@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,195 @@
|
|
|
1
|
+
import { applyOp, cloneJson } from "../../live/ops.js";
|
|
2
|
+
|
|
3
|
+
export const ARTIFACT_LIMIT = 24576;
|
|
4
|
+
export const STATE_LIMIT = 8192;
|
|
5
|
+
const ARTIFACT_KEYS = ["title", "html", "css", "javascript", "fallbackHtml", "summary"];
|
|
6
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
7
|
+
let nextScope = 0;
|
|
8
|
+
|
|
9
|
+
export class ToolStateError extends Error {
|
|
10
|
+
constructor(code) {
|
|
11
|
+
super(code);
|
|
12
|
+
this.name = "ToolStateError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateArtifact(value) {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
19
|
+
Object.keys(value).length !== ARTIFACT_KEYS.length ||
|
|
20
|
+
!ARTIFACT_KEYS.every((key) => Object.hasOwn(value, key) && typeof value[key] === "string")) return { ok: false, code: "invalid-artifact" };
|
|
21
|
+
return bytes(value) <= ARTIFACT_LIMIT ? { ok: true } : { ok: false, code: "artifact-too-large" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateState(value) {
|
|
25
|
+
try {
|
|
26
|
+
const copy = cloneJson(value);
|
|
27
|
+
return bytes(copy) <= STATE_LIMIT ? { ok: true } : { ok: false, code: "state-too-large" };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ok: false, code: "invalid-state" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pathOf(path) {
|
|
34
|
+
if (!Array.isArray(path) || path.some((part) => typeof part !== "string" && !(Number.isSafeInteger(part) && part >= 0))) {
|
|
35
|
+
throw new TypeError("tool state path must be an array of strings or non-negative integers");
|
|
36
|
+
}
|
|
37
|
+
return path.slice();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkedState(current, op) {
|
|
41
|
+
try {
|
|
42
|
+
const holder = { state: current === undefined ? {} : cloneJson(current) };
|
|
43
|
+
applyOp(holder, { ...op, path: ["state", ...op.path] });
|
|
44
|
+
const result = validateState(holder.state);
|
|
45
|
+
if (!result.ok) throw new ToolStateError(result.code);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof ToolStateError) throw error;
|
|
48
|
+
throw new ToolStateError("invalid-state");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function scopedStateHandle(store, toolId, rev) {
|
|
53
|
+
const prefix = [toolId, rev];
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
get data() { return store.data?.[toolId]?.[rev]; },
|
|
56
|
+
set(path, value) {
|
|
57
|
+
const parts = pathOf(path);
|
|
58
|
+
checkedState(store.data?.[toolId]?.[rev], { op: "set", path: parts, value });
|
|
59
|
+
store.set([...prefix, ...parts], value);
|
|
60
|
+
},
|
|
61
|
+
delete(path) { store.delete([...prefix, ...pathOf(path)]); },
|
|
62
|
+
add(path, n = 1) {
|
|
63
|
+
const parts = pathOf(path);
|
|
64
|
+
if (!parts.length || typeof n !== "number" || !Number.isFinite(n)) throw new ToolStateError("invalid-state");
|
|
65
|
+
checkedState(store.data?.[toolId]?.[rev], { op: "add", path: parts, n });
|
|
66
|
+
store.add([...prefix, ...parts], n);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readerStateHandle(snapshot) {
|
|
72
|
+
let data = cloneJson(snapshot ?? {});
|
|
73
|
+
const listeners = new Set();
|
|
74
|
+
const emit = () => { for (const fn of [...listeners]) fn(); };
|
|
75
|
+
const write = (op) => {
|
|
76
|
+
const holder = { state: data };
|
|
77
|
+
applyOp(holder, { ...op, path: ["state", ...op.path] });
|
|
78
|
+
const result = validateState(holder.state);
|
|
79
|
+
if (!result.ok) throw new ToolStateError(result.code);
|
|
80
|
+
data = holder.state;
|
|
81
|
+
emit();
|
|
82
|
+
};
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
get data() { return cloneJson(data); },
|
|
85
|
+
set(path, value) { write({ op: "set", path: pathOf(path), value }); },
|
|
86
|
+
delete(path) { write({ op: "del", path: pathOf(path) }); },
|
|
87
|
+
add(path, n = 1) {
|
|
88
|
+
const parts = pathOf(path);
|
|
89
|
+
if (!parts.length || typeof n !== "number" || !Number.isFinite(n)) throw new ToolStateError("invalid-state");
|
|
90
|
+
write({ op: "add", path: parts, n });
|
|
91
|
+
},
|
|
92
|
+
on(type, fn) {
|
|
93
|
+
if (type !== "change" || typeof fn !== "function") throw new TypeError("reader state supports on(change, fn)");
|
|
94
|
+
listeners.add(fn);
|
|
95
|
+
return () => listeners.delete(fn);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitSelectors(selector) {
|
|
101
|
+
const out = [];
|
|
102
|
+
let start = 0, quote = null, depth = 0;
|
|
103
|
+
for (let i = 0; i < selector.length; i++) {
|
|
104
|
+
const ch = selector[i];
|
|
105
|
+
if (quote) { if (ch === quote && selector[i - 1] !== "\\") quote = null; }
|
|
106
|
+
else if (ch === '"' || ch === "'") quote = ch;
|
|
107
|
+
else if (ch === "(" || ch === "[") depth++;
|
|
108
|
+
else if (ch === ")" || ch === "]") depth--;
|
|
109
|
+
else if (ch === "," && depth === 0) { out.push(selector.slice(start, i).trim()); start = i + 1; }
|
|
110
|
+
}
|
|
111
|
+
out.push(selector.slice(start).trim());
|
|
112
|
+
return out.filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function scopeCss(css, blockSelector) {
|
|
116
|
+
let out = "", cursor = 0;
|
|
117
|
+
while (cursor < css.length) {
|
|
118
|
+
const open = css.indexOf("{", cursor);
|
|
119
|
+
if (open < 0) { out += css.slice(cursor); break; }
|
|
120
|
+
const selector = css.slice(cursor, open).trim();
|
|
121
|
+
let depth = 1, end = open + 1, quote = null;
|
|
122
|
+
for (; end < css.length && depth; end++) {
|
|
123
|
+
const ch = css[end];
|
|
124
|
+
if (quote) { if (ch === quote && css[end - 1] !== "\\") quote = null; }
|
|
125
|
+
else if (ch === '"' || ch === "'") quote = ch;
|
|
126
|
+
else if (ch === "{") depth++;
|
|
127
|
+
else if (ch === "}") depth--;
|
|
128
|
+
}
|
|
129
|
+
const body = css.slice(open + 1, end - 1);
|
|
130
|
+
if (/^@(?:media|supports|container)\b/i.test(selector)) out += `${selector}{${scopeCss(body, blockSelector)}}`;
|
|
131
|
+
else if (selector.startsWith("@")) out += `${selector}{${body}}`;
|
|
132
|
+
else out += `${splitSelectors(selector).map((part) => `${blockSelector} ${part}`).join(", ")}{${body}}`;
|
|
133
|
+
cursor = end;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function mountTool({ root, artifact, mode, state, tables = () => [], onError = () => {}, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
139
|
+
const listeners = { change: new Set(), tables: new Set() };
|
|
140
|
+
const timers = new Set();
|
|
141
|
+
let cleanup, disposed = false;
|
|
142
|
+
const dispose = () => {
|
|
143
|
+
if (disposed) return;
|
|
144
|
+
disposed = true;
|
|
145
|
+
for (const timer of timers) clearTimer(timer);
|
|
146
|
+
timers.clear();
|
|
147
|
+
for (const group of Object.values(listeners)) group.clear();
|
|
148
|
+
const fn = cleanup;
|
|
149
|
+
cleanup = null;
|
|
150
|
+
if (typeof fn === "function") fn();
|
|
151
|
+
};
|
|
152
|
+
const notify = (type) => { if (!disposed) for (const fn of [...listeners[type]]) fn(); };
|
|
153
|
+
const allTables = () => Object.freeze(cloneJson(tables()).map((table) => deepFreeze(table)));
|
|
154
|
+
const tool = Object.freeze({
|
|
155
|
+
root, mode, state,
|
|
156
|
+
on(event, fn) {
|
|
157
|
+
if (!Object.hasOwn(listeners, event) || typeof fn !== "function") throw new TypeError("unknown tool event");
|
|
158
|
+
listeners[event].add(fn);
|
|
159
|
+
return () => listeners[event].delete(fn);
|
|
160
|
+
},
|
|
161
|
+
table(id) { return allTables().find((table) => table.id === id) ?? null; },
|
|
162
|
+
tables: allTables,
|
|
163
|
+
schedule(fn, ms) {
|
|
164
|
+
if (disposed) return null;
|
|
165
|
+
const timer = setTimer(() => { timers.delete(timer); if (!disposed) fn(); }, ms);
|
|
166
|
+
timers.add(timer);
|
|
167
|
+
return timer;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
root.innerHTML = artifact.html;
|
|
172
|
+
const style = root.ownerDocument.createElement("style");
|
|
173
|
+
const scope = String(++nextScope);
|
|
174
|
+
const selector = `[data-tool-scope="${scope}"]`;
|
|
175
|
+
root.setAttribute("data-tool-scope", scope);
|
|
176
|
+
style.textContent = scopeCss(artifact.css, selector);
|
|
177
|
+
root.prepend(style);
|
|
178
|
+
const body = `${artifact.javascript}\n;return typeof mount === "function" ? mount : undefined;`;
|
|
179
|
+
const exported = new Function("tool", body)(tool);
|
|
180
|
+
const mount = typeof exported === "function" ? exported : exported?.mount;
|
|
181
|
+
if (typeof mount !== "function") throw new TypeError("tool script must define mount(tool)");
|
|
182
|
+
cleanup = mount(tool);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
try { dispose(); } catch (cleanupError) { onError(cleanupError); }
|
|
185
|
+
onError(error);
|
|
186
|
+
root.innerHTML = `${artifact.fallbackHtml}<p data-tool-error>Tool could not start</p>`;
|
|
187
|
+
}
|
|
188
|
+
return { notifyState: () => notify("change"), notifyTables: () => notify("tables"), dispose };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function deepFreeze(value) {
|
|
192
|
+
if (value && typeof value === "object") { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); }
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The page's save transform: the clone's article is rendered from the clone's
|
|
2
|
+
// own page tags, which plugins/live.js has already written with the values this
|
|
3
|
+
// save carries. Statically importing save-freeze.js makes ES module evaluation
|
|
4
|
+
// register its transform first, so its restore of the load-time article can
|
|
5
|
+
// never run after this and undo it.
|
|
6
|
+
import "../attrs/save-freeze.js";
|
|
7
|
+
import { addDocumentTransform } from "../core/snapshot.js";
|
|
8
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
9
|
+
import { fragmentCleaner, project, renderModel, revOf } from "./projection.js";
|
|
10
|
+
import { blocksFromPageValues } from "./ybind/decode.js";
|
|
11
|
+
import { YDecodeError } from "./ybind/provider.js";
|
|
12
|
+
|
|
13
|
+
export const ARTICLE = "article[data-page]";
|
|
14
|
+
const PAGE = /^page(\.[0-9]+)?$/;
|
|
15
|
+
|
|
16
|
+
export function pageTags(root) {
|
|
17
|
+
return [...root.querySelectorAll('script[merge][clay~="live"]')].filter((el) => PAGE.test(el.getAttribute("merge")));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Captures run several times per save (save, comparison, dirty) and once per
|
|
21
|
+
// incoming frame (the merge's save clone). Comparison and dirty clones have
|
|
22
|
+
// neither the article nor the tags, so they return at once; the rest share one
|
|
23
|
+
// render while the tags hold the same text.
|
|
24
|
+
let memo = null;
|
|
25
|
+
|
|
26
|
+
export function renderFromTags(texts) {
|
|
27
|
+
const key = texts.join("\u0000");
|
|
28
|
+
if (memo && memo.key === key) return memo;
|
|
29
|
+
const values = texts.map((text) => {
|
|
30
|
+
try { return HyperMorph.parseJsonRelaxed(text); }
|
|
31
|
+
catch { throw new YDecodeError("invalid-json", "Cannot read the page data as JSON"); }
|
|
32
|
+
});
|
|
33
|
+
const blocks = blocksFromPageValues(values);
|
|
34
|
+
const model = project(blocks);
|
|
35
|
+
const fallbackOf = (id) => {
|
|
36
|
+
const html = blocks[id]?.artifact?.fallbackHtml;
|
|
37
|
+
return typeof html === "string" ? html : "";
|
|
38
|
+
};
|
|
39
|
+
memo = { key, html: renderModel(model, { fallbackOf, clean: fragmentCleaner(document) }), rev: revOf(model) };
|
|
40
|
+
return memo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function projectInto(clone) {
|
|
44
|
+
const article = clone.querySelector(ARTICLE);
|
|
45
|
+
if (!article) return;
|
|
46
|
+
const tags = pageTags(clone);
|
|
47
|
+
if (!tags.length) return;
|
|
48
|
+
const { html, rev } = renderFromTags(tags.map((el) => el.textContent));
|
|
49
|
+
if (article.innerHTML !== html) article.innerHTML = html;
|
|
50
|
+
article.setAttribute("data-rev", rev);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addDocumentTransform(projectInto);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { captureForSaveAndComparison } from "../core/snapshot.js";
|
|
2
|
+
import { downloadHtml, documentName } from "../core/conflict-download.js";
|
|
3
|
+
|
|
4
|
+
// One plain state per save refusal code, so the indicator, the toast and the
|
|
5
|
+
// page plugin all read the same answer. `saved` is the only happy one; every
|
|
6
|
+
// code a failed save or a live notice can carry is either worth another try on
|
|
7
|
+
// its own (retrying) or needs a person to do something (blocked). A code this
|
|
8
|
+
// table does not know falls to `error`, which leaves the existing generic
|
|
9
|
+
// "Couldn't save" feedback exactly as it was.
|
|
10
|
+
const SAVE_STATE_BY_CODE = Object.freeze({
|
|
11
|
+
"page-converting": "retrying",
|
|
12
|
+
"page-capacity": "retrying",
|
|
13
|
+
busy: "retrying",
|
|
14
|
+
"rate-limited": "retrying",
|
|
15
|
+
offline: "retrying",
|
|
16
|
+
network: "retrying",
|
|
17
|
+
"network-error": "retrying",
|
|
18
|
+
timeout: "retrying",
|
|
19
|
+
"save-history-pending": "retrying",
|
|
20
|
+
"page-condition-required": "blocked",
|
|
21
|
+
"page-conversion-refused": "blocked",
|
|
22
|
+
"page-conversion-required": "blocked",
|
|
23
|
+
"page-conversion-stale": "blocked",
|
|
24
|
+
"save-id-reused": "blocked",
|
|
25
|
+
"too-large": "blocked",
|
|
26
|
+
"unsupported-host": "blocked",
|
|
27
|
+
"save-history-refresh-required": "blocked",
|
|
28
|
+
"provenance-unsupported": "blocked",
|
|
29
|
+
"receipt-expired": "blocked",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const BLOCKED_TEXT = "Can't save here. Your edits are still in this tab.";
|
|
33
|
+
const RETRY_TEXT = "Still trying to save. Your edits are safe in this tab.";
|
|
34
|
+
|
|
35
|
+
function saveStateFor({ ok = false, code = null, offline = false } = {}) {
|
|
36
|
+
if (ok) return "saved";
|
|
37
|
+
if (offline) return "retrying";
|
|
38
|
+
return SAVE_STATE_BY_CODE[code] ?? "error";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A copy opens through the article rebuild, so it carries the projected article
|
|
42
|
+
// and no page store: the merge state is the host's, never the file's.
|
|
43
|
+
// The one page tag is removed from the captured text as it was written there; a
|
|
44
|
+
// tag that cannot be found exactly once leaves the capture as it is.
|
|
45
|
+
function withoutPageTag(html) {
|
|
46
|
+
let doc;
|
|
47
|
+
try { doc = new DOMParser().parseFromString(html, "text/html"); } catch { return html; }
|
|
48
|
+
const tags = [...doc.querySelectorAll('script[type="application/json"][merge="page"][clay~="live"]')]
|
|
49
|
+
.filter((el) => !el.closest("template") && !el.closest("noscript"));
|
|
50
|
+
if (tags.length !== 1) return html;
|
|
51
|
+
const outer = tags[0].outerHTML;
|
|
52
|
+
const at = html.indexOf(outer);
|
|
53
|
+
if (at < 0 || html.indexOf(outer, at + 1) !== -1) return html;
|
|
54
|
+
return html.slice(0, at) + html.slice(at + outer.length);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One complete, openable file: the capture the save would have sent, with the
|
|
58
|
+
// page projection already applied by the live store's snapshot listener, and
|
|
59
|
+
// without the page editor's merge state, which no saved file or download carries.
|
|
60
|
+
function downloadPageCopy() {
|
|
61
|
+
const { forSave } = captureForSaveAndComparison();
|
|
62
|
+
return downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A copy of the page captured earlier (captureForSaveAndComparison().forSave), as a download.
|
|
66
|
+
function downloadSnapshot(forSave) {
|
|
67
|
+
return downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The article's own plain text, never the editor's chrome: the capture carries
|
|
71
|
+
// the projected article, and only its text is asked for.
|
|
72
|
+
async function copyPageText() {
|
|
73
|
+
try {
|
|
74
|
+
const { forSave } = captureForSaveAndComparison();
|
|
75
|
+
const doc = new DOMParser().parseFromString(forSave, "text/html");
|
|
76
|
+
const articles = doc.querySelectorAll("article[data-page]");
|
|
77
|
+
if (articles.length !== 1) return false;
|
|
78
|
+
await navigator.clipboard.writeText(articles[0].textContent);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { SAVE_STATE_BY_CODE, BLOCKED_TEXT, RETRY_TEXT, saveStateFor, downloadPageCopy, downloadSnapshot, copyPageText };
|
package/src/page/schema.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// toDOM here draws the editing surface. The saved, readable HTML is the
|
|
11
11
|
// projection's job, not this file's.
|
|
12
12
|
import { model } from "../vendor/prosemirror.vendor.js";
|
|
13
|
+
import { safeHref, safeFileHref } from "./address.js";
|
|
13
14
|
|
|
14
15
|
const block = { id: { default: null }, pos: { default: null }, parent: { default: null } };
|
|
15
16
|
const ref = (n, extra = {}) => ({ "data-id": n.attrs.id, ...extra });
|
|
@@ -80,8 +81,15 @@ export const schema = new model.Schema({
|
|
|
80
81
|
group: "block",
|
|
81
82
|
atom: true,
|
|
82
83
|
draggable: true,
|
|
83
|
-
attrs: { ...block, src: { default: "" }, alt: { default: "" }, caption: { default: "" }, width: { default: null } },
|
|
84
|
-
toDOM: (n) => ["figure", ref(n), ["img", { src: n.attrs.src, alt: n.attrs.alt }], ["figcaption", n.attrs.caption]],
|
|
84
|
+
attrs: { ...block, src: { default: "" }, alt: { default: "" }, caption: { default: "" }, width: { default: null }, align: { default: null } },
|
|
85
|
+
toDOM: (n) => ["figure", ref(n, { "data-type": "image", ...(n.attrs.width ? { "data-width": String(n.attrs.width) } : {}), ...(n.attrs.align ? { "data-align": n.attrs.align } : {}) }), ["img", { src: n.attrs.src, alt: n.attrs.alt }], ["figcaption", n.attrs.caption]],
|
|
86
|
+
},
|
|
87
|
+
file: {
|
|
88
|
+
group: "block",
|
|
89
|
+
atom: true,
|
|
90
|
+
attrs: { ...block, src: { default: "" }, name: { default: "" }, size: { default: null }, mime: { default: null } },
|
|
91
|
+
toDOM: (n) => ["div", ref(n, { class: "page-file", "data-type": "file", "data-src": safeFileHref(n.attrs.src) ?? "", "data-name": n.attrs.name, "data-size": n.attrs.size == null ? "" : String(n.attrs.size), "data-mime": n.attrs.mime ?? "" }),
|
|
92
|
+
["a", { href: safeFileHref(n.attrs.src), target: "_blank", rel: "noopener noreferrer" }, n.attrs.name || "file"]],
|
|
85
93
|
},
|
|
86
94
|
table: {
|
|
87
95
|
group: "block",
|
|
@@ -96,8 +104,7 @@ export const schema = new model.Schema({
|
|
|
96
104
|
toDOM: (n) => ["tr", ref(n), 0],
|
|
97
105
|
},
|
|
98
106
|
table_cell: {
|
|
99
|
-
content: "
|
|
100
|
-
marks: "",
|
|
107
|
+
content: "inline*",
|
|
101
108
|
isolating: true,
|
|
102
109
|
attrs: { col: { default: null } },
|
|
103
110
|
toDOM: (n) => ["td", { "data-col": n.attrs.col }, 0],
|
|
@@ -151,7 +158,7 @@ export const schema = new model.Schema({
|
|
|
151
158
|
link: {
|
|
152
159
|
attrs: { href: {} },
|
|
153
160
|
inclusive: false,
|
|
154
|
-
toDOM: (m) => ["a", { href: m.attrs.href }, 0],
|
|
161
|
+
toDOM: (m) => ["a", { href: safeHref(m.attrs.href) }, 0],
|
|
155
162
|
},
|
|
156
163
|
},
|
|
157
164
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { orderedIds } from "./model.js";
|
|
2
|
+
import { effectiveKind } from "./rich/csv.js";
|
|
3
|
+
|
|
4
|
+
const cache = new WeakMap();
|
|
5
|
+
|
|
6
|
+
export function columnKinds(table) {
|
|
7
|
+
let out = cache.get(table);
|
|
8
|
+
if (out) return out;
|
|
9
|
+
out = {};
|
|
10
|
+
for (const [index, id] of orderedIds(table.attrs.cols).entries()) {
|
|
11
|
+
const values = [];
|
|
12
|
+
table.forEach((row) => { if (index < row.childCount) values.push(row.child(index).textContent.trim()); });
|
|
13
|
+
out[id] = effectiveKind(table.attrs.cols[id].kind, values);
|
|
14
|
+
}
|
|
15
|
+
cache.set(table, out);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const KIND_ICONS = Object.freeze({ text: "Aa", number: "#", date: "📅" });
|
|
20
|
+
export const KIND_LABELS = Object.freeze({ text: "Text", number: "Number", date: "Date" });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Spreadsheet cells on the clipboard as a grid of cell values (a string, or
|
|
2
|
+
// { c, m } when a cell has marks or line breaks), and a new table from a grid.
|
|
3
|
+
// Google Sheets, Excel and Numbers put an HTML table and tab-separated text on
|
|
4
|
+
// the clipboard; the HTML keeps bold, italic and links, the text is the fallback.
|
|
5
|
+
import { schema } from "./schema.js";
|
|
6
|
+
import { keysBetween, newId, nodeFromBlock, cellValue } from "./model.js";
|
|
7
|
+
import { parseCsv } from "./rich/csv.js";
|
|
8
|
+
import { cellText } from "./rich/cell.js";
|
|
9
|
+
import { clipboardParser } from "./blocks.js";
|
|
10
|
+
|
|
11
|
+
function htmlGrid(html, doc) {
|
|
12
|
+
if (!html || !/<table[\s>]/i.test(html)) return null;
|
|
13
|
+
const Parser = doc.defaultView?.DOMParser ?? globalThis.DOMParser;
|
|
14
|
+
if (!Parser) return null;
|
|
15
|
+
const body = new Parser().parseFromString(html, "text/html").body;
|
|
16
|
+
const tables = [...body.querySelectorAll("table")].filter((table) => !table.parentElement.closest("table"));
|
|
17
|
+
if (tables.length !== 1) return null;
|
|
18
|
+
const rest = body.cloneNode(true);
|
|
19
|
+
for (const skip of rest.querySelectorAll("table, style, script, template, meta, title")) skip.remove();
|
|
20
|
+
if (rest.textContent.trim()) return null;
|
|
21
|
+
const table = tables[0];
|
|
22
|
+
const rows = [...table.querySelectorAll("tr")].filter((tr) => tr.closest("table") === table);
|
|
23
|
+
const grid = rows.map((tr) => [...tr.children].filter((cell) => cell.tagName === "TD" || cell.tagName === "TH").map((cell) => cellValue(clipboardParser.parse(cell, { topNode: schema.nodes.table_cell.create() }))));
|
|
24
|
+
return { grid: grid.filter((row) => row.length), own: !!table.querySelector("td[data-col]") };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function textGrid(text) {
|
|
28
|
+
if (!text || !text.includes("\t")) return null;
|
|
29
|
+
const { header, rows } = parseCsv(text.replace(/\r?\n$/, ""), { delimiter: "\t" });
|
|
30
|
+
return { grid: [header, ...rows], own: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function gridFromClipboard(html, text, doc = globalThis.document) {
|
|
34
|
+
let found = null;
|
|
35
|
+
try { found = htmlGrid(html, doc); } catch { found = null; }
|
|
36
|
+
if (!found) {
|
|
37
|
+
try { found = textGrid(text); } catch { found = null; }
|
|
38
|
+
}
|
|
39
|
+
if (!found || !found.grid.length) return null;
|
|
40
|
+
const width = Math.max(...found.grid.map((row) => row.length));
|
|
41
|
+
for (const row of found.grid) while (row.length < width) row.push("");
|
|
42
|
+
return { ...found, width, height: found.grid.length };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function tableFromGrid(grid, { header = grid.length > 1 } = {}) {
|
|
46
|
+
const width = Math.max(0, ...grid.map((row) => row.length));
|
|
47
|
+
if (!width) return null;
|
|
48
|
+
const names = header ? grid[0] : [];
|
|
49
|
+
const body = header ? grid.slice(1) : grid;
|
|
50
|
+
const ids = Array.from({ length: width }, () => newId("c"));
|
|
51
|
+
const colPos = keysBetween(null, null, width);
|
|
52
|
+
const cols = Object.fromEntries(ids.map((id, i) => [id, { pos: colPos[i], name: cellText(names[i] ?? "").replace(/\s+/g, " ").trim() || `Column ${i + 1}` }]));
|
|
53
|
+
const lines = body.length ? body : [[]];
|
|
54
|
+
const rowPos = keysBetween(null, null, lines.length);
|
|
55
|
+
const rows = Object.fromEntries(lines.map((line, r) => [newId("r"), { pos: rowPos[r], cells: Object.fromEntries(ids.map((id, i) => [id, line[i] ?? ""])) }]));
|
|
56
|
+
return nodeFromBlock(schema, newId(), { t: "table", pos: null, cols, rows });
|
|
57
|
+
}
|