diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2026-01-07 21:46:03 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-07 21:46:03 +0900 |
| commit | 8c5572dd3ba11104493d8386fe56cb6ff96cfc56 (patch) | |
| tree | 440ca5048723e10258ad84729f095b60b2d4fe70 /packages/frontend/src/components | |
| parent | Update README.md (diff) | |
| download | misskey-8c5572dd3ba11104493d8386fe56cb6ff96cfc56.tar.gz misskey-8c5572dd3ba11104493d8386fe56cb6ff96cfc56.tar.bz2 misskey-8c5572dd3ba11104493d8386fe56cb6ff96cfc56.zip | |
enhance(frontend): remove vuedraggable (#17073)
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update page-editor.blocks.vue
* Update MkDraggable.vue
* refactor
* refactor
* ✌️
* refactor
* Update MkDraggable.vue
* ios
* 🎨
* 🎨
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkDraggable.vue | 310 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostFormAttaches.vue | 29 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkWidgets.vue | 38 |
3 files changed, 339 insertions, 38 deletions
diff --git a/packages/frontend/src/components/MkDraggable.vue b/packages/frontend/src/components/MkDraggable.vue new file mode 100644 index 0000000000..7075306dd4 --- /dev/null +++ b/packages/frontend/src/components/MkDraggable.vue @@ -0,0 +1,310 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + tag="div" + :enterActiveClass="$style.transition_items_enterActive" + :leaveActiveClass="$style.transition_items_leaveActive" + :enterFromClass="$style.transition_items_enterFrom" + :leaveToClass="$style.transition_items_leaveTo" + :moveClass="$style.transition_items_move" + :class="[$style.items, { [$style.dragging]: dragging, [$style.horizontal]: direction === 'horizontal', [$style.vertical]: direction === 'vertical', [$style.withGaps]: withGaps, [$style.canNest]: canNest }]" +> + <slot name="header"></slot> + <div + v-if="modelValue.length === 0" + :class="$style.emptyDropArea" + @dragover.prevent.stop="() => {}" + @dragleave="() => {}" + @drop.prevent.stop="onEmptyDrop($event)" + > + </div> + <div + v-for="(item, i) in modelValue" + :key="item.id" + :class="$style.item" + :draggable="!manualDragStart" + @dragstart.stop="onDragstart($event, item)" + > + <div + :class="[$style.forwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'forward' }]" + @dragover.prevent.stop="onDragover($event, item, false)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, false)" + ></div> + <div style="position: relative; z-index: 0;"> + <slot :item="item" :index="i" :dragStart="(ev) => onDragstart(ev, item)"></slot> + </div> + <div + :class="[$style.backwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'backward' }]" + @dragover.prevent.stop="onDragover($event, item, true)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, true)" + ></div> + </div> + <slot name="footer"></slot> +</TransitionGroup> +</template> + +<script lang="ts"> +import { ref } from 'vue'; + +// 別々のコンポーネントインスタンス間でD&Dを融通するためにグローバルに状態を持っておく必要がある +const dragging = ref(false); +let dropCallback: ((targetInstanceId: string) => void) | null = null; +</script> + +<script lang="ts" setup generic="T extends { id: string; }"> +import { nextTick } from 'vue'; +import { getDragData, setDragData } from '@/drag-and-drop.js'; +import { genId } from '@/utility/id.js'; + +const slots = defineSlots<{ + default(props: { item: T; index: number; dragStart: (ev: DragEvent) => void }): any; + header(): any; + footer(): any; +}>(); + +const props = withDefaults(defineProps<{ + modelValue: T[]; + direction: 'horizontal' | 'vertical'; + group?: string | null; + manualDragStart?: boolean; + withGaps?: boolean; + canNest?: boolean; +}>(), { + group: null, + manualDragStart: false, + withGaps: false, + canNest: false, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: T[]): void; +}>(); + +const dropReadyArea = ref<[T['id'] | null, 'forward' | 'backward' | null]>([null, null]); +const instanceId = genId(); +const group = props.group ?? instanceId; + +function onDragstart(ev: DragEvent, item: T) { + if (ev.dataTransfer == null) return; + ev.dataTransfer.effectAllowed = 'move'; + setDragData(ev, 'MkDraggable', { item, instanceId, group }); + + const target = ev.target as HTMLElement; + target.addEventListener('dragend', (ev) => { + dragging.value = false; + dropReadyArea.value = [null, null]; + }, { once: true }); + + dropCallback = (targetInstanceId) => { + if (targetInstanceId === instanceId) return; + const newValue = props.modelValue.filter(x => x.id !== item.id); + emit('update:modelValue', newValue); + }; + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + window.setTimeout(() => { + dragging.value = true; + }, 10); +} + +function onDragover(ev: DragEvent, item: T, backward: boolean) { + nextTick(() => { + dropReadyArea.value = [item.id, backward ? 'backward' : 'forward']; + }); +} + +function onDragleave(ev: DragEvent, item: T) { + dropReadyArea.value = [null, null]; +} + +function onDrop(ev: DragEvent, item: T, backward: boolean) { + const dragged = getDragData(ev, 'MkDraggable'); + dropReadyArea.value = [null, null]; + if (dragged == null || dragged.group !== group || dragged.item.id === item.id) return; + dropCallback?.(instanceId); + + const fromIndex = props.modelValue.findIndex(x => x.id === dragged.item.id); + let toIndex = props.modelValue.findIndex(x => x.id === item.id); + + const newValue = [...props.modelValue]; + if (fromIndex > -1) newValue.splice(fromIndex, 1); + toIndex = newValue.findIndex(x => x.id === item.id); + if (backward) toIndex += 1; + newValue.splice(toIndex, 0, dragged.item as T); + + emit('update:modelValue', newValue); +} + +function onEmptyDrop(ev: DragEvent) { + const dragged = getDragData(ev, 'MkDraggable'); + if (dragged == null) return; + dropCallback?.(instanceId); + + emit('update:modelValue', [dragged.item as T]); +} +</script> + +<style lang="scss" module> +.transition_items_move, +.transition_items_enterActive, +.transition_items_leaveActive { + transition: all 0.15s ease; +} +.transition_items_enterFrom, +.transition_items_leaveTo { + opacity: 0; +} +.transition_items_leaveActive { + position: absolute; +} + +.items { + display: flex; + align-items: center; + justify-content: left; + flex-wrap: wrap; +} + +.items.horizontal { + flex-direction: row; +} +.items.vertical { + flex-direction: column; +} + +.item { + position: relative; +} + +.items.vertical .item { + width: 100%; +} + +.items.horizontal.withGaps { + row-gap: var(--MI-margin); +} + +.items.horizontal.withGaps .item { + padding-left: calc(var(--MI-margin) / 2); + padding-right: calc(var(--MI-margin) / 2); +} + +.items.vertical.withGaps .item { + padding-top: calc(var(--MI-margin) / 2); + padding-bottom: calc(var(--MI-margin) / 2); +} + +.forwardArea, .backwardArea { + position: absolute; + z-index: 1; + pointer-events: none; +} + +.items.dragging { + .forwardArea, .backwardArea { + pointer-events: auto; + } +} + +.items.horizontal { + .forwardArea { + top: 0; + left: 0; + width: 50%; + height: 100%; + } + + .backwardArea { + top: 0; + right: 0; + width: 50%; + height: 100%; + } +} + +.items.vertical { + .forwardArea { + top: 0; + left: 0; + width: 100%; + height: 50%; + } + + .backwardArea { + bottom: 0; + left: 0; + width: 100%; + height: 50%; + } +} + +.items.canNest.horizontal { + .forwardArea, .backwardArea { + width: 30px; + } +} + +.items.canNest.vertical { + .forwardArea, .backwardArea { + height: 30px; + } +} + +.dropReady::before { + content: ''; + position: absolute; + z-index: 99999; + background: var(--MI_THEME-accent); + border-radius: 999px; + pointer-events: none; +} + +.items.horizontal { + .forwardArea.dropReady::before { + top: 0; + left: -1px; + width: 2px; + height: 100%; + } + + .backwardArea.dropReady::before { + top: 0; + right: -1px; + width: 2px; + height: 100%; + } +} + +.items.vertical { + .forwardArea.dropReady::before { + top: -1px; + left: 0; + width: 100%; + height: 2px; + } + + .backwardArea.dropReady::before { + bottom: -1px; + left: 0; + width: 100%; + height: 2px; + } +} + +.items.horizontal .emptyDropArea { + width: 40px; + height: 40px; +} + +.items.vertical .emptyDropArea { + width: 100%; + height: 50px; +} +</style> diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index f429db94df..d198c98404 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-show="props.modelValue.length != 0" :class="$style.root"> - <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{ element }"> + <MkDraggable + :modelValue="props.modelValue" + :class="$style.files" + direction="horizontal" + withGaps + @update:modelValue="v => emit('update:modelValue', v)" + > + <template #default="{ item }"> <div :class="$style.file" role="button" tabindex="0" - @click="showFileMenu(element, $event)" - @keydown.space.enter="showFileMenu(element, $event)" - @contextmenu.prevent="showFileMenu(element, $event)" + @click="showFileMenu(item, $event)" + @keydown.space.enter="showFileMenu(item, $event)" + @contextmenu.prevent="showFileMenu(item, $event)" > - <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> - <div v-if="element.isSensitive" :class="$style.sensitive"> + <!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる --> + <MkDriveFileThumbnail style="pointer-events: none;" :data-id="item.id" :class="$style.thumbnail" :file="item" fit="cover"/> + <div v-if="item.isSensitive" :class="$style.sensitive" style="pointer-events: none;"> <i class="ti ti-eye-exclamation" style="margin: auto;"></i> </div> </div> </template> - </Sortable> + </MkDraggable> <p :class="[$style.remain, { [$style.exceeded]: props.modelValue.length > 16, @@ -33,11 +40,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, inject } from 'vue'; +import { inject } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -45,8 +53,6 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ modelValue: Misskey.entities.DriveFile[]; detachMediaFn?: (id: string) => void; @@ -221,7 +227,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar position: relative; width: 64px; height: 64px; - margin-right: 4px; border-radius: 4px; overflow: hidden; cursor: move; diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index cf7c2cda80..e7712e8aae 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div :class="$style.root" class="_gaps_s"> <template v-if="edit"> <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> @@ -13,25 +13,21 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> </header> - <Sortable + <MkDraggable :modelValue="props.widgets" - itemKey="id" - handle=".handle" - :animation="150" - :group="{ name: 'SortableMkWidgets' }" - :class="$style.editEditing" + direction="vertical" + withGaps + group="MkWidgets" @update:modelValue="v => emit('updateWidgets', v)" > - <template #item="{element}"> + <template #default="{ item }"> <div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container> - <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> - <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> - <div class="handle"> - <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> - </div> + <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(item.id)"><i class="ti ti-settings"></i></button> + <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button> + <component :is="`widget-${item.name}`" :ref="el => widgetRefs[item.id] = el" :class="$style.customizeContainerHandleWidget" :widget="item" @updateProps="updateWidget(item.id, $event)"/> </div> </template> - </Sortable> + </MkDraggable> </template> <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> @@ -49,19 +45,18 @@ export type DefaultStoredWidget = { </script> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { computed } from 'vue'; import { isLink } from '@@/js/is-link.js'; import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ widgets: Widget[]; edit: boolean; @@ -142,11 +137,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { .widget { contain: content; - margin: var(--MI-margin) 0; - - &:first-of-type { - margin-top: 0; - } } .edit { @@ -158,10 +148,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { padding: 4px; } } - - &Editing { - min-height: 100px; - } } .customizeContainer { |