@heyramzi/ui 3.6.0 → 3.7.1

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.
Files changed (31) hide show
  1. package/dist/canvas/pen/node-name.svelte.js +29 -6
  2. package/package.json +13 -1
  3. package/scripts/lib/svelte-modules.d.mts +4 -0
  4. package/scripts/lib/svelte-modules.mjs +38 -0
  5. package/scripts/svelte-modules.mjs +14 -0
  6. package/src/ai/chat-window/reveal.svelte.js +106 -0
  7. package/src/ai/confirmation/confirmation-context.svelte.js +12 -0
  8. package/src/ai/reasoning/reasoning-context.svelte.js +12 -0
  9. package/src/canvas/editor/dispatch.svelte.js +160 -0
  10. package/src/canvas/lod/lod-mode.svelte.js +24 -0
  11. package/src/canvas/lod/render-all.svelte.js +42 -0
  12. package/src/canvas/pen/node-name.svelte.js +48 -0
  13. package/src/canvas/pen/node-name.svelte.ts +30 -6
  14. package/src/components/copy/copied.svelte.js +34 -0
  15. package/src/components/docs/scroll-spy.svelte.js +76 -0
  16. package/src/components/open-in/open-in.svelte +14 -15
  17. package/src/components/open-in/open-in.svelte.d.ts +2 -2
  18. package/src/consumer-prebundle.test.ts +126 -0
  19. package/src/effects/animated-beam/path-calculator.svelte.js +54 -0
  20. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +39 -15
  21. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte.d.ts +2 -1
  22. package/src/onboarding/onboarding.svelte.js +19 -0
  23. package/src/shadcn/hooks/is-mobile.svelte.js +7 -0
  24. package/src/shadcn/hooks/use-highlighted-code.svelte.js +65 -0
  25. package/src/shadcn/sidebar/context.svelte.js +63 -0
  26. package/src/shadcn/sidebar/sidebar-provider.svelte +9 -2
  27. package/src/shadcn/sidebar/sidebar-provider.svelte.d.ts +5 -0
  28. package/src/slides/deck-controller.svelte.js +254 -0
  29. package/src/work/kanban-board-state.svelte.js +205 -0
  30. package/src/work/kanban-rail-state.svelte.js +85 -0
  31. package/src/work/use-kanban-dnd.svelte.js +129 -0
