Svelte5でSortableJSを使用したメモ
エリア間でシームレスなドラッグ&ドロップができるコンポーネントを作成したかったのですが、標準のドラッグ&ドロップAPIではドラッグ中のカーソルスタイルが変更できないことが判明したため完全に自分で作成するのは断念しました。代替としてSortableJSライブラリを使用することにしたので、SortableJSを使用したSvelte5のコンポーネントを作成方法を備忘として残しておきます。
(2024/12/07追記)
もう少しSvelteのReactivityシステムと融和したものを作る必要に迫られたのでもう少し汎用性を高めてActionとclassだけで制御するバージョンを作成しました。ownership_invalid_mutation
の警告がかなり出ますが、一応動作します。
作成したコンポーネント
REPL
汎用版
試作版
コード全体
code (class&action)
<script module lang="ts">
import { sortable, SortableOperator, type ListItem } from "./Sortable.svelte";
</script>
<script lang="ts">
let select: string[] = $state([]);
let result = $state([]);
const source: ListItem[] = [
{key: "0", value: "foo"},
{key: "1", value: "bar"},
{key: "2", value: "baz"},
{key: "3", value: "qux"},
{key: "4", value: "quux"},
];
const oper = new SortableOperator();
const option = {
animation: 150,
onEnd(ev: SortableEvent) {
const targetKey = ev.item.dataset.id;
if (targetKey === undefined) { return; }
for (const key of oper.keys.filter(x => select.includes(x as string) && x !== targetKey).toReversed()) {
oper.rawMove(key, targetKey, true);
}
select = [];
},
};
oper.prep(source, option);
function set() { oper.set("2", "set_value"); }
function add() { oper.set("9", "add_value"); }
function ins() { oper.insert("10", "insert_value", "3"); }
function del() { oper.delete("9"); }
function show() { result = oper.getSorted().map(x => `${x.key}: ${x.value}`); }
function oncontextmenuF(key: string): (ev: MouseEvent)=>void {
return (ev) => { ev.preventDefault(); if (select.includes(key)) {select = select.filter(x=>x!==key);} else {select.push(key);} }
}
</script>
<ul use:sortable={oper}>
{#each oper.bind as {key, value} (key)}
<li data-id={key} class={select.includes(key) ? "highlight":""} oncontextmenu={oncontextmenuF(key)}>{value}</li>
{/each}
</ul>
<button type="button" onclick={set}>set</button>
<button type="button" onclick={add}>add</button>
<button type="button" onclick={ins}>insert</button>
<button type="button" onclick={del}>delete</button>
<button type="button" onclick={show}>show current</button>
<p>{result}</p>
<style>
li { cursor: default; }
.highlight {
border-width: 1px;
border-style: solid;
border-color: #fff;
}
</style>
<script module lang="ts">
import Sortable, { MultiDrag, Swap } from "https://unpkg.com/sortablejs?module";
import { type Options as SortableOptions } from "https://unpkg.com/@types/sortablejs@1.15.8/index.d.ts";
import { type ActionReturn } from "svelte/action";
export type ListItem = { key: unknown, value: unknown };
export function sortable(node: HTMLElement, operator: SortableOperator): ActionReturn {
operator.new(node);
return { destroy: () => operator.cleanup() };
}
export class SortableOperator {
#elem: HTMLElement | undefined;
#main: Sortable | undefined = undefined;
#bind: ListItem[] = $state([]);
#options: SortableOptions = {};
#map: Map<unknown, unknown> = new Map();
#keys: unknown[] = [];
get bind(): ListItem[] { return this.#bind; }
get keys(): unknown[] { return this.#keys; }
constructor(list?: ListItem[], options?: SortableOptions) {
if (list !== undefined && options !== undefined) { this.#setProperties(list, options); }
}
prep(list: ListItem[], options: SortableOptions) { this.#setProperties(list, options); }
getSorted(): ListItem[] {
return this.#keys.map(key => { return {key, value: this.#map.get(key)}; });
}
get(key: unknown): unknown { return this.#map.get(key); }
set(key: unknown, value: unknown) {
if (this.#map.has(key)) {
this.#bind[this.#index(key)].value = value;
} else {
this.#bind.push({key, value});
this.#keys.push(key);
}
this.#map.set(key, value);
}
delete(key: unknown) {
if (!this.#map.has(key)) { return; }
this.#bind.splice(this.#index(key), 1);
this.#keys = this.#keys.filter(x => x !== key);
this.#map.delete(key);
}
insert(key: unknown, value: unknown, targetKey: unknown, after?: boolean) {
if (this.#map.has(key) || !this.#map.has(targetKey)) { return; }
const offset = after ? 1 : 0;
this.#bind.splice(this.#index(targetKey)+offset, 0, {key, value});
this.#keys.splice(this.#keys.findIndex(x => x===targetKey)+offset, 0, key);
this.#map.set(key, value);
}
move(key: unknown, targetKey: unknown, after?: boolean) {
this.rawMove(key, targetKey, after);
this.sync();
}
rawMove(key: unknown, targetKey: unknown, after?: boolean) {
if (!this.#map.has(key) || !this.#map.has(targetKey)) { return; }
const move = this.#getChild(key);
const target = this.#getChild(targetKey, after);
if (!move || !target) { return; }
this.#elem?.insertBefore(move, target);
}
sync() { if (this.#main) { this.#keys = this.#main.toArray(); } }
//---------- No needs to use manually ----------//
new(node: HTMLElement) {
this.#elem = node;
if (this.#options.multiDrag) { SortableOperator.addMultiDrag(); }
if (this.#options.swap) { SortableOperator.addSwap(); }
this.#main = new Sortable(node, this.#options);
}
cleanup() { this.#main?.destroy(); }
#index(key: unknown): number {
return this.#bind.findIndex(x => x.key === key);
}
#setProperties(list: ListItem[], options: SortableOptions) {
this.#keys = Array.from(new Set(list.map(x => x.key)));
this.#bind = list.map(({key, value}) => { return { key, value }; });
this.#options = this.#addSyncEvent(options);
this.#map = new Map(list.map(x => [x.key, x.value]));
this.#resetIfExistDuplicateKeys();
}
#addSyncEvent(options: SortableOptions) {
const TRIGGER = "onEnd";
if (Object.keys(options).includes(TRIGGER)) {
const fn = options[TRIGGER]!;
options[TRIGGER] = (ev: SortableEvent) => { fn(ev); this.sync(); }
} else {
options[TRIGGER] = this.sync;
}
return options;
}
#resetIfExistDuplicateKeys() {
if (this.#keys.length !== this.#bind.length) {
this.#keys = [];
this.#bind = [];
this.#map.clear();
}
}
#getChild(id: unknown, next?: boolean): HTMLElement | undefined {
if (!this.#elem) { return; }
const l = this.#elem.children.length;
const attr = this.#options.dataIdAttr ?? "data-id";
for (let i=0; i<l; i++) {
const el = this.#elem.children[i] as HTMLElement;
if (el.getAttribute(attr) === id) {
return next ? this.#elem.children[i+1] as HTMLElement | undefined : el;
}
}
}
static addMultiDrag() { try { Sortable.mount(new MultiDrag()); } catch (e) {} }
static addSwap() { try { Sortable.mount(new Swap()); } catch (e) {} }
}
</script>
code (prototype)
<script module lang="ts">
import DnD, {type AreaObject, SortableOperator} from "./DnD.svelte";
</script>
<script lang="ts">
const initArea: AreaObject = {
area1: ["AAA", "BBB"],
area2: ["CCC", "DDD", "EEE"],
};
let show: AreaObject = $state(initArea);
function update(areas: AreaObject) {
show = areas;
}
const operator = new SortableOperator(initArea, update);
</script>
<p>Area1: {show.area1}</p>
<p>Area2: {show.area2}</p>
<DnD {operator} />
<script module lang="ts">
import Sortable from "https://unpkg.com/sortablejs?module";
export type Props = {
operator: SortableOperator,
};
export type AreaObject = {area1: string[], area2: string[]};
export type UpdateHandler = (areas: AreaObject) => void;
export class SortableOperator {
#initial: {area1: string[], area2: string[]};
update: UpdateHandler;
#sortable: {area1?: Sortable, area2?: Sortable};
constructor(areas: AreaObject, update: UpdateHandler) {
this.#initial = areas;
this.update = update;
this.#sortable = {};
}
init(area1: HTMLElement, area2: HTMLElement, options: SortableOptions) {
this.#sortable = {
area1: Sortable.create(area1, options),
area2: Sortable.create(area2, options),
};
}
getLatest(): AreaObject {
return {
area1: this.#sortable.area1?.toArray() ?? [],
area2: this.#sortable.area2?.toArray() ?? [],
}
}
get initial(): AreaObject { return this.#initial; }
}
</script>
<script lang="ts">
const { operator }: Props = $props();
let area1: HTMLElement, area2: HTMLElement;
let dragging = $state(false);
const options: SortableOptions = {
group: "share",
animation: 150,
dataIdAttr: "data-id",
swapThreshold: 0.5,
forceFallback: true,
onStart,
onEnd
};
$effect(() => {
operator.init(area1, area2, options);
});
function onStart(ev: Sortable.SortableEvent) {
dragging = true;
}
function onEnd(ev: Sortable.SortableEvent) {
operator.update(operator.getLatest());
dragging = false;
}
</script>
<!----------------------------->
<div class="whole">
<ul bind:this={area1} class="area {dragging ? "dragging" : ""}">
{#each operator.initial.area1 as item}
<li class="item {dragging ? "dragging" : "draggable"}" data-id={item}>{item}</li>
{/each}
</ul>
<ul bind:this={area2} class="area {dragging ? "dragging" : ""}">
{#each operator.initial.area2 as item}
<li class="item {dragging ? "dragging" : "draggable"}" data-id={item}>{item}</li>
{/each}
</ul>
</div>
<!----------------------------->
<style>
.whole {
margin: 0.5rem;
}
.area {
padding: 0.5rem;
min-height: 3.5rem;
display: flex;
flex-wrap: wrap;
justify-content: start;
align-items: center;
gap: 0.5rem;
border-style: solid;
border-width: 1px;
border-color: rgb(59 130 246);
}
.item {
width: 2.5rem;
height: 2.5rem;
display: flex;
justify-content: center;
align-items: center;
border-style: solid;
border-width: 1px;
border-radius: 0.25rem;
user-select: none;
}
.draggable {
cursor: grab;
}
.dragging {
cursor: grabbing;
}
</style>
簡単な解説
SortableJSの使用
Svelteのプロジェクトで通常通り以下コマンドで使用可能。TypeScriptを使用している場合は追加的に型情報をインストールする必要があり、tsconfig.json
にオプションを追加する必要がある。
npm install --save sortablejs
npm install --save-dev @types/sortablejs
{
"compilerOptions": {
"resolvePackageJsonExports": false
}
}
Svelte自動更新システムとの共存
SortableJSはVanillaJSでの使用が想定されておりライブラリが独自に要素を更新している。Svelteプロジェクトで使用する際、単純に使用するとSvelteの要素更新システムと競合が発生する場合があり、想定外に要素が増減するなど意図しない挙動になる場合がある。これを回避するためにコンポーネント外との境界に位置するSortableJSに関する変数や処理をクラスに閉じ込め、それらをSvelteの更新システムが関知しないようにした。そのようにコンポーネント内のSortableJSに関する部分をSvelteの更新システムから隔離することによって、SortableJSにとってVanillaJSのような環境を作り出し意図しない挙動を防止した。
汎用版は競合部分の挙動を確かめたうえで、各メソッドを呼び出す限りにおいて描画と取得可能なデータがずれないように調整した。ただし、SortableOption
のイベント内で各メソッドを組み合わせて使用する場合は動作しない場合がある。(onEnd
イベント内でdelete
とinsert
を続けて呼び出す等)
操作中のカーソル変更
Chromium系ブラウザ標準のドラッグ&ドロップAPIでは操作中のカーソルを変更することができない。正確にはドロップ不可エリア上にカーソルが位置するとカーソルが禁止マークへ変化することを抑制できず、その後はカーソル変更の指定がなかったかのように標準のカーソル遷移に戻る。SortableJSも特に指定しない場合、標準のドラッグ&ドロップAPIを使用するため挙動は変わらない。これを回避するためにはSortableOptions
のforceFallback
プロパティをtrue
にする必要がある。true
にするとDataTransfer.setData
等のブラウザ標準のドラッグ&ドロップAPIが全く使用できなくなるため注意する。
カーソルをgrabに変更する
通常時はcursor: grab
を要素に指定する。ドラッグ時の変更はSortableOptions
のghostClass
,chosenClass
,dragClass
で制御することも可能だが、要素だけのクラスを変更するため、要素上からカーソルが離れた場合に通常のマウスカーソルに戻ってしまう。これを回避するため、Svelteのリアクティビティを活用して動的に要素・ドロップエリア両方のクラスを変更し、ドロップエリア内でもcursor: grabbing
を維持するようにした。
注意点
ドラッグ中描画がずれる場合
forceFallback
プロパティをtrue
にした場合でもドラッグ中にドラッグアイテムが半透明で描画されるが、それがドラッグ開始位置からずれて描画されることがある。これはドラッグアイテムをposition: fixed
を用いて手動で描画制御していることが原因で、この基準点が変わっている場合にずれが起こる。具体的には祖先要素のどこかにtransform
やbackdrop-filter
等を用いている場合、その要素が基準点となってしまうために右下にずれる。これを簡単に回避するには祖先要素でそのようなCSSを用いないことしかない。それが不可能な場合はライブラリを改造してposition: absolute
を用いるように変更するしかないようだ。
所感
もともとドラッグ&ドロップAPIに詳しくなかった事もありますが、このコンポーネントを作成するために相当時間がかかりました。操作中のカーソルを手に変更することがこんなにも手間がかかるのは正直想定外でした。標準APIを用いるとsetTimeout
を用いて時間差でカーソルを変更したとしても即座に元に戻ったりしたので、絶対にカーソルを変更させない鉄の意志を感じます。
SortableJSは大丈夫そうですがライブラリはあまり使いたくないので、また時間ができた時にSortableJSの中身を見て要点をつかんだ後にSvelteのコンポーネントに書き換えれたらいいなぁと思いつつ面倒なのでこのままかもしれません。
参考文献
- Set CSS cursor while dragging
- CSS cursor when dragging
- Custom cursor with drag and drop an HTML element without libraries
- sortablejs • Playground
- SortableJS / Sortable
- Cursor changes style while dragging
- "position: fixed" の基準点は先祖要素に "transform" が設定されているとその要素になる
- jhubbardsf / svelte-sortablejs
- svelte and sortable js, how to handle a dynamic array
- False positive ownership_invalid_binding?
Discussion