@brftech/filex-core 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-DKW-HyXT.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-DKW-HyXT.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-D2HmP2yr.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-D2HmP2yr.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-DPNvOFCs.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-DPNvOFCs.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-ByqjNuNL.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-ByqjNuNL.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-2iONhbuJ.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-2iONhbuJ.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-CmiU7hIT.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-CmiU7hIT.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Inhl2yN9.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Inhl2yN9.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-C2AFFEze.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-C2AFFEze.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-YdW14q_n.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-YdW14q_n.js.map} +1 -1
- package/dist/filex-core.js +47 -25
- package/dist/filex-core.umd.cjs +40 -40
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-eARevpz0.js +9952 -0
- package/dist/index-eARevpz0.js.map +1 -0
- package/dist/index.d.ts +363 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +269 -1
- package/src/components/CommandPalette.vue +12 -0
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GridView.vue +33 -1
- package/src/components/ListView.vue +39 -6
- package/src/components/OnboardingTour.vue +332 -0
- package/src/components/OperationsCenter.vue +284 -0
- package/src/components/PendingOpsTray.vue +50 -66
- package/src/components/QuickLook.vue +142 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +52 -6
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useKeyboardShortcuts.ts +321 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/index.ts +45 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +92 -0
- package/src/locales/tr.ts +92 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +672 -0
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOperations — unified long-running-operation store (wiring:c3).
|
|
3
|
+
*
|
|
4
|
+
* One presentation-level aggregation point for everything that used to be
|
|
5
|
+
* scattered across corner UIs:
|
|
6
|
+
* - uploads (useUploadChunked jobs / legacy multipart rows)
|
|
7
|
+
* - ops-queue jobs (usePendingOps copy/move/delete worker ops)
|
|
8
|
+
* - future kinds (convert / archive — same `sync()` contract, own group)
|
|
9
|
+
*
|
|
10
|
+
* The store does NOT talk to the network. Publishers (renderless
|
|
11
|
+
* UploadProgress / PendingOpsTray adapters) reconcile their reactive source
|
|
12
|
+
* lists into the store via `sync(group, items)`; OperationsCenter renders the
|
|
13
|
+
* merged picture (badge + panel). Action callbacks (cancel/retry/dismiss)
|
|
14
|
+
* are registered per row so the panel can route user intent back to the
|
|
15
|
+
* owning surface without the store knowing any transport details.
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle rules (per the İşlem Merkezi contract):
|
|
18
|
+
* - running rows live in `active`;
|
|
19
|
+
* - done/aborted rows linger in `active` for DONE_LINGER_MS ("bitti"
|
|
20
|
+
* flash) then move to `history` (session-only, capped);
|
|
21
|
+
* - error rows are STICKY: they stay in `active` until the user
|
|
22
|
+
* dismisses or retries them — even after the source list swept them
|
|
23
|
+
* (usePendingOps drops terminal ops after its RETAIN window).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
|
|
27
|
+
|
|
28
|
+
export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive';
|
|
29
|
+
export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
|
|
30
|
+
|
|
31
|
+
/** What a publisher hands to `sync()` for one row. */
|
|
32
|
+
export interface OperationInput {
|
|
33
|
+
/** Stable id within the group (upload uuid / ops row id). */
|
|
34
|
+
id: string | number;
|
|
35
|
+
kind: OperationKind;
|
|
36
|
+
/** Primary label — file name or target path. '' → renderer falls back to the kind verb. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** 0..100, or null for indeterminate (renderer shows a spinner). */
|
|
39
|
+
percent: number | null;
|
|
40
|
+
status: OperationStatus;
|
|
41
|
+
error?: string | null;
|
|
42
|
+
/** Queue op accepted but not started yet ("Sırada"). */
|
|
43
|
+
queued?: boolean;
|
|
44
|
+
/** Progress counters for queue ops (3/5 items). */
|
|
45
|
+
doneCount?: number;
|
|
46
|
+
totalCount?: number;
|
|
47
|
+
/** Byte progress for uploads. */
|
|
48
|
+
uploadedBytes?: number;
|
|
49
|
+
totalBytes?: number;
|
|
50
|
+
cancellable?: boolean;
|
|
51
|
+
retryable?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OperationActions {
|
|
55
|
+
cancel?: () => void;
|
|
56
|
+
/** Clean the row out of the OWNING source list (called on retire/dismiss). */
|
|
57
|
+
dismiss?: () => void;
|
|
58
|
+
retry?: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Operation {
|
|
62
|
+
/** `${group}:${id}` — unique across publishers. */
|
|
63
|
+
key: string;
|
|
64
|
+
kind: OperationKind;
|
|
65
|
+
name: string;
|
|
66
|
+
percent: number | null;
|
|
67
|
+
status: OperationStatus;
|
|
68
|
+
error: string | null;
|
|
69
|
+
queued: boolean;
|
|
70
|
+
doneCount: number | null;
|
|
71
|
+
totalCount: number | null;
|
|
72
|
+
uploadedBytes: number | null;
|
|
73
|
+
totalBytes: number | null;
|
|
74
|
+
cancellable: boolean;
|
|
75
|
+
retryable: boolean;
|
|
76
|
+
startedAt: number;
|
|
77
|
+
settledAt: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DONE_LINGER_MS = 4000;
|
|
81
|
+
const HISTORY_CAP = 50;
|
|
82
|
+
|
|
83
|
+
function toOperation(key: string, input: OperationInput, prev?: Operation): Operation {
|
|
84
|
+
const terminal = input.status !== 'running';
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
kind: input.kind,
|
|
88
|
+
name: input.name,
|
|
89
|
+
percent: input.percent,
|
|
90
|
+
status: input.status,
|
|
91
|
+
error: input.error ?? null,
|
|
92
|
+
queued: input.queued ?? false,
|
|
93
|
+
doneCount: input.doneCount ?? null,
|
|
94
|
+
totalCount: input.totalCount ?? null,
|
|
95
|
+
uploadedBytes: input.uploadedBytes ?? null,
|
|
96
|
+
totalBytes: input.totalBytes ?? null,
|
|
97
|
+
cancellable: input.cancellable ?? false,
|
|
98
|
+
retryable: input.retryable ?? false,
|
|
99
|
+
startedAt: prev?.startedAt ?? Date.now(),
|
|
100
|
+
settledAt: prev?.settledAt ?? (terminal ? Date.now() : null),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useOperations() {
|
|
105
|
+
const active = ref<Operation[]>([]);
|
|
106
|
+
const history = ref<Operation[]>([]);
|
|
107
|
+
|
|
108
|
+
const actions = new Map<string, OperationActions>();
|
|
109
|
+
/** Keys already moved to history — sync must not resurrect them. */
|
|
110
|
+
const retired = new Set<string>();
|
|
111
|
+
const lingerTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
112
|
+
|
|
113
|
+
function clearTimer(key: string) {
|
|
114
|
+
const tm = lingerTimers.get(key);
|
|
115
|
+
if (tm !== undefined) {
|
|
116
|
+
clearTimeout(tm);
|
|
117
|
+
lingerTimers.delete(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Move a row from active to the session history (newest first). */
|
|
122
|
+
function retire(key: string, opts: { toHistory?: boolean } = {}) {
|
|
123
|
+
clearTimer(key);
|
|
124
|
+
const idx = active.value.findIndex((o) => o.key === key);
|
|
125
|
+
if (idx === -1) return;
|
|
126
|
+
const [op] = active.value.splice(idx, 1);
|
|
127
|
+
active.value = [...active.value];
|
|
128
|
+
retired.add(key);
|
|
129
|
+
if (opts.toHistory !== false) {
|
|
130
|
+
history.value = [{ ...op, settledAt: op.settledAt ?? Date.now() }, ...history.value].slice(
|
|
131
|
+
0,
|
|
132
|
+
HISTORY_CAP,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scheduleLinger(key: string) {
|
|
138
|
+
if (lingerTimers.has(key)) return;
|
|
139
|
+
lingerTimers.set(
|
|
140
|
+
key,
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
lingerTimers.delete(key);
|
|
143
|
+
// Retire + ask the owning source to drop its row so it doesn't
|
|
144
|
+
// resurrect on the next sync (uploads keep done rows forever).
|
|
145
|
+
const acts = actions.get(key);
|
|
146
|
+
retire(key);
|
|
147
|
+
acts?.dismiss?.();
|
|
148
|
+
}, DONE_LINGER_MS),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Full-list reconcile for one publisher group. Upserts every present row,
|
|
154
|
+
* removes vanished ones (sticky errors excepted), and manages the
|
|
155
|
+
* done-linger → history hand-off.
|
|
156
|
+
*/
|
|
157
|
+
function sync(
|
|
158
|
+
group: string,
|
|
159
|
+
items: Array<{ input: OperationInput; actions?: OperationActions }>,
|
|
160
|
+
): void {
|
|
161
|
+
const prefix = `${group}:`;
|
|
162
|
+
const present = new Set<string>();
|
|
163
|
+
let next = [...active.value];
|
|
164
|
+
let changed = false;
|
|
165
|
+
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const key = `${group}:${item.input.id}`;
|
|
168
|
+
present.add(key);
|
|
169
|
+
if (item.actions) actions.set(key, item.actions);
|
|
170
|
+
if (retired.has(key)) continue; // already history — don't resurrect
|
|
171
|
+
const idx = next.findIndex((o) => o.key === key);
|
|
172
|
+
const op = toOperation(key, item.input, idx === -1 ? undefined : next[idx]);
|
|
173
|
+
if (idx === -1) {
|
|
174
|
+
next = [op, ...next];
|
|
175
|
+
changed = true;
|
|
176
|
+
} else if (JSON.stringify(next[idx]) !== JSON.stringify(op)) {
|
|
177
|
+
next[idx] = op;
|
|
178
|
+
changed = true;
|
|
179
|
+
}
|
|
180
|
+
if (op.status === 'done' || op.status === 'aborted') scheduleLinger(key);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Vanished from source: running → drop silently (superseded elsewhere),
|
|
184
|
+
// done/aborted → straight to history, error → sticky (keep visible).
|
|
185
|
+
for (const o of [...next]) {
|
|
186
|
+
if (!o.key.startsWith(prefix) || present.has(o.key)) continue;
|
|
187
|
+
if (o.status === 'error') continue;
|
|
188
|
+
clearTimer(o.key);
|
|
189
|
+
next = next.filter((x) => x.key !== o.key);
|
|
190
|
+
changed = true;
|
|
191
|
+
if (o.status !== 'running') {
|
|
192
|
+
retired.add(o.key);
|
|
193
|
+
history.value = [{ ...o, settledAt: o.settledAt ?? Date.now() }, ...history.value].slice(
|
|
194
|
+
0,
|
|
195
|
+
HISTORY_CAP,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Bookkeeping: retired keys whose source rows are gone can be forgotten
|
|
201
|
+
// (their ids won't reappear — uuids / monotonic op ids).
|
|
202
|
+
for (const key of [...retired]) {
|
|
203
|
+
if (key.startsWith(prefix) && !present.has(key)) retired.delete(key);
|
|
204
|
+
}
|
|
205
|
+
for (const key of [...actions.keys()]) {
|
|
206
|
+
if (key.startsWith(prefix) && !present.has(key) && !active.value.some((o) => o.key === key)) {
|
|
207
|
+
// keep actions for rows still on screen (sticky errors)
|
|
208
|
+
if (!next.some((o) => o.key === key)) actions.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (changed) active.value = next;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cancel(key: string) {
|
|
216
|
+
actions.get(key)?.cancel?.();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function retry(key: string) {
|
|
220
|
+
const acts = actions.get(key);
|
|
221
|
+
retire(key); // failed attempt goes to history; retry publishes a fresh row
|
|
222
|
+
acts?.dismiss?.();
|
|
223
|
+
acts?.retry?.();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function dismiss(key: string) {
|
|
227
|
+
const acts = actions.get(key);
|
|
228
|
+
retire(key);
|
|
229
|
+
acts?.dismiss?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function clearHistory() {
|
|
233
|
+
history.value = [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const runningCount = computed(
|
|
237
|
+
() => active.value.filter((o) => o.status === 'running').length,
|
|
238
|
+
);
|
|
239
|
+
const errorCount = computed(() => active.value.filter((o) => o.status === 'error').length);
|
|
240
|
+
const hasError = computed(() => errorCount.value > 0);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Aggregate progress across determinate running rows (uploads weighted by
|
|
244
|
+
* bytes, queue ops by item counts). null → nothing determinate is running
|
|
245
|
+
* (badge ring spins instead of filling).
|
|
246
|
+
*/
|
|
247
|
+
const overallPercent = computed<number | null>(() => {
|
|
248
|
+
const running = active.value.filter((o) => o.status === 'running');
|
|
249
|
+
let sum = 0;
|
|
250
|
+
let n = 0;
|
|
251
|
+
for (const o of running) {
|
|
252
|
+
let frac: number | null = null;
|
|
253
|
+
if (o.totalBytes && o.totalBytes > 0) {
|
|
254
|
+
frac = Math.min(1, (o.uploadedBytes ?? 0) / o.totalBytes);
|
|
255
|
+
} else if (o.totalCount && o.totalCount > 0) {
|
|
256
|
+
frac = Math.min(1, (o.doneCount ?? 0) / o.totalCount);
|
|
257
|
+
} else if (o.percent !== null) {
|
|
258
|
+
frac = Math.min(1, o.percent / 100);
|
|
259
|
+
}
|
|
260
|
+
if (frac !== null) {
|
|
261
|
+
sum += frac;
|
|
262
|
+
n += 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (n === 0) return null;
|
|
266
|
+
return Math.round((sum / n) * 100);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (getCurrentScope()) {
|
|
270
|
+
onScopeDispose(() => {
|
|
271
|
+
for (const tm of lingerTimers.values()) clearTimeout(tm);
|
|
272
|
+
lingerTimers.clear();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
active,
|
|
278
|
+
history,
|
|
279
|
+
runningCount,
|
|
280
|
+
errorCount,
|
|
281
|
+
hasError,
|
|
282
|
+
overallPercent,
|
|
283
|
+
sync,
|
|
284
|
+
cancel,
|
|
285
|
+
retry,
|
|
286
|
+
dismiss,
|
|
287
|
+
clearHistory,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type OperationsStore = ReturnType<typeof useOperations>;
|
package/src/index.ts
CHANGED
|
@@ -82,3 +82,48 @@ export {
|
|
|
82
82
|
|
|
83
83
|
// ——— Locale catalogue (consumers may merge their own keys) ———
|
|
84
84
|
export { messages, tr, en } from './locales';
|
|
85
|
+
|
|
86
|
+
/* wiring:c1 — theme registry + gallery (hosts can list/apply themes programmatically) */
|
|
87
|
+
export {
|
|
88
|
+
THEMES,
|
|
89
|
+
THEME_LS_KEY,
|
|
90
|
+
DEFAULT_THEME_ID,
|
|
91
|
+
themeById,
|
|
92
|
+
useThemeState,
|
|
93
|
+
setTheme,
|
|
94
|
+
applyThemeToEl,
|
|
95
|
+
syncThemeStyle,
|
|
96
|
+
generateThemeCss,
|
|
97
|
+
} from './lib/themes';
|
|
98
|
+
export type { ThemeDef, ThemeTokenMap } from './lib/themes';
|
|
99
|
+
export { default as ThemeGallery } from './components/ThemeGallery.vue';
|
|
100
|
+
/* wiring:c2 — customizable shortcut registry + settings/quick-look UI */
|
|
101
|
+
export {
|
|
102
|
+
SHORTCUT_ACTIONS,
|
|
103
|
+
useShortcutList,
|
|
104
|
+
effectiveCombo,
|
|
105
|
+
setShortcutOverride,
|
|
106
|
+
resetShortcut,
|
|
107
|
+
resetAllShortcuts,
|
|
108
|
+
findShortcutConflict,
|
|
109
|
+
comboFromEvent,
|
|
110
|
+
} from './composables/useKeyboardShortcuts';
|
|
111
|
+
export type {
|
|
112
|
+
ShortcutActionDef,
|
|
113
|
+
ShortcutView,
|
|
114
|
+
ShortcutConflict,
|
|
115
|
+
} from './composables/useKeyboardShortcuts';
|
|
116
|
+
export { default as ShortcutSettings } from './components/ShortcutSettings.vue';
|
|
117
|
+
export { default as QuickLook } from './components/QuickLook.vue';
|
|
118
|
+
/* /wiring:c2 */
|
|
119
|
+
/* wiring:c3 — unified operations center (badge + panel + store) */
|
|
120
|
+
export { useOperations } from './composables/useOperations';
|
|
121
|
+
export type {
|
|
122
|
+
Operation,
|
|
123
|
+
OperationInput,
|
|
124
|
+
OperationActions,
|
|
125
|
+
OperationKind,
|
|
126
|
+
OperationStatus,
|
|
127
|
+
OperationsStore,
|
|
128
|
+
} from './composables/useOperations';
|
|
129
|
+
export { default as OperationsCenter } from './components/OperationsCenter.vue';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dragGhost — wiring:c4 custom HTML5 drag image.
|
|
3
|
+
*
|
|
4
|
+
* The browser default drag image (a translucent snapshot of the row/card)
|
|
5
|
+
* reads poorly for multi-selection drags. This builds a small pill with the
|
|
6
|
+
* dragged item's name and, for multi-drags, a count badge, parks it
|
|
7
|
+
* off-screen (setDragImage requires a rendered element), and removes it on
|
|
8
|
+
* the next tick. Purely visual — the dataTransfer payload is untouched.
|
|
9
|
+
*
|
|
10
|
+
* Styled by `.fe-dragghost` in base.css via the `--fe-*` tokens published
|
|
11
|
+
* at :root (and re-published at `.dark`/html level for dark hosts), so it
|
|
12
|
+
* matches the active theme even though it's appended to <body>.
|
|
13
|
+
*/
|
|
14
|
+
export function applyDragGhost(ev: DragEvent, name: string, count: number): void {
|
|
15
|
+
const dt = ev.dataTransfer;
|
|
16
|
+
if (!dt || typeof document === 'undefined' || typeof dt.setDragImage !== 'function') return;
|
|
17
|
+
try {
|
|
18
|
+
const el = document.createElement('div');
|
|
19
|
+
el.className = 'fe-dragghost';
|
|
20
|
+
const label = document.createElement('span');
|
|
21
|
+
label.className = 'fe-dragghost__name';
|
|
22
|
+
label.textContent = name;
|
|
23
|
+
el.appendChild(label);
|
|
24
|
+
if (count > 1) {
|
|
25
|
+
const badge = document.createElement('span');
|
|
26
|
+
badge.className = 'fe-dragghost__badge';
|
|
27
|
+
badge.textContent = String(count);
|
|
28
|
+
el.appendChild(badge);
|
|
29
|
+
}
|
|
30
|
+
el.style.position = 'fixed';
|
|
31
|
+
el.style.top = '-1000px';
|
|
32
|
+
el.style.left = '-1000px';
|
|
33
|
+
document.body.appendChild(el);
|
|
34
|
+
dt.setDragImage(el, 14, 14);
|
|
35
|
+
// The engine snapshots the element synchronously during dragstart —
|
|
36
|
+
// safe to drop it right after.
|
|
37
|
+
setTimeout(() => el.remove(), 0);
|
|
38
|
+
} catch {
|
|
39
|
+
/* non-critical visual nicety — native ghost remains */
|
|
40
|
+
}
|
|
41
|
+
}
|