@@ -0,0 +1,254 @@
1
+ import { untrack } from "svelte";
2
+ import { fitBox, gridBoxes } from "./layout.js";
3
+ import { keyAction, nextPosition, ordinalOf, prevPosition, progressOf, settle, visibleIndexes } from "./navigation.js";
4
+ import { resolveLabels } from "./labels.js";
5
+ import { DEFAULT_CANVAS } from "./types.js";
6
+ /** Height the controls bar takes at the bottom of the overview, in px. */
7
+ const CONTROLS_ROOM = 56;
8
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
+ /**
10
+ * Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
11
+ * shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
12
+ */
13
+ function frame(cell, target, fit) {
14
+ const k = fit.scale / target.scale;
15
+ return {
16
+ x: fit.x + (cell.x - target.x) * k,
17
+ y: fit.y + (cell.y - target.y) * k,
18
+ scale: cell.scale * k
19
+ };
20
+ }
21
+ function isEditable(el) {
22
+ return el.isContentEditable || [
23
+ "INPUT",
24
+ "TEXTAREA",
25
+ "SELECT"
26
+ ].includes(el.tagName);
27
+ }
28
+ const byDocumentOrder = (a, b) => a.el && b.el ? a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 : 0;
29
+ export class DeckController {
30
+ entries = $state.raw([]);
31
+ view = $state("slide");
32
+ /** The highlighted thumbnail in the overview, as an index among the visible slides. */
33
+ cursor = $state(0);
34
+ direction = $state(1);
35
+ zoom = $state(null);
36
+ size = $state({
37
+ w: 0,
38
+ h: 0
39
+ });
40
+ options;
41
+ #listeners = new Set();
42
+ #timer;
43
+ #frame = 0;
44
+ constructor(options) {
45
+ this.options = options;
46
+ }
47
+ canvas = $derived(this.options.canvas ?? DEFAULT_CANVAS);
48
+ labels = $derived(resolveLabels(this.options.labels));
49
+ slides = $derived(this.entries.map((entry) => ({
50
+ steps: this.options.fragments === false ? 0 : entry.steps,
51
+ hidden: entry.hidden
52
+ })));
53
+ visible = $derived(visibleIndexes(this.slides));
54
+ position = $derived(settle(this.slides, {
55
+ index: this.options.index,
56
+ step: this.options.step
57
+ }));
58
+ ordinal = $derived(ordinalOf(this.slides, this.position.index));
59
+ progress = $derived(progressOf(this.slides, this.position));
60
+ current = $derived(this.entries[this.position.index]);
61
+ /** The slide after this one, for a presenter's preview. Null on the last. */
62
+ upcoming = $derived(this.visible.find((i) => i > this.position.index) ?? null);
63
+ overview = $derived(this.view === "overview");
64
+ /** True when every fragment shows: in the overview, or on a deck that doesn't stage reveals. */
65
+ revealAll = $derived(this.overview || this.options.fragments === false);
66
+ atStart = $derived(prevPosition(this.slides, this.position, this.options.loop) === null);
67
+ atEnd = $derived(nextPosition(this.slides, this.position, this.options.loop) === null);
68
+ fit = $derived(fitBox(this.size, this.canvas));
69
+ grid = $derived(gridBoxes({
70
+ w: this.size.w,
71
+ h: Math.max(0, this.size.h - (this.options.controls === false ? 0 : CONTROLS_ROOM))
72
+ }, this.canvas, this.visible.length));
73
+ /** Where slide `index` sits right now: centred to fit, or in its overview cell. */
74
+ boxOf(index) {
75
+ const zoom = this.zoom;
76
+ const cell = this.grid.boxes[this.visible.indexOf(index)];
77
+ if (zoom?.framed) {
78
+ const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
79
+ return cell && target ? frame(cell, target, this.fit) : this.fit;
80
+ }
81
+ return this.overview || zoom ? cell : this.fit;
82
+ }
83
+ register(entry) {
84
+ untrack(() => {
85
+ this.entries = [...this.entries, entry].sort(byDocumentOrder);
86
+ });
87
+ return () => untrack(() => {
88
+ this.entries = this.entries.filter((candidate) => candidate !== entry);
89
+ this.#write(this.position);
90
+ });
91
+ }
92
+ listen(listener) {
93
+ this.#listeners.add(listener);
94
+ return () => this.#listeners.delete(listener);
95
+ }
96
+ mount() {
97
+ return () => this.#stopZoom();
98
+ }
99
+ /** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
100
+ get listening() {
101
+ return this.options.keyboard !== false;
102
+ }
103
+ #write(position) {
104
+ this.options.index = position.index;
105
+ this.options.step = position.step;
106
+ }
107
+ /** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
108
+ commit(target, source = "local", animate = true) {
109
+ if (!target) return;
110
+ const from = this.position;
111
+ const to = settle(this.slides, target);
112
+ if (to.index === from.index && to.step === from.step) return;
113
+ this.direction = to.index >= from.index ? 1 : -1;
114
+ const apply = () => this.#write(to);
115
+ if (animate && to.index !== from.index && this.options.transition) this.options.transition(apply);
116
+ else apply();
117
+ for (const listener of this.#listeners) listener(to, source);
118
+ }
119
+ next() {
120
+ this.commit(nextPosition(this.slides, this.position, this.options.loop));
121
+ }
122
+ prev() {
123
+ this.commit(prevPosition(this.slides, this.position, this.options.loop));
124
+ }
125
+ go(index, step = 0, source = "local") {
126
+ this.commit({
127
+ index,
128
+ step
129
+ }, source);
130
+ }
131
+ /**
132
+ * Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
133
+ * the current slide, so `select` moves there first.
134
+ */
135
+ toggleOverview(force) {
136
+ const open = force ?? !this.overview;
137
+ if (open === this.overview) return;
138
+ const index = this.position.index;
139
+ if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
140
+ this.view = open ? "overview" : "slide";
141
+ this.#stopZoom();
142
+ const ms = this.options.zoomMs?.() ?? 0;
143
+ if (ms <= 0 || !this.visible.includes(index)) return;
144
+ /*
145
+ * Each way paints its start state first and moves on a later frame. Moved in the same
146
+ * flush, a style read in between (the overview handing focus back) commits the boxes
147
+ * before the transition rule is on, and the zoom jumps to its end.
148
+ */
149
+ this.zoom = {
150
+ index,
151
+ framed: open,
152
+ running: !open
153
+ };
154
+ this.#frame = requestAnimationFrame(() => {
155
+ this.#frame = requestAnimationFrame(() => {
156
+ this.zoom = {
157
+ index,
158
+ framed: !open,
159
+ running: true
160
+ };
161
+ this.#timer = setTimeout(() => this.#stopZoom(), ms);
162
+ });
163
+ });
164
+ }
165
+ /** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
166
+ select(index) {
167
+ const keep = index === this.position.index;
168
+ this.commit({
169
+ index,
170
+ step: keep ? this.position.step : 0
171
+ }, "local", false);
172
+ this.toggleOverview(false);
173
+ }
174
+ #stopZoom() {
175
+ clearTimeout(this.#timer);
176
+ cancelAnimationFrame(this.#frame);
177
+ this.zoom = null;
178
+ }
179
+ #moveCursor(to) {
180
+ this.cursor = clamp(to, 0, this.visible.length - 1);
181
+ }
182
+ /**
183
+ * Runs the deck's own keys. Returns the action it took, or null when the key wasn't its.
184
+ * `fullscreen` and `presenter` are returned untouched, since they need the page.
185
+ */
186
+ handleKey(event) {
187
+ if (event.defaultPrevented || !this.listening || event.altKey || event.ctrlKey || event.metaKey) return null;
188
+ const target = event.target instanceof HTMLElement ? event.target : null;
189
+ if (target && isEditable(target)) return null;
190
+ const action = keyAction(event.key, event.shiftKey);
191
+ if (!action) return null;
192
+ /* Space and Enter belong to a focused control, not to the deck. */
193
+ if ((event.key === " " || event.key === "Enter") && target?.closest("button, a, summary")) return null;
194
+ const handled = this.overview ? this.#overviewKey(action) : this.#slideKey(action);
195
+ if (!handled) return null;
196
+ event.preventDefault();
197
+ return action;
198
+ }
199
+ #slideKey(action) {
200
+ switch (action) {
201
+ case "next":
202
+ this.next();
203
+ return true;
204
+ case "prev":
205
+ this.prev();
206
+ return true;
207
+ case "first":
208
+ this.go(this.visible[0] ?? 0);
209
+ return true;
210
+ case "last":
211
+ this.go(this.visible[this.visible.length - 1] ?? 0);
212
+ return true;
213
+ case "overview":
214
+ this.toggleOverview(true);
215
+ return true;
216
+ case "fullscreen": return true;
217
+ case "presenter": return !!this.options.presenter;
218
+ default: return false;
219
+ }
220
+ }
221
+ #overviewKey(action) {
222
+ const cols = this.grid.cols;
223
+ switch (action) {
224
+ case "next":
225
+ this.#moveCursor(this.cursor + 1);
226
+ return true;
227
+ case "prev":
228
+ this.#moveCursor(this.cursor - 1);
229
+ return true;
230
+ case "down":
231
+ this.#moveCursor(this.cursor + cols);
232
+ return true;
233
+ case "up":
234
+ this.#moveCursor(this.cursor - cols);
235
+ return true;
236
+ case "first":
237
+ this.#moveCursor(0);
238
+ return true;
239
+ case "last":
240
+ this.#moveCursor(this.visible.length - 1);
241
+ return true;
242
+ case "select":
243
+ this.select(this.visible[this.cursor] ?? this.position.index);
244
+ return true;
245
+ case "close":
246
+ case "overview":
247
+ this.toggleOverview(false);
248
+ return true;
249
+ case "fullscreen": return true;
250
+ case "presenter": return !!this.options.presenter;
251
+ default: return false;
252
+ }
253
+ }
254
+ }
@@ -0,0 +1,205 @@
1
+ import { untrack } from "svelte";
2
+ import { SvelteSet } from "svelte/reactivity";
3
+ import { prefersReducedMotion } from "svelte/motion";
4
+ import { cubicOut } from "svelte/easing";
5
+ import { durationMs } from "../lib/motion.js";
6
+ import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
7
+ import { createKanbanRailState } from "./kanban-rail-state.svelte.js";
8
+ import { createCardKeyDown } from "./kanban-keyboard.js";
9
+ /**
10
+ * A board's state: sort, the per-column fold and cap, and the drag wiring. The hidden rail
11
+ * lives in `rail`, the keyboard move in `onCardKeyDown`.
12
+ * Call it during component init, since it registers an effect. `input` is read on every use.
13
+ */
14
+ export function createKanbanBoardState(input) {
15
+ // The drop slot takes the held card's height, so the column doesn't jump when it lands.
16
+ let slotHeight = $state(0);
17
+ let slotMotion = $derived({
18
+ duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
19
+ easing: cubicOut
20
+ });
21
+ let internalExpanded = $state([]);
22
+ let expandedIds = $derived(input().expanded ?? internalExpanded);
23
+ function compare(a, b) {
24
+ const { sortKey, manualTiebreak, boardOrder, sortKeys } = input();
25
+ if (sortKey === "manual") {
26
+ const tie = manualTiebreak?.(a, b) ?? 0;
27
+ if (!boardOrder) return tie;
28
+ const ao = boardOrder(a);
29
+ const bo = boardOrder(b);
30
+ if (ao == null && bo == null) return tie;
31
+ if (ao == null) return 1;
32
+ if (bo == null) return -1;
33
+ return ao < bo ? -1 : ao > bo ? 1 : tie;
34
+ }
35
+ const key = sortKeys.find((entry) => entry.id === sortKey);
36
+ return key ? key.compare(a, b) : 0;
37
+ }
38
+ function sortedByColumn(columnId) {
39
+ const { items, getColumn } = input();
40
+ return items.filter((item) => getColumn(item) === columnId).slice().sort(compare);
41
+ }
42
+ function idAt(list, index) {
43
+ const item = list[index];
44
+ return item === undefined ? null : input().getId(item);
45
+ }
46
+ function capOf(columnId) {
47
+ const { limit } = input();
48
+ return typeof limit === "function" ? limit(columnId) : limit;
49
+ }
50
+ /** The column holds more cards than its cap, so it gets a fold. */
51
+ function isFoldable(columnId, total) {
52
+ const cap = capOf(columnId);
53
+ return cap !== undefined && total > cap;
54
+ }
55
+ // Open from this board's "Show N more", on top of the header caret's `expanded`.
56
+ const laneOpen = new SvelteSet();
57
+ function isOpen(columnId) {
58
+ return expandedIds.includes(columnId) || laneOpen.has(columnId);
59
+ }
60
+ /** The fold is shut, so the tail is out of sight. */
61
+ function isCapped(columnId, total) {
62
+ return isFoldable(columnId, total) && !isOpen(columnId);
63
+ }
64
+ // A card moved into a folded column stays in view, wherever its sort puts it.
65
+ const pinned = new SvelteSet();
66
+ // The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
67
+ let previousExpanded = [];
68
+ $effect(() => {
69
+ const now = expandedIds;
70
+ untrack(() => {
71
+ for (const id of previousExpanded) if (!now.includes(id)) shut(id);
72
+ });
73
+ previousExpanded = [...now];
74
+ });
75
+ function moveAndPin(id, move) {
76
+ pinned.add(id);
77
+ return input().onMove(id, move);
78
+ }
79
+ function canDrop(columnId) {
80
+ const { isDroppable, columns } = input();
81
+ return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
82
+ }
83
+ function setExpanded(next) {
84
+ internalExpanded = next;
85
+ input().onExpandedChange?.(next);
86
+ }
87
+ // Shutting drops the column's pins, so the fold closes to exactly its cap.
88
+ function shut(columnId) {
89
+ laneOpen.delete(columnId);
90
+ for (const item of sortedByColumn(columnId)) pinned.delete(input().getId(item));
91
+ }
92
+ /** "Show N more": opens this board's cell only. */
93
+ function expand(columnId) {
94
+ laneOpen.add(columnId);
95
+ }
96
+ /** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
97
+ function collapse(columnId) {
98
+ shut(columnId);
99
+ if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
100
+ }
101
+ /** The header caret: the whole column, in every board that shares `expanded`. */
102
+ function toggleColumn(columnId) {
103
+ if (expandedIds.includes(columnId)) collapse(columnId);
104
+ else setExpanded([...expandedIds, columnId]);
105
+ }
106
+ const dnd = createKanbanDnd({
107
+ items: () => input().items,
108
+ getId: (item) => input().getId(item),
109
+ getColumn: (item) => input().getColumn(item),
110
+ getColumnItems: sortedByColumn,
111
+ getSortKey: () => input().sortKey,
112
+ boardOrder: (item) => input().boardOrder?.(item),
113
+ onMove: moveAndPin,
114
+ dropAtTop: (columnId) => input().dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
115
+ isDroppable: canDrop
116
+ });
117
+ const rail = createKanbanRailState({
118
+ hidden: () => input().hidden,
119
+ onHiddenChange: () => input().onHiddenChange,
120
+ canDrop,
121
+ dnd
122
+ });
123
+ const onCardKeyDown = createCardKeyDown({
124
+ input,
125
+ canDrop,
126
+ isHidden: (columnId) => rail.hiddenSet.has(columnId),
127
+ sortedByColumn,
128
+ isCapped,
129
+ moveAndPin
130
+ });
131
+ let view = $derived(input().columns.map((column) => {
132
+ const all = sortedByColumn(column.id);
133
+ const cap = capOf(column.id);
134
+ const foldable = isFoldable(column.id, all.length);
135
+ const open = foldable && isOpen(column.id);
136
+ if (!foldable || cap === undefined) {
137
+ return {
138
+ column,
139
+ all,
140
+ head: all,
141
+ tail: [],
142
+ foldable,
143
+ open,
144
+ capped: false
145
+ };
146
+ }
147
+ // Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
148
+ const head = [];
149
+ const tail = [];
150
+ all.forEach((item, index) => {
151
+ (index < cap || pinned.has(input().getId(item)) ? head : tail).push(item);
152
+ });
153
+ return {
154
+ column,
155
+ all,
156
+ head,
157
+ tail,
158
+ foldable,
159
+ open,
160
+ capped: !open && tail.length > 0
161
+ };
162
+ }));
163
+ let hiddenColumns = $derived(input().columns.filter((column) => rail.hiddenSet.has(column.id)));
164
+ function contextOf(entry, active) {
165
+ const { column, all, head, capped } = entry;
166
+ return {
167
+ count: all.length,
168
+ active,
169
+ shown: capped ? head.length : all.length,
170
+ capped,
171
+ hide: () => rail.hide(column.id),
172
+ expand: () => expand(column.id),
173
+ collapse: () => collapse(column.id)
174
+ };
175
+ }
176
+ return {
177
+ get slotHeight() {
178
+ return slotHeight;
179
+ },
180
+ set slotHeight(value) {
181
+ slotHeight = value;
182
+ },
183
+ get slotMotion() {
184
+ return slotMotion;
185
+ },
186
+ get expandedIds() {
187
+ return expandedIds;
188
+ },
189
+ get view() {
190
+ return view;
191
+ },
192
+ get hiddenColumns() {
193
+ return hiddenColumns;
194
+ },
195
+ dnd,
196
+ rail,
197
+ sortedByColumn,
198
+ idAt,
199
+ expand,
200
+ collapse,
201
+ toggleColumn,
202
+ onCardKeyDown,
203
+ contextOf
204
+ };
205
+ }
@@ -0,0 +1,85 @@
1
+ import { SvelteSet } from "svelte/reactivity";
2
+ /** The rail of hidden columns: which are folded, the fold and unfold timing, and drops onto it. */
3
+ export function createKanbanRailState(config) {
4
+ const { canDrop, dnd } = config;
5
+ let internalHidden = $state([]);
6
+ let hiddenIds = $derived(config.hidden() ?? internalHidden);
7
+ let hiddenSet = $derived(new Set(hiddenIds));
8
+ let railOpen = $state(true);
9
+ let dragOverHidden = $state(null);
10
+ const collapsing = new SvelteSet();
11
+ const expanding = new SvelteSet();
12
+ const leaving = new SvelteSet();
13
+ function setHidden(next) {
14
+ internalHidden = next;
15
+ config.onHiddenChange()?.(next);
16
+ }
17
+ // Fold: the column fades out first, then leaves the row and joins the rail.
18
+ function hide(columnId) {
19
+ if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
20
+ collapsing.add(columnId);
21
+ setTimeout(() => {
22
+ setHidden([...hiddenIds, columnId]);
23
+ collapsing.delete(columnId);
24
+ }, 300);
25
+ }
26
+ // Unfold: the rail entry leaves, then the column grows back in.
27
+ function show(columnId) {
28
+ leaving.add(columnId);
29
+ setTimeout(() => {
30
+ leaving.delete(columnId);
31
+ expanding.add(columnId);
32
+ setHidden(hiddenIds.filter((id) => id !== columnId));
33
+ setTimeout(() => expanding.delete(columnId), 350);
34
+ }, 200);
35
+ }
36
+ function showAll() {
37
+ for (const id of hiddenIds) leaving.add(id);
38
+ setTimeout(() => {
39
+ leaving.clear();
40
+ setHidden([]);
41
+ }, 200);
42
+ }
43
+ function onHiddenDragOver(e, columnId) {
44
+ if (!canDrop(columnId)) return;
45
+ e.preventDefault();
46
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
47
+ if (dragOverHidden !== columnId) dragOverHidden = columnId;
48
+ dnd.clearDragOver();
49
+ }
50
+ function onHiddenDragLeave(e) {
51
+ if (!e.currentTarget.contains(e.relatedTarget)) dragOverHidden = null;
52
+ }
53
+ // A card dropped on a folded column brings the column back and lands in it.
54
+ function onHiddenDrop(e, columnId) {
55
+ dragOverHidden = null;
56
+ dnd.onDrop(e, columnId);
57
+ show(columnId);
58
+ }
59
+ return {
60
+ get hiddenSet() {
61
+ return hiddenSet;
62
+ },
63
+ get railOpen() {
64
+ return railOpen;
65
+ },
66
+ set railOpen(value) {
67
+ railOpen = value;
68
+ },
69
+ get dragOverHidden() {
70
+ return dragOverHidden;
71
+ },
72
+ set dragOverHidden(value) {
73
+ dragOverHidden = value;
74
+ },
75
+ collapsing,
76
+ expanding,
77
+ leaving,
78
+ hide,
79
+ show,
80
+ showAll,
81
+ onHiddenDragOver,
82
+ onHiddenDragLeave,
83
+ onHiddenDrop
84
+ };
85
+ }
@@ -0,0 +1,129 @@
1
+ import { boardOrderAt } from "./board-order.js";
2
+ /**
3
+ * A board's drag state and handlers, on native HTML5 drag so a card can leave the board for
4
+ * any drop target a caller wired up.
5
+ */
6
+ export function createKanbanDnd(config) {
7
+ let draggedId = $state(null);
8
+ let dragOver = $state(null);
9
+ function readDraggedId(e) {
10
+ const raw = e.dataTransfer?.getData("text/plain");
11
+ if (!raw) return null;
12
+ try {
13
+ const parsed = JSON.parse(raw);
14
+ return parsed.id ?? null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ function onDragStart(e, id) {
20
+ if (e.dataTransfer) {
21
+ e.dataTransfer.effectAllowed = "move";
22
+ e.dataTransfer.setData("text/plain", JSON.stringify({ id }));
23
+ }
24
+ // Defer: marking the source during the dragstart event makes some browsers
25
+ // snapshot the faded state instead of the card.
26
+ requestAnimationFrame(() => {
27
+ draggedId = id;
28
+ });
29
+ }
30
+ function onItemDragOver(e, columnId, overId, nextId) {
31
+ if (config.isDroppable && !config.isDroppable(columnId)) return;
32
+ e.preventDefault();
33
+ e.stopPropagation();
34
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
35
+ const rect = e.currentTarget.getBoundingClientRect();
36
+ const insertAfter = e.clientY > rect.top + rect.height / 2;
37
+ const targetOverId = insertAfter ? nextId : overId;
38
+ if (targetOverId === draggedId) return;
39
+ if (dragOver?.columnId === columnId && dragOver.overId === targetOverId) return;
40
+ dragOver = {
41
+ columnId,
42
+ overId: targetOverId
43
+ };
44
+ }
45
+ function onColumnDragOver(e, columnId) {
46
+ if (config.isDroppable && !config.isDroppable(columnId)) return;
47
+ e.preventDefault();
48
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
49
+ if (dragOver?.columnId !== columnId || dragOver.overId !== null) {
50
+ dragOver = {
51
+ columnId,
52
+ overId: null
53
+ };
54
+ }
55
+ }
56
+ function onDragLeave(e) {
57
+ if (!e.currentTarget.contains(e.relatedTarget)) {
58
+ dragOver = null;
59
+ }
60
+ }
61
+ function clearDragOver() {
62
+ dragOver = null;
63
+ }
64
+ function resetDrag() {
65
+ draggedId = null;
66
+ dragOver = null;
67
+ }
68
+ /** Where a manual drop lands: on the hovered card, else the top or the end. */
69
+ function manualInsertIndex(column, overId, columnId) {
70
+ const overIndex = overId ? column.findIndex((item) => config.getId(item) === overId) : -1;
71
+ if (overIndex !== -1) return overIndex;
72
+ return config.dropAtTop?.(columnId) ? 0 : column.length;
73
+ }
74
+ function onDrop(e, targetColumnId) {
75
+ e.preventDefault();
76
+ if (config.isDroppable && !config.isDroppable(targetColumnId)) return;
77
+ const overId = dragOver?.overId ?? null;
78
+ dragOver = null;
79
+ draggedId = null;
80
+ const id = readDraggedId(e);
81
+ const item = id ? config.items().find((entry) => config.getId(entry) === id) : undefined;
82
+ if (!item) return;
83
+ const movedId = config.getId(item);
84
+ if (config.getSortKey() !== "manual") {
85
+ if (config.getColumn(item) !== targetColumnId) {
86
+ void config.onMove(movedId, { columnId: targetColumnId });
87
+ }
88
+ return;
89
+ }
90
+ const column = config.getColumnItems(targetColumnId).filter((entry) => config.getId(entry) !== movedId);
91
+ const index = manualInsertIndex(column, overId, targetColumnId);
92
+ const before = column[index - 1];
93
+ const after = column[index];
94
+ const orderOf = config.boardOrder;
95
+ void config.onMove(movedId, {
96
+ columnId: targetColumnId,
97
+ ...orderOf ? { boardOrder: boardOrderAt(column, index, orderOf) } : {},
98
+ beforeId: before === undefined ? null : config.getId(before),
99
+ afterId: after === undefined ? null : config.getId(after)
100
+ });
101
+ }
102
+ return {
103
+ get draggedId() {
104
+ return draggedId;
105
+ },
106
+ get dragOver() {
107
+ return dragOver;
108
+ },
109
+ set dragOver(value) {
110
+ dragOver = value;
111
+ },
112
+ onDragStart,
113
+ onItemDragOver,
114
+ onColumnDragOver,
115
+ onDragLeave,
116
+ onDrop,
117
+ clearDragOver,
118
+ resetDrag,
119
+ isDragSource(id) {
120
+ return draggedId === id;
121
+ },
122
+ showIndicatorBefore(columnId, itemId) {
123
+ return dragOver?.columnId === columnId && dragOver.overId === itemId && draggedId !== null;
124
+ },
125
+ showIndicatorEnd(columnId) {
126
+ return dragOver?.columnId === columnId && dragOver.overId === null && draggedId !== null;
127
+ }
128
+ };
129
+ }