@panphora/clayjs 1.13.1 → 1.15.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.
Files changed (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -0,0 +1,447 @@
1
+ import { schema } from "./schema.js";
2
+ import { orderedIds } from "./model.js";
3
+ import { announce } from "./announce.js";
4
+ import { BoardDrag } from "./board-drag.js";
5
+
6
+ const N = schema.nodes;
7
+ const boardViews = new WeakMap();
8
+ const DUE = /^\d{4}-\d{2}-\d{2}$/;
9
+ let boards = 0;
10
+
11
+ const svg = (body, fill = false) => `<svg class="i" viewBox="0 0 16 16" width="16" height="16" fill="${fill ? "currentColor" : "none"}" stroke="${fill ? "none" : "currentColor"}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
12
+ export const BOARD_ICONS = {
13
+ plus: svg('<path d="M8 3.25v9.5M3.25 8h9.5"/>'),
14
+ more: svg('<circle cx="3.5" cy="8" r="1.15"/><circle cx="8" cy="8" r="1.15"/><circle cx="12.5" cy="8" r="1.15"/>', true),
15
+ chevron: svg('<path d="M4.75 6.5 8 9.75l3.25-3.25"/>'),
16
+ close: svg('<path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/>'),
17
+ up: svg('<path d="M8 12.75v-9.5M4.25 7 8 3.25 11.75 7"/>'),
18
+ down: svg('<path d="M8 3.25v9.5M4.25 9 8 12.75 11.75 9"/>'),
19
+ left: svg('<path d="M12.75 8h-9.5M7 4.25 3.25 8 7 11.75"/>'),
20
+ right: svg('<path d="M3.25 8h9.5M9 4.25 12.75 8 9 11.75"/>'),
21
+ trash: svg('<path d="M2.75 4.5h10.5M6.25 4.5V2.75h3.5V4.5M4.25 4.5l.6 8.75h6.3l.6-8.75"/>'),
22
+ calendar: svg('<rect x="2.75" y="3.5" width="10.5" height="9.75" rx="1.75"/><path d="M2.75 6.75h10.5M5.5 2v2.5M10.5 2v2.5"/>'),
23
+ pencil: svg('<path d="M10.25 3.25l2.5 2.5-7 7H3.25v-2.5z"/>'),
24
+ restore: svg('<path d="M3.5 8a4.5 4.5 0 1 0 1.32-3.18M3.5 2.75v2.5H6"/>'),
25
+ };
26
+
27
+ export function el(doc, tag, cls, attrs = {}, text = null) {
28
+ const node = doc.createElement(tag);
29
+ if (cls) node.className = cls;
30
+ for (const [key, value] of Object.entries(attrs)) if (value !== null && value !== undefined && value !== false) node.setAttribute(key, value === true ? "" : String(value));
31
+ if (text !== null) node.textContent = text;
32
+ return node;
33
+ }
34
+
35
+ export function iconButton(doc, cls, icon, label, attrs = {}) {
36
+ const button = el(doc, "button", cls, { type: "button", ...attrs });
37
+ button.innerHTML = BOARD_ICONS[icon];
38
+ if (label) button.append(label);
39
+ return button;
40
+ }
41
+
42
+ export function boardOf(node) {
43
+ const columns = node.attrs.columns ?? {}, cards = node.attrs.cards ?? {};
44
+ const columnIds = orderedIds(columns);
45
+ const lanes = new Map(columnIds.map((id) => [id, []]));
46
+ const missing = new Map();
47
+ for (const id of orderedIds(cards)) {
48
+ const col = String(cards[id].col ?? "");
49
+ if (lanes.has(col)) lanes.get(col).push(id);
50
+ else missing.set(col, [...(missing.get(col) ?? []), id]);
51
+ }
52
+ return { columns, cards, columnIds, lanes, missing: new Map([...missing].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) };
53
+ }
54
+
55
+ const pad = (n) => String(n).padStart(2, "0");
56
+ export const todayKey = (now = new Date()) => `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
57
+ export const dueState = (due, today = todayKey()) => (!DUE.test(due) ? "later" : due < today ? "overdue" : due === today ? "today" : "later");
58
+ export function dueLabel(due, today = todayKey(), long = false) {
59
+ if (!DUE.test(due)) return due;
60
+ if (!long && due === today) return "Today";
61
+ const sameYear = due.slice(0, 4) === today.slice(0, 4);
62
+ return new Date(`${due}T00:00:00Z`).toLocaleDateString("en-US", { ...(long ? { weekday: "short" } : {}), month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" });
63
+ }
64
+ export const initialOf = (name) => ([...String(name).trim()][0] ?? "?").toUpperCase();
65
+ export const placeAfter = (ids, index) => (index >= 2 ? ids[index - 2] : null);
66
+ const countOf = (doc, n) => el(doc, "span", "page-board-count", { "aria-label": `${n} ${n === 1 ? "card" : "cards"}` }, String(n));
67
+ const isTyping = (target) => /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? "");
68
+
69
+ function dueBadge(doc, due) {
70
+ const today = todayKey();
71
+ const state = dueState(due, today);
72
+ const badge = el(doc, "span", "page-board-due", { "data-due": state });
73
+ badge.append(el(doc, "span", "page-board-sr", {}, "Due "), doc.createTextNode(dueLabel(due, today)));
74
+ if (state === "overdue") badge.append(el(doc, "span", "page-board-sr", {}, ", overdue"));
75
+ return badge;
76
+ }
77
+
78
+ export const cardFace = (doc, name) => el(doc, "span", "page-board-face", { "aria-hidden": "true" }, initialOf(name));
79
+
80
+ export class BoardView {
81
+ constructor(node, view, getPos, { commands = null, undo = null, redo = null, panel = null } = {}) {
82
+ this.node = node;
83
+ this.view = view;
84
+ this.getPos = getPos;
85
+ this.commands = commands;
86
+ this.undo = undo;
87
+ this.redo = redo;
88
+ this.panel = panel;
89
+ this.uid = `page-board-${++boards}`;
90
+ this.menu = null;
91
+ const views = boardViews.get(view) ?? new Map();
92
+ boardViews.set(view, views);
93
+ views.set(node.attrs.id, this);
94
+ this.editing = null;
95
+ this.rendering = false;
96
+ const doc = view.dom.ownerDocument;
97
+ this.dom = el(doc, "div", "page-board", { "data-type": "board", "data-id": node.attrs.id ?? "", role: "group", "aria-label": "Board" });
98
+ this.dom.contentEditable = "false";
99
+ this.trays = el(doc, "div", "page-board-trays");
100
+ this.scroll = el(doc, "div", "page-board-scroll");
101
+ this.lanes = el(doc, "div", "page-board-lanes");
102
+ this.scroll.append(this.lanes);
103
+ this.dom.append(this.trays, this.scroll);
104
+ this.dom.addEventListener("click", (event) => this.click(event));
105
+ this.dom.addEventListener("change", (event) => this.change(event));
106
+ this.dom.addEventListener("keydown", (event) => this.keydown(event));
107
+ this.dom.addEventListener("focusout", (event) => this.focusout(event));
108
+ this.dom.addEventListener("mousedown", (event) => { if (event.target.closest?.("button")) event.preventDefault(); });
109
+ this.dragger = new BoardDrag(this);
110
+ this.render();
111
+ }
112
+
113
+ get blockId() { return this.node.attrs.id; }
114
+
115
+ run(name, ...args) {
116
+ if (!this.commands || !this.view.editable) return { status: "refused", code: "read-only" };
117
+ const result = this.commands[name](this.blockId, ...args);
118
+ if (result?.status === "refused") this.render();
119
+ return result;
120
+ }
121
+
122
+ focusKey(key) {
123
+ const target = key ? [...this.dom.querySelectorAll("[data-focus]")].find((node) => node.dataset.focus === key && !node.disabled) : null;
124
+ if (!target) return false;
125
+ target.focus();
126
+ return target.ownerDocument.activeElement === target;
127
+ }
128
+
129
+ render() {
130
+ const doc = this.dom.ownerDocument;
131
+ const active = doc.activeElement;
132
+ const key = active && this.dom.contains(active) ? active.dataset?.focus ?? null : null;
133
+ const range = key && typeof active.selectionStart === "number" ? [active.selectionStart, active.selectionEnd] : null;
134
+ this.rendering = true;
135
+ try {
136
+ this.board = boardOf(this.node);
137
+ this.dom.dataset.id = this.blockId ?? "";
138
+ const gone = this.commands?.removed?.(this.blockId) ?? { columns: {}, cards: {} };
139
+ const removed = gone.columns ?? {};
140
+ const deleted = Object.keys(gone.cards ?? {}).filter((id) => !Object.hasOwn(this.board.cards, id));
141
+ this.trays.replaceChildren(...[...this.board.missing].map(([col, ids], index) => this.tray(col, ids, index, removed[col])));
142
+ if (deleted.length) this.trays.append(this.deletedTray(deleted, gone.cards));
143
+ else this.showDeleted = false;
144
+ const lanes = this.board.columnIds.map((id, index) => this.lane(id, index));
145
+ if (this.editing?.kind === "column") lanes.push(this.newLane());
146
+ else lanes.push(iconButton(doc, "page-board-addcol", "plus", null, { "aria-label": "Add column", title: "Add column", "data-action": "add-column", "data-focus": "add-column" }));
147
+ this.lanes.replaceChildren(...lanes);
148
+ if (key && this.focusKey(key) && range) doc.activeElement.setSelectionRange?.(...range);
149
+ this.dragger?.repaint();
150
+ } finally {
151
+ this.rendering = false;
152
+ }
153
+ }
154
+
155
+ lane(colId, index) {
156
+ const doc = this.dom.ownerDocument;
157
+ const ids = this.board.lanes.get(colId);
158
+ const name = this.board.columns[colId].name || "Untitled";
159
+ const headingId = `${this.uid}-col-${index}`;
160
+ const lane = el(doc, "div", "page-board-lane", { role: "group", "aria-labelledby": headingId, "data-col": colId });
161
+ const head = el(doc, "div", "page-board-head", { "data-col": colId });
162
+ const renaming = this.editing?.kind === "rename" && this.editing.id === colId;
163
+ if (renaming) head.append(this.editing.input, el(doc, "span", "page-board-name page-board-sr", { id: headingId }, name), countOf(doc, ids.length));
164
+ else {
165
+ head.append(el(doc, "h4", "page-board-name", { id: headingId }, name), countOf(doc, ids.length));
166
+ head.append(iconButton(doc, "page-board-icon page-board-colmenu", "more", null, { "aria-haspopup": "menu", "aria-expanded": String(this.menu === colId), "aria-label": `${name} column options`, "data-action": "colmenu", "data-col": colId, "data-focus": `colmenu:${colId}` }));
167
+ if (this.menu === colId) head.append(this.columnMenu(colId, index, name));
168
+ }
169
+ const list = el(doc, "ol", "page-board-cards");
170
+ for (const id of ids) {
171
+ const item = el(doc, "li");
172
+ item.append(this.card(id));
173
+ list.append(item);
174
+ }
175
+ lane.append(head, list);
176
+ if (this.editing?.kind === "card" && this.editing.id === colId) lane.append(this.editing.input);
177
+ else lane.append(iconButton(doc, "page-board-add", "plus", "Add card", { "data-action": "add-card", "data-col": colId, "data-focus": `add:${colId}` }));
178
+ return lane;
179
+ }
180
+
181
+ newLane() {
182
+ const doc = this.dom.ownerDocument;
183
+ const lane = el(doc, "div", "page-board-lane", { role: "group", "aria-label": "New column" });
184
+ const head = el(doc, "div", "page-board-head");
185
+ head.append(this.editing.input);
186
+ lane.append(head);
187
+ return lane;
188
+ }
189
+
190
+ card(id) {
191
+ const doc = this.dom.ownerDocument;
192
+ const card = this.board.cards[id];
193
+ const button = el(doc, "button", "page-board-card", { type: "button", "aria-haspopup": "dialog", "aria-expanded": String(this.isOpen(id)), "data-action": "card", "data-card": id, "data-focus": `card:${id}` });
194
+ button.append(el(doc, "span", "page-board-title", {}, card.title || "Untitled"));
195
+ if (card.owner || card.due) {
196
+ const meta = el(doc, "span", "page-board-meta");
197
+ if (card.owner) {
198
+ const owner = el(doc, "span", "page-board-owner");
199
+ owner.append(cardFace(doc, card.owner), el(doc, "span", "page-board-owner-name", {}, card.owner));
200
+ meta.append(owner);
201
+ }
202
+ if (card.due) meta.append(dueBadge(doc, card.due));
203
+ button.append(meta);
204
+ }
205
+ return button;
206
+ }
207
+
208
+ tray(col, ids, index, removed) {
209
+ const doc = this.dom.ownerDocument;
210
+ const headingId = `${this.uid}-missing-${index}`;
211
+ const tray = el(doc, "div", "page-board-tray", { role: "group", "aria-labelledby": headingId, "data-col": col });
212
+ const head = el(doc, "div", "page-board-tray-head");
213
+ head.append(el(doc, "h4", "page-board-name", { id: headingId }, removed ? `Cards from deleted column “${removed.name || "Untitled"}”` : "Cards from a deleted column"), countOf(doc, ids.length));
214
+ if (removed) head.append(iconButton(doc, "page-board-btn page-board-restore", "restore", "Restore column", { "data-action": "restore-column", "data-col": col, "data-focus": `restore:${col}` }));
215
+ const list = el(doc, "ol", "page-board-cards");
216
+ for (const id of ids) {
217
+ const item = el(doc, "li");
218
+ item.append(this.card(id), this.moveSelect(id));
219
+ list.append(item);
220
+ }
221
+ tray.append(head, list);
222
+ return tray;
223
+ }
224
+
225
+ deletedTray(ids, cards) {
226
+ const doc = this.dom.ownerDocument;
227
+ const tray = el(doc, "div", "page-board-tray page-board-deleted", { role: "group", "aria-label": "Deleted cards" });
228
+ const head = el(doc, "div", "page-board-tray-head");
229
+ head.append(iconButton(doc, "page-board-btn page-board-deleted-toggle", "chevron", `Deleted cards (${ids.length})`, { "aria-expanded": String(!!this.showDeleted), "data-action": "toggle-deleted", "data-focus": "toggle-deleted" }));
230
+ tray.append(head);
231
+ if (!this.showDeleted) return tray;
232
+ const list = el(doc, "ol", "page-board-deleted-list");
233
+ for (const id of ids.sort((a, b) => (cards[a].pos ?? "") < (cards[b].pos ?? "") ? -1 : 1)) {
234
+ const title = cards[id].title || "Untitled";
235
+ const item = el(doc, "li", "page-board-deleted-item");
236
+ item.append(el(doc, "span", "page-board-title", {}, title), iconButton(doc, "page-board-btn page-board-restore", "restore", "Restore", { "aria-label": `Restore ${title}`, "data-action": "restore-card", "data-card": id, "data-focus": `restore-card:${id}` }));
237
+ list.append(item);
238
+ }
239
+ tray.append(list);
240
+ return tray;
241
+ }
242
+
243
+ moveSelect(id) {
244
+ const doc = this.dom.ownerDocument;
245
+ const wrap = el(doc, "span", "page-board-select page-board-move");
246
+ const select = el(doc, "select", null, { "aria-label": `Move ${this.board.cards[id].title || "Untitled"} to column`, "data-action": "orphan-move", "data-card": id, "data-focus": `move:${id}`, disabled: !this.board.columnIds.length });
247
+ select.append(el(doc, "option", null, { value: "", selected: true, disabled: true, hidden: true }, "Move to column"));
248
+ for (const col of this.board.columnIds) select.append(el(doc, "option", null, { value: col }, this.board.columns[col].name || "Untitled"));
249
+ wrap.append(select);
250
+ wrap.insertAdjacentHTML("beforeend", BOARD_ICONS.chevron);
251
+ return wrap;
252
+ }
253
+
254
+ columnMenu(colId, index, name) {
255
+ const doc = this.dom.ownerDocument;
256
+ const last = this.board.columnIds.length - 1;
257
+ const menu = el(doc, "div", "page-board-pop page-board-menu", { role: "menu", "aria-label": `${name} column` });
258
+ const item = (action, label, icon, disabled = false) => iconButton(doc, `page-board-option${action === "delete-column" ? " is-danger" : ""}`, icon, label, { role: "menuitem", "data-action": action, "data-col": colId, "data-focus": `${action}:${colId}`, disabled });
259
+ menu.append(item("rename", "Rename", "pencil"), item("left", "Move left", "left", index === 0), item("right", "Move right", "right", index === last), el(doc, "div", "page-board-sep", { role: "separator" }), item("delete-column", "Delete column", "trash"));
260
+ return menu;
261
+ }
262
+
263
+ isOpen(cardId) { return this.panel?.isOpen(this.blockId, cardId) ?? false; }
264
+
265
+ openMenu(colId) {
266
+ this.menu = colId;
267
+ this.render();
268
+ this.dom.querySelector(".page-board-menu .page-board-option:not(:disabled)")?.focus();
269
+ }
270
+
271
+ closeMenu(refocus) {
272
+ const colId = this.menu;
273
+ this.menu = null;
274
+ this.dom.querySelector(".page-board-menu")?.remove();
275
+ for (const button of this.dom.querySelectorAll(".page-board-colmenu")) button.setAttribute("aria-expanded", "false");
276
+ if (refocus) this.focusKey(`colmenu:${colId}`);
277
+ }
278
+
279
+ inlineInput(cls, label, key, value = "") {
280
+ const input = el(this.dom.ownerDocument, "input", cls, { type: "text", "aria-label": label, "data-focus": key, placeholder: cls === "page-board-new" ? "Card title" : "Column name" });
281
+ input.value = value;
282
+ input.addEventListener("keydown", (event) => {
283
+ if (event.isComposing || event.keyCode === 229) return;
284
+ if (event.key === "Enter") { event.preventDefault(); this.finishEdit(true, true); }
285
+ else if (event.key === "Escape") { event.preventDefault(); this.finishEdit(false, true); }
286
+ });
287
+ input.addEventListener("blur", () => {
288
+ if (this.rendering) return;
289
+ setTimeout(() => { if (this.editing?.input === input && input.ownerDocument.activeElement !== input) this.finishEdit(true, false); }, 0);
290
+ });
291
+ return input;
292
+ }
293
+
294
+ startEdit(kind, colId) {
295
+ if (this.editing) this.finishEdit(true, false);
296
+ this.menu = null;
297
+ const name = kind === "rename" ? this.board.columns[colId]?.name ?? "" : "";
298
+ const key = kind === "card" ? `new:${colId}` : kind === "column" ? "new-column" : `name:${colId}`;
299
+ const input = this.inlineInput(kind === "card" ? "page-board-new" : "page-board-rename", kind === "card" ? "New card title" : "Column name", key, name);
300
+ this.editing = { kind, id: colId, input };
301
+ this.render();
302
+ input.focus();
303
+ input.select();
304
+ }
305
+
306
+ finishEdit(save, refocus) {
307
+ const editing = this.editing;
308
+ if (!editing) return;
309
+ const value = editing.input.value.trim();
310
+ if (editing.kind === "card" && save && value && refocus) {
311
+ const result = this.run("createCard", { columnId: editing.id, title: value });
312
+ if (result?.status !== "refused") editing.input.value = "";
313
+ else if (!this.board.columns[editing.id]) {
314
+ this.editing = null;
315
+ this.render();
316
+ this.focusKey("add-column");
317
+ return;
318
+ }
319
+ this.focusKey(`new:${editing.id}`);
320
+ return;
321
+ }
322
+ this.editing = null;
323
+ let focus = editing.kind === "column" ? "add-column" : editing.kind === "card" ? `add:${editing.id}` : `colmenu:${editing.id}`;
324
+ if (save && value) {
325
+ if (editing.kind === "card") this.run("createCard", { columnId: editing.id, title: value });
326
+ else if (editing.kind === "column") {
327
+ const result = this.run("createColumn", { name: value });
328
+ if (result.status === "created") focus = `add:${result.id}`;
329
+ } else if (value !== this.board.columns[editing.id]?.name) this.run("renameColumn", editing.id, value);
330
+ }
331
+ this.render();
332
+ if (refocus) this.focusKey(focus);
333
+ }
334
+
335
+ shiftColumn(colId, delta) {
336
+ const ids = this.board.columnIds;
337
+ const index = ids.indexOf(colId);
338
+ this.menu = null;
339
+ this.run("moveColumn", colId, { afterId: delta < 0 ? placeAfter(ids, index) : ids[index + 1] });
340
+ this.render();
341
+ this.focusKey(`colmenu:${colId}`);
342
+ }
343
+
344
+ deleteColumn(colId) {
345
+ const ids = this.board.columnIds;
346
+ const next = ids[ids.indexOf(colId) + 1] ?? ids[ids.indexOf(colId) - 1];
347
+ this.menu = null;
348
+ const result = this.run("removeColumn", colId);
349
+ this.render();
350
+ if (result.status !== "removed") return;
351
+ announce(this.view, result.cards ? "Column deleted. Its cards are under Cards from a deleted column." : "Column deleted");
352
+ if (!this.focusKey(`restore:${colId}`) && !this.focusKey(`colmenu:${next}`)) this.focusKey("add-column");
353
+ }
354
+
355
+ restoreCard(cardId) {
356
+ if (this.run("restoreCard", cardId).status !== "restored") return;
357
+ announce(this.view, "Card restored");
358
+ this.focusKey(`card:${cardId}`) || this.focusKey("toggle-deleted");
359
+ }
360
+
361
+ restoreColumn(colId) {
362
+ if (this.run("restoreColumn", colId).status !== "restored") return;
363
+ announce(this.view, "Column restored");
364
+ this.focusKey(`colmenu:${colId}`);
365
+ }
366
+
367
+ openCard(cardId) {
368
+ this.menu = null;
369
+ this.dom.querySelector(".page-board-menu")?.remove();
370
+ this.panel?.open(this.blockId, cardId, { commands: this.commands, undo: this.undo, redo: this.redo });
371
+ }
372
+
373
+ click(event) {
374
+ const target = event.target.closest?.("[data-action]");
375
+ if (!target || !this.dom.contains(target) || target.disabled) return;
376
+ const { action, col, card } = target.dataset;
377
+ if (this.editing && action !== "add-card" && action !== "add-column" && action !== "rename") this.finishEdit(true, false);
378
+ if (action === "card") this.openCard(card);
379
+ else if (action === "colmenu") { if (this.menu === col) this.closeMenu(true); else this.openMenu(col); }
380
+ else if (action === "rename") this.startEdit("rename", col);
381
+ else if (action === "left" || action === "right") this.shiftColumn(col, action === "left" ? -1 : 1);
382
+ else if (action === "delete-column") this.deleteColumn(col);
383
+ else if (action === "add-card") this.startEdit("card", col);
384
+ else if (action === "add-column") this.startEdit("column", null);
385
+ else if (action === "restore-column") this.restoreColumn(col);
386
+ else if (action === "restore-card") this.restoreCard(card);
387
+ else if (action === "toggle-deleted") { this.showDeleted = !this.showDeleted; this.render(); this.focusKey("toggle-deleted"); }
388
+ }
389
+
390
+ change(event) {
391
+ const select = event.target;
392
+ if (select.dataset?.action !== "orphan-move" || !select.value) return;
393
+ const card = select.dataset.card, col = select.value;
394
+ const ids = this.board.lanes.get(col) ?? [];
395
+ if (this.run("moveCard", card, { columnId: col, afterId: ids[ids.length - 1] ?? null }).status === "moved") this.focusKey(`card:${card}`);
396
+ }
397
+
398
+ keydown(event) {
399
+ const key = event.key.toLowerCase();
400
+ if ((event.metaKey || event.ctrlKey) && (key === "z" || key === "y") && !isTyping(event.target)) {
401
+ event.preventDefault();
402
+ if (key === "y" || event.shiftKey) this.redo?.();
403
+ else this.undo?.();
404
+ return;
405
+ }
406
+ const menu = event.target.closest?.(".page-board-menu");
407
+ if (!menu) return;
408
+ if (event.key === "Escape" || event.key === "Tab") {
409
+ event.preventDefault();
410
+ this.closeMenu(true);
411
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
412
+ event.preventDefault();
413
+ const items = [...menu.querySelectorAll(".page-board-option:not(:disabled)")];
414
+ const at = items.indexOf(event.target);
415
+ items[(at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
416
+ }
417
+ }
418
+
419
+ focusout(event) {
420
+ if (this.rendering || this.menu === null) return;
421
+ const menu = this.dom.querySelector(".page-board-menu");
422
+ if (menu?.contains(event.target) && !menu.contains(event.relatedTarget)) this.closeMenu(false);
423
+ }
424
+
425
+ update(node) {
426
+ if (node.type !== N.board || node.attrs.id !== this.node.attrs.id) return false;
427
+ const changed = !node.sameMarkup(this.node);
428
+ this.node = node;
429
+ if (changed) this.render();
430
+ return true;
431
+ }
432
+
433
+ stopEvent() { return true; }
434
+
435
+ ignoreMutation() { return true; }
436
+
437
+ destroy() {
438
+ const views = boardViews.get(this.view);
439
+ const next = views?.get(this.blockId);
440
+ if (this.editing && next && next !== this) {
441
+ next.editing = this.editing;
442
+ next.render();
443
+ }
444
+ if (next === this) views.delete(this.blockId);
445
+ this.dragger.stop();
446
+ }
447
+ }
@@ -0,0 +1,41 @@
1
+ import { state } from "../vendor/prosemirror.vendor.js";
2
+ import { schema } from "./schema.js";
3
+ import { isKey, keyBetween, keysBetween, newId } from "./model.js";
4
+ import { outline } from "./blocks.js";
5
+ import { asStep } from "./blocksel.js";
6
+
7
+ const { TextSelection } = state;
8
+ const N = schema.nodes;
9
+
10
+ export const BOARD_COLUMNS = Object.freeze(["To do", "In progress", "Done"]);
11
+
12
+ const keyAfter = (pos, next) => (isKey(pos) && (next === null || (isKey(next) && pos < next)) ? keyBetween(pos, next) : null);
13
+
14
+ export function makeBoard(names = BOARD_COLUMNS) {
15
+ const positions = keysBetween(null, null, names.length);
16
+ const columns = Object.fromEntries(names.map((name, i) => [newId("c"), { pos: positions[i], name }]));
17
+ return N.board.create({ id: newId(), columns, cards: {} });
18
+ }
19
+
20
+ // The board takes the place of an empty line, otherwise it goes after the
21
+ // line's whole subtree at the line's own level. An empty paragraph follows it
22
+ // and holds the caret, so typing goes on below the board.
23
+ export function insertBoardNode(st, dispatch, board = makeBoard(), remove = null) {
24
+ const tr = st.tr;
25
+ if (remove) tr.delete(remove.from, remove.to);
26
+ const index = tr.doc.resolve(tr.mapping.map(st.selection.head)).index(0);
27
+ const ents = outline(tr.doc);
28
+ const line = tr.doc.child(index);
29
+ const empty = line.isTextblock && line.content.size === 0;
30
+ const last = empty ? index : ents[index].subEnd;
31
+ const from = empty ? ents[index].at : ents[last].end;
32
+ const next = ents[last + 1]?.node.attrs.pos ?? null;
33
+ const parent = line.attrs.parent ?? null;
34
+ const pos = empty ? line.attrs.pos : keyAfter(ents[last].node.attrs.pos, next);
35
+ const placed = N.board.create({ ...board.attrs, pos, parent });
36
+ const after = N.paragraph.create({ id: newId(), pos: keyAfter(pos, next), parent });
37
+ tr.replaceWith(from, empty ? ents[index].end : from, [placed, after]);
38
+ tr.setSelection(TextSelection.create(tr.doc, from + placed.nodeSize + 1));
39
+ if (dispatch) dispatch(asStep(tr).scrollIntoView());
40
+ return true;
41
+ }