@panphora/clayjs 1.13.0 → 1.14.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69739 -41784
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +95 -94
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/plugins/visitor-drafts/prompt.js +4 -6
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- 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
|
+
}
|