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 | |
| 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
* 🎨
* 🎨
| -rw-r--r-- | packages/frontend/package.json | 1 | ||||
| -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 | ||||
| -rw-r--r-- | packages/frontend/src/drag-and-drop.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/RolesEditorFormula.vue | 35 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/server-rules.vue | 46 | ||||
| -rw-r--r-- | packages/frontend/src/pages/channel-editor.vue | 38 | ||||
| -rw-r--r-- | packages/frontend/src/pages/page-editor/page-editor.blocks.vue | 33 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/emoji-palette.palette.vue | 25 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/navbar.vue | 27 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/profile.vue | 32 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/widgets.vue | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 26 |
14 files changed, 457 insertions, 186 deletions
diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f04906fd16..d5fdeed249 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -75,7 +75,6 @@ "v-code-diff": "1.13.1", "vite": "7.3.0", "vue": "3.5.26", - "vuedraggable": "next", "wanakana": "5.3.1" }, "devDependencies": { 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 { diff --git a/packages/frontend/src/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts index 670912241e..725e7a70b3 100644 --- a/packages/frontend/src/drag-and-drop.ts +++ b/packages/frontend/src/drag-and-drop.ts @@ -9,6 +9,7 @@ type DragDataMap = { driveFiles: Misskey.entities.DriveFile[]; driveFolders: Misskey.entities.DriveFolder[]; deckColumn: string; + MkDraggable: { item: { id: string }; instanceId: string; group: string; }; }; // NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要 diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 9d9db9158d..9eeedd5c1d 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.header"> <MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect"> </MkSelect> - <button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle"> + <button v-if="draggable" class="_button" :class="$style.dragHandle" :draggable="true" @dragstart.stop="dragStartCallback"> <i class="ti ti-menu-2"></i> </button> <button v-if="draggable" class="_button" :class="$style.remove" @click="removeSelf"> @@ -17,14 +17,27 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-if="type === 'and' || type === 'or'" class="_gaps"> - <Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5"> - <template #item="{element}"> + <MkDraggable + v-model="v.values" + direction="vertical" + withGaps + canNest + manualDragStart + group="roleFormula" + > + <template #default="{ item, dragStart }"> <div :class="$style.item"> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/> + <RolesEditorFormula + :modelValue="item" + :dragStartCallback="dragStart" + draggable + @update:modelValue="updated => valuesItemUpdated(updated)" + @remove="removeItem(item.id)" + /> </div> </template> - </Sortable> + </MkDraggable> <MkButton rounded style="margin: 0 auto;" @click="addValue"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> </div> @@ -45,18 +58,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; +import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import { genId } from '@/utility/id.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/utility/clone.js'; import { rolesCache } from '@/cache.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const emit = defineEmits<{ (ev: 'update:modelValue', value: any): void; (ev: 'remove'): void; @@ -65,6 +77,7 @@ const emit = defineEmits<{ const props = defineProps<{ modelValue: any; draggable?: boolean; + dragStartCallback?: (ev: DragEvent) => void; }>(); const v = ref(deepClone(props.modelValue)); @@ -132,8 +145,8 @@ function valuesItemUpdated(item) { v.value.values[i] = item; } -function removeItem(item) { - v.value.values = v.value.values.filter(_item => _item.id !== item.id); +function removeItem(itemId) { + v.value.values = v.value.values.filter(_item => _item.id !== itemId); } function removeSelf() { diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index d26f02b41c..b192129548 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -12,28 +12,25 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div><SearchText>{{ i18n.ts._serverRules.description }}</SearchText></div> - <Sortable + <MkDraggable v-model="serverRules" - class="_gaps_m" - :itemKey="(_, i) => i" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" + withGaps + manualDragStart > - <template #item="{element,index}"> + <template #default="{ item, index, dragStart }"> <div :class="$style.item"> <div :class="$style.itemHeader"> <div :class="$style.itemNumber" v-text="String(index + 1)"/> - <span :class="$style.itemHandle"><i class="ti ti-menu"/></span> - <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button> + <span :class="$style.itemHandle" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"/></span> + <button class="_button" :class="$style.itemRemove" @click="remove(item.id)"><i class="ti ti-x"></i></button> </div> - <MkInput v-model="serverRules[index]"/> + <MkInput :modelValue="item.text" @update:modelValue="serverRules[index].text = $event"/> </div> </template> - </Sortable> + </MkDraggable> <div :class="$style.commands"> - <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton rounded @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> @@ -42,28 +39,31 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { ref } from 'vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); +const serverRules = ref<{ text: string; id: string; }[]>(instance.serverRules.map(text => ({ text, id: Math.random().toString() }))); -const serverRules = ref<string[]>(instance.serverRules); - -const save = async () => { +async function save() { await os.apiWithDialog('admin/update-meta', { - serverRules: serverRules.value, + serverRules: serverRules.value.map(r => r.text), }); fetchInstance(true); -}; +} -const remove = (index: number): void => { - serverRules.value.splice(index, 1); -}; +function add(): void { + serverRules.value.push({ text: '', id: Math.random().toString() }); +} + +function remove(id: string): void { + serverRules.value = serverRules.value.filter(r => r.id !== id); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 251f5d557d..16b01937c0 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -41,20 +41,19 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton> - <Sortable - v-model="pinnedNotes" - itemKey="id" - :handle="'.' + $style.pinnedNoteHandle" - :animation="150" + <MkDraggable + :modelValue="pinnedNoteIds.map(id => ({ id }))" + direction="vertical" + @update:modelValue="v => pinnedNoteIds = v.map(x => x.id)" > - <template #item="{element,index}"> + <template #default="{ item }"> <div :class="$style.pinnedNote"> <button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button> - {{ element.id }} - <button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button> + {{ item.id }} + <button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(item.id)"><i class="ti ti-x"></i></button> </div> </template> - </Sortable> + </MkDraggable> </div> </MkFolder> @@ -68,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch, defineAsyncComponent } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -81,10 +80,9 @@ import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { useRouter } from '@/router.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const router = useRouter(); const props = defineProps<{ @@ -99,7 +97,7 @@ const bannerId = ref<string | null>(null); const color = ref('#000'); const isSensitive = ref(false); const allowRenoteToExternal = ref(true); -const pinnedNotes = ref<{ id: Misskey.entities.Note['id'] }[]>([]); +const pinnedNoteIds = ref<Misskey.entities.Note['id'][]>([]); watch(() => bannerId.value, async () => { if (bannerId.value == null) { @@ -123,9 +121,7 @@ async function fetchChannel() { bannerId.value = result.bannerId; bannerUrl.value = result.bannerUrl; isSensitive.value = result.isSensitive; - pinnedNotes.value = result.pinnedNoteIds.map(id => ({ - id, - })); + pinnedNoteIds.value = result.pinnedNoteIds; color.value = result.color; allowRenoteToExternal.value = result.allowRenoteToExternal; @@ -143,13 +139,11 @@ async function addPinnedNote() { const note = await os.apiWithDialog('notes/show', { noteId: fromUrl ?? value, }); - pinnedNotes.value = [{ - id: note.id, - }, ...pinnedNotes.value]; + pinnedNoteIds.value.unshift(note.id); } -function removePinnedNote(index: number) { - pinnedNotes.value.splice(index, 1); +function removePinnedNote(id: string) { + pinnedNoteIds.value = pinnedNoteIds.value.filter(x => x !== id); } function save() { @@ -166,7 +160,7 @@ function save() { os.apiWithDialog('channels/update', { ...params, channelId: props.channelId, - pinnedNoteIds: pinnedNotes.value.map(x => x.id), + pinnedNoteIds: pinnedNoteIds.value, }); } else { os.apiWithDialog('channels/create', params).then(created => { diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue index f191320180..f18eb7e4f0 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -4,36 +4,41 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{element}"> - <div :class="$style.item"> +<MkDraggable + :modelValue="modelValue" + direction="vertical" + withGaps + canNest + group="pageBlocks" + @update:modelValue="v => emit('update:modelValue', v)" +> + <template #default="{ item }"> + <div> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/> + <component :is="getComponent(item.type) as any" :modelValue="item" @update:modelValue="updateItem" @remove="() => removeItem(item)"/> </div> </template> -</Sortable> +</MkDraggable> </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import XSection from './els/page-editor.el.section.vue'; import XText from './els/page-editor.el.text.vue'; import XImage from './els/page-editor.el.image.vue'; import XNote from './els/page-editor.el.note.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; -function getComponent(type: string) { +function getComponent(type: Misskey.entities.Page['content'][number]['type']) { switch (type) { case 'section': return XSection; case 'text': return XText; case 'image': return XImage; case 'note': return XNote; - default: return null; + default: return XText; } } -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ modelValue: Misskey.entities.Page['content']; }>(); @@ -61,11 +66,3 @@ function removeItem(el) { emit('update:modelValue', newValue); } </script> - -<style lang="scss" module> -.item { - & + .item { - margin-top: 16px; - } -} -</style> diff --git a/packages/frontend/src/pages/settings/emoji-palette.palette.vue b/packages/frontend/src/pages/settings/emoji-palette.palette.vue index b624d424f3..0282f62fb8 100644 --- a/packages/frontend/src/pages/settings/emoji-palette.palette.vue +++ b/packages/frontend/src/pages/settings/emoji-palette.palette.vue @@ -18,19 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <div v-panel style="border-radius: 6px;"> - <Sortable - v-model="emojis" + <MkDraggable + :modelValue="emojis.map(emoji => ({ id: emoji, emoji }))" + direction="horizontal" :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - :group="{ name: 'SortableEmojiPalettes' }" + group="emojiPalettes" + @update:modelValue="v => emojis = v.map(x => x.emoji)" > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="remove(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> + <template #default="{ item }"> + <button class="_button" :class="$style.emojisItem" @click="remove(item.emoji, $event)"> + <!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる --> + <MkCustomEmoji v-if="item.emoji[0] === ':'" style="pointer-events: none;" :name="item.emoji" :normal="true" :fallbackToImage="true"/> + <MkEmoji v-else style="pointer-events: none;" :emoji="item.emoji" :normal="true"/> </button> </template> <template #footer> @@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-plus"></i> </button> </template> - </Sortable> + </MkDraggable> </div> <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> </div> @@ -47,7 +46,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; -import Sortable from 'vuedraggable'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -55,6 +53,7 @@ import { deepClone } from '@/utility/clone.js'; import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import MkEmoji from '@/components/global/MkEmoji.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index baa8fdc967..880e4321ed 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -9,25 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSlot> <template #label>{{ i18n.ts.navbar }}</template> <MkContainer :showHeader="false"> - <Sortable + <MkDraggable v-model="items" - itemKey="id" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" > - <template #item="{element,index}"> + <template #default="{ item }"> <div - v-if="element.type === '-' || navbarItemDef[element.type]" + v-if="item.type === '-' || navbarItemDef[item.type]" :class="$style.item" > <button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button> - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span> - <button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item.type]?.title ?? i18n.ts.divider }}</span> + <button class="_button" :class="$style.itemRemove" @click="removeItem(item.id)"><i class="ti ti-x"></i></button> </div> </template> - </Sortable> + </MkDraggable> </MkContainer> </FormSlot> <div class="_buttons"> @@ -54,13 +50,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import { computed, ref } from 'vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSlot from '@/components/form/slot.vue'; import MkContainer from '@/components/MkContainer.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { store } from '@/store.js'; @@ -70,8 +67,6 @@ import { prefer } from '@/preferences.js'; import { getInitialPrefValue } from '@/preferences/manager.js'; import { genId } from '@/utility/id.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const items = ref(prefer.s.menu.map(x => ({ id: genId(), type: x, @@ -98,8 +93,8 @@ async function addItem() { }]; } -function removeItem(index: number) { - items.value.splice(index, 1); +function removeItem(itemId: string) { + items.value = items.value.filter(i => i.id !== itemId); } function save() { diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 8e4c39c8bb..27a1ed279b 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -75,30 +75,27 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.metadataRoot" class="_gaps_s"> <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> - <Sortable + <MkDraggable v-model="fields" - class="_gaps_s" - itemKey="id" - :animation="150" - :handle="'.' + $style.dragItemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" + withGaps + manualDragStart > - <template #item="{element, index}"> + <template #default="{ item, dragStart }"> <div v-panel :class="$style.fieldDragItem"> - <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> - <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> + <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"></i></button> + <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(item.id)"><i class="ti ti-x"></i></button> <div :class="$style.dragItemForm"> <FormSplit :minWidth="200"> - <MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel"> + <MkInput v-model="item.name" small :placeholder="i18n.ts._profile.metadataLabel"> </MkInput> - <MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent"> + <MkInput v-model="item.value" small :placeholder="i18n.ts._profile.metadataContent"> </MkInput> </FormSplit> </div> </div> </template> - </Sortable> + </MkDraggable> </div> </MkFolder> <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> @@ -165,7 +162,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import { computed, reactive, ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -174,6 +171,7 @@ import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; import FormLink from '@/components/form/link.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -188,8 +186,6 @@ import { genId } from '@/utility/id.js'; const $i = ensureSignin(); -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const reactionAcceptance = store.model('reactionAcceptance'); function assertVaildLang(lang: string | null): lang is keyof typeof langmap { @@ -228,8 +224,8 @@ while (fields.value.length < 4) { addField(); } -function deleteField(index: number) { - fields.value.splice(index, 1); +function deleteField(itemId: string) { + fields.value = fields.value.filter(f => f.id !== itemId); } function saveFields() { diff --git a/packages/frontend/src/ui/_common_/widgets.vue b/packages/frontend/src/ui/_common_/widgets.vue index 1a6d62e19b..ed084af6ee 100644 --- a/packages/frontend/src/ui/_common_/widgets.vue +++ b/packages/frontend/src/ui/_common_/widgets.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="editMode = false"/> <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> - <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> + <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em; margin-top: 16px;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> </div> </template> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c866ec47a..82cfbb6891 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -833,9 +833,6 @@ importers: vue: specifier: 3.5.26 version: 3.5.26(typescript@5.9.3) - vuedraggable: - specifier: next - version: 4.1.0(vue@3.5.26(typescript@5.9.3)) wanakana: specifier: 5.3.1 version: 5.3.1 @@ -4933,10 +4930,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.49.0': - resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.50.1': resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -10107,9 +10100,6 @@ packages: resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} engines: {node: '>=0.10.0'} - sortablejs@1.14.0: - resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -11114,11 +11104,6 @@ packages: typescript: optional: true - vuedraggable@4.1.0: - resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} - peerDependencies: - vue: ^3.0.1 - w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -15278,7 +15263,7 @@ snapshots: '@stylistic/eslint-plugin@5.5.0(eslint@9.39.2)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/types': 8.50.1 eslint: 9.39.2 eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -15972,8 +15957,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.49.0': {} - '@typescript-eslint/types@8.50.1': {} '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': @@ -22285,8 +22268,6 @@ snapshots: dependencies: is-plain-obj: 1.1.0 - sortablejs@1.14.0: {} - source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -23270,11 +23251,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vuedraggable@4.1.0(vue@3.5.26(typescript@5.9.3)): - dependencies: - sortablejs: 1.14.0 - vue: 3.5.26(typescript@5.9.3) - w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 |