@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.
- package/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69385 -40992
- package/dist/server/checkpoint.cjs +1 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +127 -99
- package/entries/clay.js +22 -0
- package/entries/sap.js +5 -5
- package/package.json +16 -2
- package/packed-contract.json +18 -3
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +12 -3
- 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 +154 -36
- package/src/core/snapshot.js +51 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/derived.js +116 -0
- package/src/lib/effect.js +130 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +47 -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 +16 -4
- package/src/loader.js +37 -1
- 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 +1170 -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 +386 -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/derive.js +12 -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 +62 -4
- 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/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +36 -8
- 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/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -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,1170 @@
|
|
|
1
|
+
// Flat lists and toggles for the page editor: the outline a flat document
|
|
2
|
+
// implies, the structure plugin that keeps `parent` and `indent` valid after
|
|
3
|
+
// local edits, the list, toggle and move commands, the collapse plugin and
|
|
4
|
+
// paste. Spec: plans/hyperclay/workspace-positioning/seats/default-page/v1/s3/MERGED-S3.md
|
|
5
|
+
import { model, state, view as pmView, commands, history } from "../vendor/prosemirror.vendor.js";
|
|
6
|
+
import { schema } from "./schema.js";
|
|
7
|
+
import { REMOTE, newId } from "./model.js";
|
|
8
|
+
import { safeHref, safeSrc } from "./link.js";
|
|
9
|
+
|
|
10
|
+
const alignOf = (a) => (a === "left" || a === "right" ? a : null);
|
|
11
|
+
|
|
12
|
+
const { Plugin, PluginKey, TextSelection, NodeSelection, Selection } = state;
|
|
13
|
+
const N = schema.nodes;
|
|
14
|
+
const isList = (n) => n.type === N.list_item;
|
|
15
|
+
const isToggle = (n) => n.type === N.toggle;
|
|
16
|
+
const parentOf = (n) => n.attrs.parent ?? null;
|
|
17
|
+
|
|
18
|
+
// Move, Tab and Shift-Tab are each their own undo step: the transaction closes
|
|
19
|
+
// the history group before it, and carries STEP so the editor closes the group
|
|
20
|
+
// after it too (createPageEditor).
|
|
21
|
+
export const STEP = "page-step";
|
|
22
|
+
export const MOVE = "page-move";
|
|
23
|
+
const asStep = (dispatch) => dispatch && ((tr) => dispatch(history.closeHistory(tr).setMeta(STEP, true)));
|
|
24
|
+
|
|
25
|
+
// ---- the outline ------------------------------------------------------------
|
|
26
|
+
//
|
|
27
|
+
// One entry per top-level block, in document order:
|
|
28
|
+
// at, end the block's start and end position
|
|
29
|
+
// up index of its outline parent, or -1. A list item with indent > 0
|
|
30
|
+
// hangs under the nearest earlier item of its run with a smaller
|
|
31
|
+
// indent; every other block hangs under the toggle named by parent.
|
|
32
|
+
// depth how many toggles it sits inside
|
|
33
|
+
// hidden some toggle above it is closed
|
|
34
|
+
// subEnd index of the last block of its subtree (itself when it has none)
|
|
35
|
+
// A subtree is always contiguous, so it is the range [ents[i].at, ents[subEnd].end].
|
|
36
|
+
export function outline(doc) {
|
|
37
|
+
const ents = [];
|
|
38
|
+
const toggleAt = new Map();
|
|
39
|
+
let run = [];
|
|
40
|
+
doc.forEach((node, at) => {
|
|
41
|
+
const i = ents.length;
|
|
42
|
+
const prev = ents[i - 1];
|
|
43
|
+
const parent = parentOf(node);
|
|
44
|
+
const t = parent !== null && toggleAt.has(parent) ? toggleAt.get(parent) : -1;
|
|
45
|
+
let up = t;
|
|
46
|
+
if (isList(node)) {
|
|
47
|
+
if (!(prev && isList(prev.node) && parentOf(prev.node) === parent)) run = [];
|
|
48
|
+
while (run.length && ents[run[run.length - 1]].node.attrs.indent >= node.attrs.indent) run.pop();
|
|
49
|
+
if (run.length) up = run[run.length - 1];
|
|
50
|
+
run.push(i);
|
|
51
|
+
} else run = [];
|
|
52
|
+
const hidden = t >= 0 && (ents[t].hidden || !ents[t].node.attrs.open);
|
|
53
|
+
ents.push({ node, at, end: at + node.nodeSize, i, up, depth: t >= 0 ? ents[t].depth + 1 : 0, hidden, subEnd: i });
|
|
54
|
+
if (isToggle(node) && node.attrs.id) toggleAt.set(node.attrs.id, i);
|
|
55
|
+
});
|
|
56
|
+
for (let j = ents.length - 1; j >= 0; j--) {
|
|
57
|
+
const u = ents[j].up;
|
|
58
|
+
if (u >= 0 && ents[j].subEnd > ents[u].subEnd) ents[u].subEnd = ents[j].subEnd;
|
|
59
|
+
}
|
|
60
|
+
return ents;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const subtreeRange = (ents, i) => ({ from: ents[i].at, to: ents[ents[i].subEnd].end });
|
|
64
|
+
|
|
65
|
+
// ---- structure: parent and indent stay valid after any local edit -----------
|
|
66
|
+
//
|
|
67
|
+
// Rules, one forward pass:
|
|
68
|
+
// 1. A block's parent is null or a toggle on the chain the previous block
|
|
69
|
+
// opens: the previous block's own toggle ancestors, plus the previous block
|
|
70
|
+
// itself when it is a toggle. An invalid parent climbs its own chain
|
|
71
|
+
// (through the old document for a toggle just deleted) until it is valid.
|
|
72
|
+
// 2. If the next block with a different parent names one deeper on that
|
|
73
|
+
// chain, this block moves down to it, so blocks inserted inside a body
|
|
74
|
+
// (one, or a pasted run of them) join the body instead of cutting it.
|
|
75
|
+
// 3. In a run of list items (contiguous items with one parent) the first has
|
|
76
|
+
// indent 0 and each other at most the previous one's + 1. When an item is
|
|
77
|
+
// clamped, its whole subtree moves by the same amount and its old siblings
|
|
78
|
+
// move with it, so deleting a parent item promotes its children one level.
|
|
79
|
+
// A valid document comes back with no fixes.
|
|
80
|
+
export function structureFixes(doc, oldDoc = null) {
|
|
81
|
+
const declared = new Map();
|
|
82
|
+
oldDoc?.forEach((n) => { if (n.attrs.id) declared.set(n.attrs.id, parentOf(n)); });
|
|
83
|
+
doc.forEach((n) => { if (n.attrs.id) declared.set(n.attrs.id, parentOf(n)); });
|
|
84
|
+
const nodes = [];
|
|
85
|
+
doc.forEach((node, at) => nodes.push({ node, at }));
|
|
86
|
+
const ahead = new Array(nodes.length).fill(null);
|
|
87
|
+
for (let i = nodes.length - 2; i >= 0; i--) {
|
|
88
|
+
const next = parentOf(nodes[i + 1].node);
|
|
89
|
+
ahead[i] = next !== parentOf(nodes[i].node) ? next : ahead[i + 1];
|
|
90
|
+
}
|
|
91
|
+
const fixes = [];
|
|
92
|
+
let allowed = [null];
|
|
93
|
+
let prev = null;
|
|
94
|
+
let stack = [];
|
|
95
|
+
nodes.forEach(({ node, at }, i) => {
|
|
96
|
+
let p = parentOf(node);
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
while (p !== null && !allowed.includes(p) && !seen.has(p)) { seen.add(p); p = declared.has(p) ? declared.get(p) : null; }
|
|
99
|
+
if (!allowed.includes(p)) p = null;
|
|
100
|
+
const q = ahead[i];
|
|
101
|
+
if (allowed.indexOf(q) > allowed.indexOf(p)) p = q;
|
|
102
|
+
const chain = allowed.slice(0, allowed.indexOf(p) + 1);
|
|
103
|
+
|
|
104
|
+
let indent = node.attrs.indent;
|
|
105
|
+
if (isList(node)) {
|
|
106
|
+
const d = Number.isInteger(indent) && indent > 0 ? indent : 0;
|
|
107
|
+
const continues = prev && isList(prev.node) && prev.parent === p;
|
|
108
|
+
if (!continues) stack = [];
|
|
109
|
+
while (stack.length && stack[stack.length - 1].orig > d) stack.pop();
|
|
110
|
+
let shift = 0;
|
|
111
|
+
if (stack.length) {
|
|
112
|
+
const top = stack[stack.length - 1];
|
|
113
|
+
shift = top.shift;
|
|
114
|
+
if (top.orig === d) stack.pop();
|
|
115
|
+
}
|
|
116
|
+
indent = Math.max(0, Math.min(d - shift, continues ? prev.indent + 1 : 0));
|
|
117
|
+
stack.push({ orig: d, shift: d - indent });
|
|
118
|
+
} else stack = [];
|
|
119
|
+
|
|
120
|
+
if (p !== parentOf(node) || (isList(node) && indent !== node.attrs.indent)) {
|
|
121
|
+
fixes.push({ at, attrs: { ...node.attrs, parent: p, ...(isList(node) ? { indent } : {}) } });
|
|
122
|
+
}
|
|
123
|
+
prev = { node, parent: p, indent };
|
|
124
|
+
allowed = isToggle(node) && node.attrs.id ? [...chain, node.attrs.id] : chain;
|
|
125
|
+
});
|
|
126
|
+
return fixes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function structurePlugin() {
|
|
130
|
+
return new Plugin({
|
|
131
|
+
appendTransaction(trs, old, next) {
|
|
132
|
+
if (!trs.some((tr) => tr.docChanged && !tr.getMeta(REMOTE))) return null;
|
|
133
|
+
const fixes = structureFixes(next.doc, old.doc);
|
|
134
|
+
if (!fixes.length) return null;
|
|
135
|
+
const tr = next.tr;
|
|
136
|
+
for (const f of fixes) tr.setNodeMarkup(f.at, null, f.attrs);
|
|
137
|
+
return tr;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- spans ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
// The top-level indices the selection touches.
|
|
145
|
+
function touched(doc, sel, ents) {
|
|
146
|
+
const a = doc.resolve(sel.from).index(0);
|
|
147
|
+
let b = Math.min(doc.resolve(sel.to).index(0), ents.length - 1);
|
|
148
|
+
if (b > a && sel.to <= ents[b].at) b--;
|
|
149
|
+
return [Math.min(a, ents.length - 1), b];
|
|
150
|
+
}
|
|
151
|
+
export const touchedBlocks = touched;
|
|
152
|
+
|
|
153
|
+
// The selection lifted to whole sibling subtrees: [lo, hi] are block indices,
|
|
154
|
+
// every root in it shares the outline parent `up`.
|
|
155
|
+
export function siblingSpan(ents, a, b) {
|
|
156
|
+
let lo = a;
|
|
157
|
+
while (ents[lo].up >= 0 && b > ents[ents[lo].up].subEnd) lo = ents[lo].up;
|
|
158
|
+
const up = ents[lo].up;
|
|
159
|
+
let hi = ents[lo].subEnd;
|
|
160
|
+
while (hi < b) hi = ents[hi + 1].subEnd;
|
|
161
|
+
return { lo, hi, up };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- block selection --------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
// A selection of whole top-level blocks. anchor and head are block boundaries
|
|
167
|
+
// (depth-0 positions), so from..to covers whole blocks; the commands lift it to
|
|
168
|
+
// whole sibling subtrees (siblingSpan), so a selected toggle carries its body.
|
|
169
|
+
// It is invisible to ProseMirror (the DOM selection is hidden) and drawn with
|
|
170
|
+
// node decorations by blocksel.js.
|
|
171
|
+
const boundary = (doc, pos, side) => {
|
|
172
|
+
const $p = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
|
|
173
|
+
if ($p.depth === 0) return $p.pos;
|
|
174
|
+
return side < 0 ? $p.before(1) : $p.after(1);
|
|
175
|
+
};
|
|
176
|
+
const blockRef = (e) => ({ id: e.node.attrs.id, at: e.at, node: e.node });
|
|
177
|
+
const matchesRef = (e, ref) => ref.id ? e.node.attrs.id === ref.id : e.at === ref.at && e.node.type === ref.node.type && e.node.content.eq(ref.node.content);
|
|
178
|
+
|
|
179
|
+
export function captureSpan(ents, lo, hi) {
|
|
180
|
+
const roots = [];
|
|
181
|
+
for (let i = lo; i <= hi; i = ents[i].subEnd + 1) roots.push(blockRef(ents[i]));
|
|
182
|
+
return { roots, members: ents.slice(lo, hi + 1).map(blockRef) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function resolveSpan(ents, span) {
|
|
186
|
+
const roots = span.roots.map((ref) => ents.findIndex((e) => matchesRef(e, ref))).filter((i) => i >= 0);
|
|
187
|
+
if (!roots.length || roots.some((i) => ents[i].hidden)) return null;
|
|
188
|
+
const lo = roots[0], up = ents[lo].up;
|
|
189
|
+
let hi = ents[lo].subEnd;
|
|
190
|
+
for (const i of roots.slice(1)) {
|
|
191
|
+
if (i !== hi + 1 || ents[i].up !== up) return null;
|
|
192
|
+
hi = ents[i].subEnd;
|
|
193
|
+
}
|
|
194
|
+
if (ents.slice(lo, hi + 1).some((e) => !span.members.some((ref) => matchesRef(e, ref)))) return null;
|
|
195
|
+
return { lo, hi };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const mapSpan = (span, mapping) => {
|
|
199
|
+
const map = (ref) => ({ ...ref, at: mapping.map(ref.at, 1) });
|
|
200
|
+
return { roots: span.roots.map(map), members: span.members.map(map) };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export class BlockSelection extends Selection {
|
|
204
|
+
map(doc, mapping) {
|
|
205
|
+
return this.getBookmark().map(mapping).resolve(doc);
|
|
206
|
+
}
|
|
207
|
+
eq(other) {
|
|
208
|
+
return other instanceof BlockSelection && other.anchor === this.anchor && other.head === this.head;
|
|
209
|
+
}
|
|
210
|
+
toJSON() {
|
|
211
|
+
return { type: "page-block", anchor: this.anchor, head: this.head };
|
|
212
|
+
}
|
|
213
|
+
static fromJSON(doc, json) {
|
|
214
|
+
return new BlockSelection(doc.resolve(json.anchor), doc.resolve(json.head));
|
|
215
|
+
}
|
|
216
|
+
getBookmark() {
|
|
217
|
+
const { lo, hi } = this.span();
|
|
218
|
+
return new BlockBookmark(this.anchor, this.head, captureSpan(outline(this.$from.doc), lo, hi));
|
|
219
|
+
}
|
|
220
|
+
span(doc = this.$from.doc) {
|
|
221
|
+
return { lo: doc.resolve(this.from).index(0), hi: doc.resolve(this.to).index(0) - 1 };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
BlockSelection.prototype.visible = false;
|
|
225
|
+
Selection.jsonID("page-block", BlockSelection);
|
|
226
|
+
|
|
227
|
+
class BlockBookmark {
|
|
228
|
+
constructor(anchor, head, span) { this.anchor = anchor; this.head = head; this.span = span; }
|
|
229
|
+
map(mapping) {
|
|
230
|
+
const fwd = this.anchor <= this.head;
|
|
231
|
+
return new BlockBookmark(mapping.map(this.anchor, fwd ? 1 : -1), mapping.map(this.head, fwd ? -1 : 1), mapSpan(this.span, mapping));
|
|
232
|
+
}
|
|
233
|
+
resolve(doc) {
|
|
234
|
+
const ents = outline(doc), span = resolveSpan(ents, this.span);
|
|
235
|
+
if (!span) {
|
|
236
|
+
const first = ents.find((e) => matchesRef(e, this.span.roots[0]));
|
|
237
|
+
return Selection.near(doc.resolve(first?.at ?? boundary(doc, Math.min(this.anchor, this.head), -1)));
|
|
238
|
+
}
|
|
239
|
+
const from = ents[span.lo].at, to = ents[span.hi].end;
|
|
240
|
+
return this.anchor <= this.head ? new BlockSelection(doc.resolve(from), doc.resolve(to)) : new BlockSelection(doc.resolve(to), doc.resolve(from));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const prevSibling = (ents, i) => {
|
|
245
|
+
for (let j = i - 1; j >= 0; j--) if (ents[j].up === ents[i].up) return j;
|
|
246
|
+
return -1;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Move blocks lo..hi so they start at `target` (a block boundary of the
|
|
250
|
+
// current doc, outside the span). Moved nodes get pos null: identityPlugin
|
|
251
|
+
// then gives exactly those blocks new keys between their new neighbours and
|
|
252
|
+
// leaves every other key alone. `attrs(ent)` returns attr changes per block.
|
|
253
|
+
export function moveIntent(ents, lo, hi, target, attrs = () => null) {
|
|
254
|
+
const root = attrs(ents[lo]) || {};
|
|
255
|
+
const parent = "parent" in root ? root.parent : parentOf(ents[lo].node);
|
|
256
|
+
let afterId = null;
|
|
257
|
+
for (let k = 0; k < ents.length && ents[k].end <= target; k++) {
|
|
258
|
+
if (k >= lo && k <= hi) continue;
|
|
259
|
+
if (parentOf(ents[k].node) === parent) afterId = ents[k].node.attrs.id;
|
|
260
|
+
}
|
|
261
|
+
const ids = [], changes = {};
|
|
262
|
+
for (let k = lo; k <= hi; k++) {
|
|
263
|
+
const n = ents[k].node, c = attrs(ents[k]);
|
|
264
|
+
ids.push(n.attrs.id);
|
|
265
|
+
if (c && "indent" in c && c.indent !== n.attrs.indent) changes[n.attrs.id] = { indent: c.indent };
|
|
266
|
+
}
|
|
267
|
+
return { ids, afterId, parent, attrs: changes };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function moveSpanTr(st, ents, lo, hi, target, attrs = () => null, sel = st.selection) {
|
|
271
|
+
const from = ents[lo].at, to = ents[hi].end, size = to - from;
|
|
272
|
+
const nodes = [];
|
|
273
|
+
for (let k = lo; k <= hi; k++) {
|
|
274
|
+
const n = ents[k].node;
|
|
275
|
+
nodes.push(n.type.create({ ...n.attrs, ...(attrs(ents[k]) || {}), pos: null }, n.content, n.marks));
|
|
276
|
+
}
|
|
277
|
+
const tr = st.tr.delete(from, to);
|
|
278
|
+
const at = target > from ? target - size : target;
|
|
279
|
+
tr.insert(at, nodes);
|
|
280
|
+
const shift = at - from;
|
|
281
|
+
if (sel instanceof BlockSelection) tr.setSelection(new BlockSelection(tr.doc.resolve(sel.anchor + shift), tr.doc.resolve(sel.head + shift)));
|
|
282
|
+
else if (sel instanceof NodeSelection) tr.setSelection(NodeSelection.create(tr.doc, sel.from + shift));
|
|
283
|
+
else tr.setSelection(TextSelection.create(tr.doc, sel.anchor + shift, sel.head + shift));
|
|
284
|
+
return tr.setMeta(MOVE, moveIntent(ents, lo, hi, target, attrs));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Attr changes that carry a span out of its outline parent P to P's level.
|
|
288
|
+
function leaveParent(ents, lo, up) {
|
|
289
|
+
const p = ents[up].node;
|
|
290
|
+
if (isToggle(p)) {
|
|
291
|
+
const from = p.attrs.id, to = parentOf(p);
|
|
292
|
+
return (e) => (parentOf(e.node) === from ? { parent: to } : null);
|
|
293
|
+
}
|
|
294
|
+
const delta = p.attrs.indent - ents[lo].node.attrs.indent;
|
|
295
|
+
return (e) => (isList(e.node) ? { indent: e.node.attrs.indent + delta } : null);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- move a block with its subtree ------------------------------------------
|
|
299
|
+
//
|
|
300
|
+
// dir -1 / +1. The unit is the selection lifted to whole sibling subtrees. It
|
|
301
|
+
// swaps with the previous / next sibling subtree; at the edge of its parent it
|
|
302
|
+
// steps out and lands just above / below the parent, at the parent's level.
|
|
303
|
+
export const moveBlock = (dir) => (st, dispatch) => {
|
|
304
|
+
dispatch = asStep(dispatch);
|
|
305
|
+
const ents = outline(st.doc);
|
|
306
|
+
if (!ents.length) return false;
|
|
307
|
+
const [a, b] = touched(st.doc, st.selection, ents);
|
|
308
|
+
const { lo, hi, up } = siblingSpan(ents, a, b);
|
|
309
|
+
let target, attrs = () => null;
|
|
310
|
+
if (dir < 0) {
|
|
311
|
+
const s = prevSibling(ents, lo);
|
|
312
|
+
if (s >= 0) target = ents[s].at;
|
|
313
|
+
else if (up >= 0) { target = ents[up].at; attrs = leaveParent(ents, lo, up); }
|
|
314
|
+
else return true;
|
|
315
|
+
} else {
|
|
316
|
+
const next = hi + 1;
|
|
317
|
+
if (next < ents.length && ents[next].up === up) target = ents[ents[next].subEnd].end;
|
|
318
|
+
else if (up >= 0) { target = ents[ents[up].subEnd].end; attrs = leaveParent(ents, lo, up); }
|
|
319
|
+
else return true;
|
|
320
|
+
}
|
|
321
|
+
if (dispatch) dispatch(moveSpanTr(st, ents, lo, hi, target, attrs).scrollIntoView());
|
|
322
|
+
return true;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// ---- Tab / Shift-Tab --------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
export const MAX_INDENT = 8;
|
|
328
|
+
const sameRun = (x, y) => isList(x.node) && isList(y.node) && parentOf(x.node) === parentOf(y.node);
|
|
329
|
+
|
|
330
|
+
export function indentBlocks(st, dispatch) {
|
|
331
|
+
dispatch = asStep(dispatch);
|
|
332
|
+
const ents = outline(st.doc);
|
|
333
|
+
if (!ents.length) return false;
|
|
334
|
+
const [a, b] = touched(st.doc, st.selection, ents);
|
|
335
|
+
const first = ents[a];
|
|
336
|
+
if (isList(first.node) && a > 0 && sameRun(ents[a - 1], first)) {
|
|
337
|
+
if (first.node.attrs.indent > ents[a - 1].node.attrs.indent) return true;
|
|
338
|
+
for (let k = a; k <= Math.max(b, first.subEnd); k++) if (ents[k].node.attrs.indent >= MAX_INDENT) return true;
|
|
339
|
+
if (dispatch) {
|
|
340
|
+
const tr = st.tr;
|
|
341
|
+
let end = b;
|
|
342
|
+
for (let k = a; k <= end; k++) end = Math.max(end, ents[k].subEnd);
|
|
343
|
+
for (let k = a; k <= end && sameRun(ents[k], first); k++) {
|
|
344
|
+
tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, indent: ents[k].node.attrs.indent + 1 });
|
|
345
|
+
}
|
|
346
|
+
dispatch(tr);
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
const s = prevSibling(ents, a);
|
|
351
|
+
if (s < 0 || !isToggle(ents[s].node) || ents[s].subEnd !== a - 1) return true;
|
|
352
|
+
if (dispatch) {
|
|
353
|
+
const { hi } = siblingSpan(ents, a, b);
|
|
354
|
+
const tr = st.tr, from = parentOf(first.node), to = ents[s].node.attrs.id;
|
|
355
|
+
for (let k = a; k <= hi; k++) {
|
|
356
|
+
if (parentOf(ents[k].node) === from) tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, parent: to });
|
|
357
|
+
}
|
|
358
|
+
if (!ents[s].node.attrs.open) tr.setNodeMarkup(ents[s].at, null, { ...ents[s].node.attrs, open: true });
|
|
359
|
+
dispatch(tr);
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function outdentBlocks(st, dispatch) {
|
|
365
|
+
dispatch = asStep(dispatch);
|
|
366
|
+
const ents = outline(st.doc);
|
|
367
|
+
if (!ents.length) return false;
|
|
368
|
+
const [a, b] = touched(st.doc, st.selection, ents);
|
|
369
|
+
const first = ents[a];
|
|
370
|
+
if (isList(first.node) && first.node.attrs.indent > 0) {
|
|
371
|
+
if (dispatch) {
|
|
372
|
+
const tr = st.tr;
|
|
373
|
+
for (let k = a; k <= b;) {
|
|
374
|
+
const e = ents[k];
|
|
375
|
+
if (isList(e.node) && e.node.attrs.indent > 0) {
|
|
376
|
+
for (let m = k; m <= e.subEnd; m++) {
|
|
377
|
+
if (isList(ents[m].node)) tr.setNodeMarkup(ents[m].at, null, { ...ents[m].node.attrs, indent: ents[m].node.attrs.indent - 1 });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
k = e.subEnd + 1;
|
|
381
|
+
}
|
|
382
|
+
dispatch(tr);
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const t = parentOf(first.node);
|
|
387
|
+
if (t === null) return true;
|
|
388
|
+
const { lo, hi } = siblingSpan(ents, a, b);
|
|
389
|
+
let up = ents[lo].up;
|
|
390
|
+
while (up >= 0 && !isToggle(ents[up].node)) up = ents[up].up;
|
|
391
|
+
if (up < 0) return true;
|
|
392
|
+
if (dispatch) {
|
|
393
|
+
const attrs = leaveParent(ents, lo, up);
|
|
394
|
+
const end = ents[up].subEnd;
|
|
395
|
+
if (hi === end) {
|
|
396
|
+
const tr = st.tr;
|
|
397
|
+
for (let k = lo; k <= hi; k++) { const c = attrs(ents[k]); if (c) tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, ...c }); }
|
|
398
|
+
dispatch(tr);
|
|
399
|
+
} else dispatch(moveSpanTr(st, ents, lo, hi, ents[end].end, attrs).scrollIntoView());
|
|
400
|
+
}
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- Enter ------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
function deleteFirst(st) {
|
|
407
|
+
const tr = st.tr;
|
|
408
|
+
if (!st.selection.empty) tr.deleteSelection();
|
|
409
|
+
return tr;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function enterListItem(st, dispatch) {
|
|
413
|
+
const { $from, $to, empty } = st.selection;
|
|
414
|
+
if ($from.depth !== 1 || !isList($from.parent) || !$from.sameParent($to)) return false;
|
|
415
|
+
if (empty && $from.parent.content.size === 0) {
|
|
416
|
+
const n = $from.parent;
|
|
417
|
+
if (n.attrs.indent > 0) return outdentBlocks(st, dispatch);
|
|
418
|
+
if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
const tr = deleteFirst(st);
|
|
422
|
+
const $pos = tr.selection.$from;
|
|
423
|
+
const node = $pos.parent;
|
|
424
|
+
const ents = outline(tr.doc);
|
|
425
|
+
const i = $pos.index(0);
|
|
426
|
+
const atStart = $pos.parentOffset === 0;
|
|
427
|
+
const atEnd = $pos.parentOffset === node.content.size;
|
|
428
|
+
const next = ents[i + 1];
|
|
429
|
+
const deeper = atEnd && next && sameRun(next, ents[i]) && next.node.attrs.indent > node.attrs.indent;
|
|
430
|
+
if (atStart) {
|
|
431
|
+
tr.split($pos.pos, 1, [{ type: N.list_item, attrs: node.attrs }]);
|
|
432
|
+
tr.setNodeMarkup(ents[i].at, null, { ...node.attrs, checked: false });
|
|
433
|
+
} else {
|
|
434
|
+
tr.split($pos.pos, 1, [{ type: N.list_item, attrs: { ...node.attrs, checked: false, indent: node.attrs.indent + (deeper ? 1 : 0) } }]);
|
|
435
|
+
}
|
|
436
|
+
if (dispatch) dispatch(tr.scrollIntoView());
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function enterToggle(st, dispatch) {
|
|
441
|
+
const { $from, $to } = st.selection;
|
|
442
|
+
if ($from.depth !== 1 || !isToggle($from.parent) || !$from.sameParent($to)) return false;
|
|
443
|
+
const tr = deleteFirst(st);
|
|
444
|
+
const $pos = tr.selection.$from;
|
|
445
|
+
const node = $pos.parent;
|
|
446
|
+
const ents = outline(tr.doc);
|
|
447
|
+
const i = $pos.index(0), e = ents[i];
|
|
448
|
+
const hasBody = e.subEnd > i;
|
|
449
|
+
if (node.content.size === 0 && !hasBody) {
|
|
450
|
+
tr.setNodeMarkup(e.at, N.paragraph, { id: node.attrs.id, pos: node.attrs.pos, parent: node.attrs.parent });
|
|
451
|
+
if (dispatch) dispatch(tr);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if ($pos.parentOffset === 0 && node.content.size > 0) {
|
|
455
|
+
tr.insert(e.at, N.paragraph.create({ parent: parentOf(node) }));
|
|
456
|
+
if (dispatch) dispatch(tr.scrollIntoView());
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
const rest = node.content.cut($pos.parentOffset);
|
|
460
|
+
if (node.attrs.open) {
|
|
461
|
+
tr.split($pos.pos, 1, [{ type: N.paragraph, attrs: { parent: node.attrs.id } }]);
|
|
462
|
+
} else {
|
|
463
|
+
const end = ents[e.subEnd].end;
|
|
464
|
+
const before = tr.steps.length;
|
|
465
|
+
tr.delete($pos.pos, e.end - 1);
|
|
466
|
+
const at = tr.mapping.slice(before).map(end);
|
|
467
|
+
tr.insert(at, N.toggle.create({ parent: parentOf(node), open: false }, rest));
|
|
468
|
+
tr.setSelection(TextSelection.create(tr.doc, at + 1));
|
|
469
|
+
}
|
|
470
|
+
if (dispatch) dispatch(tr.scrollIntoView());
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Enter in an empty paragraph that ends a toggle's body steps out of the toggle.
|
|
475
|
+
export function exitToggleOnEmpty(st, dispatch) {
|
|
476
|
+
const { $from, empty } = st.selection;
|
|
477
|
+
if (!empty || $from.depth !== 1 || $from.parent.type !== N.paragraph || $from.parent.content.size) return false;
|
|
478
|
+
const t = parentOf($from.parent);
|
|
479
|
+
if (t === null) return false;
|
|
480
|
+
const ents = outline(st.doc);
|
|
481
|
+
const i = $from.index(0);
|
|
482
|
+
const ti = ents.findIndex((x) => x.node.attrs.id === t);
|
|
483
|
+
if (ti < 0 || ents[ti].subEnd !== i) return false;
|
|
484
|
+
if (dispatch) dispatch(st.tr.setNodeMarkup(ents[i].at, null, { ...$from.parent.attrs, parent: parentOf(ents[ti].node) }));
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Enter at the end of a paragraph or heading inside a toggle body: the new
|
|
489
|
+
// paragraph stays in the same body (the default split would make a root block).
|
|
490
|
+
export function enterBodyBlock(st, dispatch) {
|
|
491
|
+
const { $from, empty } = st.selection;
|
|
492
|
+
if (!empty || $from.depth !== 1 || !$from.parent.isTextblock || $from.parent.type.spec.code) return false;
|
|
493
|
+
const n = $from.parent;
|
|
494
|
+
if (isList(n) || isToggle(n) || parentOf(n) === null || $from.parentOffset !== n.content.size) return false;
|
|
495
|
+
if (dispatch) {
|
|
496
|
+
const tr = st.tr.split($from.pos, 1, [{ type: N.paragraph, attrs: { parent: parentOf(n) } }]);
|
|
497
|
+
dispatch(tr.scrollIntoView());
|
|
498
|
+
}
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---- Backspace / Delete -----------------------------------------------------
|
|
503
|
+
|
|
504
|
+
export function backspaceListItem(st, dispatch) {
|
|
505
|
+
const { $from, empty } = st.selection;
|
|
506
|
+
if (!empty || $from.depth !== 1 || !isList($from.parent) || $from.parentOffset !== 0) return false;
|
|
507
|
+
if ($from.parent.attrs.indent > 0) return outdentBlocks(st, dispatch);
|
|
508
|
+
const n = $from.parent;
|
|
509
|
+
if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function backspaceToggle(st, dispatch) {
|
|
514
|
+
const { $from, empty } = st.selection;
|
|
515
|
+
if (!empty || $from.depth !== 1 || !isToggle($from.parent) || $from.parentOffset !== 0) return false;
|
|
516
|
+
const n = $from.parent;
|
|
517
|
+
if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The collapsed toggle whose hidden body ends right before block i, if any.
|
|
522
|
+
function collapsedBefore(ents, i) {
|
|
523
|
+
if (i <= 0 || !ents[i - 1].hidden) return -1;
|
|
524
|
+
let j = i - 1;
|
|
525
|
+
while (j >= 0 && ents[j].hidden) j--;
|
|
526
|
+
return j >= 0 && isToggle(ents[j].node) && ents[j].subEnd >= i - 1 ? j : -1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Join a textblock's content onto the end of a summary line, leaving the body.
|
|
530
|
+
function joinIntoSummary(st, ents, ti, bi) {
|
|
531
|
+
const tr = st.tr, b = ents[bi], sumEnd = ents[ti].end - 1;
|
|
532
|
+
tr.delete(b.at, b.end);
|
|
533
|
+
if (b.node.content.size) tr.insert(sumEnd, b.node.content);
|
|
534
|
+
return tr.setSelection(TextSelection.create(tr.doc, sumEnd));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function backspaceAfterCollapsed(st, dispatch) {
|
|
538
|
+
const { $from, empty } = st.selection;
|
|
539
|
+
if (!empty || $from.depth !== 1 || $from.parentOffset !== 0 || !$from.parent.isTextblock) return false;
|
|
540
|
+
const ents = outline(st.doc);
|
|
541
|
+
const i = $from.index(0), ti = collapsedBefore(ents, i);
|
|
542
|
+
if (ti < 0) return false;
|
|
543
|
+
if (dispatch) dispatch(joinIntoSummary(st, ents, ti, i).scrollIntoView());
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function deleteAtCollapsedEnd(st, dispatch) {
|
|
548
|
+
const { $from, empty } = st.selection;
|
|
549
|
+
if (!empty || $from.depth !== 1 || !isToggle($from.parent) || $from.parent.attrs.open) return false;
|
|
550
|
+
if ($from.parentOffset !== $from.parent.content.size) return false;
|
|
551
|
+
const ents = outline(st.doc);
|
|
552
|
+
const ti = $from.index(0), ni = ents[ti].subEnd + 1;
|
|
553
|
+
if (ni === ti + 1) return false;
|
|
554
|
+
if (ni >= ents.length || !ents[ni].node.isTextblock) return true;
|
|
555
|
+
if (dispatch) dispatch(joinIntoSummary(st, ents, ti, ni).scrollIntoView());
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Delete at the end of a line just above a toggle. A plain paragraph's text
|
|
560
|
+
// moves onto the front of the summary, so the toggle keeps its body and open
|
|
561
|
+
// state; the default join would turn the toggle into a paragraph and release
|
|
562
|
+
// its body. Any other line (heading, list item, callout) would lose its format
|
|
563
|
+
// in that merge, so the caret steps into the summary instead.
|
|
564
|
+
export function deleteBeforeToggle(st, dispatch) {
|
|
565
|
+
const { $from, empty } = st.selection;
|
|
566
|
+
if (!empty || $from.depth !== 1) return false;
|
|
567
|
+
const n = $from.parent, i = $from.index(0);
|
|
568
|
+
if (!n.isTextblock || n.type.spec.code || isToggle(n) || !n.content.size || $from.parentOffset !== n.content.size) return false;
|
|
569
|
+
if (i + 1 >= st.doc.childCount || !isToggle(st.doc.child(i + 1))) return false;
|
|
570
|
+
if (dispatch && n.type !== N.paragraph) dispatch(st.tr.setSelection(TextSelection.create(st.doc, $from.after() + 1)).scrollIntoView());
|
|
571
|
+
else if (dispatch) {
|
|
572
|
+
const at = $from.before();
|
|
573
|
+
const tr = st.tr.insert($from.after() + 1, n.content).delete(at, $from.after());
|
|
574
|
+
dispatch(tr.setSelection(TextSelection.create(tr.doc, at + 1 + n.content.size)).scrollIntoView());
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Backspace and Delete at the edge of a code block never merge text into or
|
|
580
|
+
// out of it. An empty line on the other side is removed; otherwise the caret
|
|
581
|
+
// steps across to the neighbouring line.
|
|
582
|
+
const codeEdge = (dir) => (st, dispatch) => {
|
|
583
|
+
const { $from, empty } = st.selection;
|
|
584
|
+
if (!empty || $from.depth !== 1 || !$from.parent.isTextblock) return false;
|
|
585
|
+
const n = $from.parent, i = $from.index(0);
|
|
586
|
+
if ($from.parentOffset !== (dir < 0 ? 0 : n.content.size)) return false;
|
|
587
|
+
const ents = outline(st.doc);
|
|
588
|
+
const ti = dir < 0 ? collapsedBefore(ents, i) : -1;
|
|
589
|
+
const j = ti >= 0 ? ti : i + dir;
|
|
590
|
+
if (j < 0 || j >= ents.length) return false;
|
|
591
|
+
const o = ents[j].node;
|
|
592
|
+
if (!o.isTextblock || (!n.type.spec.code && !o.type.spec.code)) return false;
|
|
593
|
+
if (!dispatch) return true;
|
|
594
|
+
const tr = st.tr, mine = ents[i], other = ents[j];
|
|
595
|
+
const target = dir < 0 ? other.end - 1 : other.at + 1;
|
|
596
|
+
if (!n.content.size && !n.type.spec.code) tr.delete(mine.at, mine.end).setSelection(TextSelection.create(tr.doc, tr.mapping.map(target)));
|
|
597
|
+
else if (!o.content.size && !o.type.spec.code) tr.delete(other.at, other.end);
|
|
598
|
+
else tr.setSelection(TextSelection.create(tr.doc, target));
|
|
599
|
+
dispatch(tr.scrollIntoView());
|
|
600
|
+
return true;
|
|
601
|
+
};
|
|
602
|
+
export const backspaceCodeEdge = codeEdge(-1);
|
|
603
|
+
export const deleteCodeEdge = codeEdge(1);
|
|
604
|
+
|
|
605
|
+
// Backspace at the start of the first paragraph, heading or code block of a
|
|
606
|
+
// toggle body joins it onto the summary line, which the default join cannot
|
|
607
|
+
// do. Later body blocks are left to the default join with the line above.
|
|
608
|
+
export function backspaceBodyBlock(st, dispatch) {
|
|
609
|
+
const { $from, empty } = st.selection;
|
|
610
|
+
if (!empty || $from.depth !== 1 || $from.parentOffset !== 0 || !$from.parent.isTextblock) return false;
|
|
611
|
+
const n = $from.parent;
|
|
612
|
+
if (isList(n) || isToggle(n) || parentOf(n) === null) return false;
|
|
613
|
+
const i = $from.index(0);
|
|
614
|
+
if (st.doc.child(i - 1).attrs.id !== parentOf(n)) return false;
|
|
615
|
+
if (dispatch) dispatch(joinIntoSummary(st, outline(st.doc), i - 1, i).scrollIntoView());
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---- open / closed ----------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
export function setOpen(st, ti, open, ents = outline(st.doc)) {
|
|
622
|
+
const e = ents[ti];
|
|
623
|
+
return st.tr.setNodeMarkup(e.at, null, { ...e.node.attrs, open }).setMeta("addToHistory", false);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function toggleOpenAtSelection(st, dispatch) {
|
|
627
|
+
const { $from } = st.selection;
|
|
628
|
+
if ($from.depth !== 1 || !isToggle($from.parent)) return false;
|
|
629
|
+
if (dispatch) dispatch(setOpen(st, $from.index(0), !$from.parent.attrs.open));
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---- hidden ranges and the selection guard ----------------------------------
|
|
634
|
+
|
|
635
|
+
// [from, to] of every hidden body, outermost only: positions strictly inside
|
|
636
|
+
// one are inside hidden blocks. Each carries the toggle's entry.
|
|
637
|
+
export function hiddenRanges(ents) {
|
|
638
|
+
const out = [];
|
|
639
|
+
for (const e of ents) {
|
|
640
|
+
if (!isToggle(e.node) || e.hidden || e.node.attrs.open || e.subEnd === e.i) continue;
|
|
641
|
+
out.push({ line: e.at + 1, sumEnd: e.end - 1, from: e.end, to: ents[e.subEnd].end });
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const inside = (r, p) => p > r.from && p < r.to;
|
|
647
|
+
|
|
648
|
+
// Where a selection endpoint inside a hidden range goes: forward to the first
|
|
649
|
+
// visible text after it, backward to the end of the summary line.
|
|
650
|
+
function snap(doc, ranges, r, dir) {
|
|
651
|
+
if (dir > 0) {
|
|
652
|
+
let p = r.to;
|
|
653
|
+
for (;;) {
|
|
654
|
+
const found = Selection.findFrom(doc.resolve(p), 1, true);
|
|
655
|
+
if (!found) break;
|
|
656
|
+
const again = ranges.find((x) => inside(x, found.head));
|
|
657
|
+
if (!again) return found.head;
|
|
658
|
+
p = again.to;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return r.sumEnd;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// The selection with no endpoint inside a hidden body, and with a range that
|
|
665
|
+
// crosses a hidden body widened to start at that toggle's line, unless it
|
|
666
|
+
// starts exactly at the summary's end: then it starts after the body instead,
|
|
667
|
+
// so the toggle is not selected. Returns null when it is already fine.
|
|
668
|
+
// `dragging` defers the widening to mouseup.
|
|
669
|
+
export function guardSelection(doc, sel, ranges, dir = -1, dragging = false) {
|
|
670
|
+
if (!ranges.length) return null;
|
|
671
|
+
let { anchor, head } = sel;
|
|
672
|
+
if (!(sel instanceof TextSelection)) {
|
|
673
|
+
const r = ranges.find((x) => sel.from >= x.from && sel.from < x.to);
|
|
674
|
+
return r ? TextSelection.create(doc, r.sumEnd) : null;
|
|
675
|
+
}
|
|
676
|
+
const hr = ranges.find((r) => inside(r, head));
|
|
677
|
+
if (hr) head = snap(doc, ranges, hr, dir);
|
|
678
|
+
const ar = ranges.find((r) => inside(r, anchor));
|
|
679
|
+
if (ar) anchor = ar.sumEnd;
|
|
680
|
+
if (!dragging && anchor !== head) {
|
|
681
|
+
const from = Math.min(anchor, head), to = Math.max(anchor, head);
|
|
682
|
+
const r = ranges.find((x) => (from > x.line || from === x.sumEnd) && from < x.from && to >= x.to);
|
|
683
|
+
if (r) {
|
|
684
|
+
const lo = from === r.sumEnd ? snap(doc, ranges, r, 1) : r.line;
|
|
685
|
+
if (anchor < head) anchor = lo; else head = lo;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (anchor === sel.anchor && head === sel.head) return null;
|
|
689
|
+
return TextSelection.create(doc, anchor, head);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---- ordered numbering ------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
const ALPHA = (n) => { let s = ""; while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); } return s; };
|
|
695
|
+
const ROMAN = (n) => [[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"], [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"]].reduce((s, [v, r]) => { while (n >= v) { s += r; n -= v; } return s; }, "");
|
|
696
|
+
|
|
697
|
+
// Marker text for every ordered item, by entry index. A run restarts numbering;
|
|
698
|
+
// within a run each indent level counts its own ordered items, a deeper item
|
|
699
|
+
// never interrupts a shallower count, and any non-ordered item at a level
|
|
700
|
+
// restarts that level.
|
|
701
|
+
export function orderedMarkers(ents) {
|
|
702
|
+
const out = new Map();
|
|
703
|
+
let counts = [];
|
|
704
|
+
ents.forEach((e, i) => {
|
|
705
|
+
if (!isList(e.node)) { counts = []; return; }
|
|
706
|
+
if (i > 0 && !sameRun(ents[i - 1], e)) counts = [];
|
|
707
|
+
const d = e.node.attrs.indent;
|
|
708
|
+
counts.length = d + 1;
|
|
709
|
+
if (e.node.attrs.kind !== "ordered") { counts[d] = 0; return; }
|
|
710
|
+
counts[d] = (counts[d] || 0) + 1;
|
|
711
|
+
const n = counts[d];
|
|
712
|
+
out.set(i, `${d % 3 === 0 ? n : d % 3 === 1 ? ALPHA(n) : ROMAN(n)}.`);
|
|
713
|
+
});
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ---- the collapse plugin ----------------------------------------------------
|
|
718
|
+
|
|
719
|
+
export const collapseKey = new PluginKey("page-collapse");
|
|
720
|
+
|
|
721
|
+
const STRUCTURAL = ["id", "parent", "indent", "kind", "open"];
|
|
722
|
+
|
|
723
|
+
// True when no block was added, removed or retyped and no attr the outline
|
|
724
|
+
// reads changed: typing, marks, checked, pos. Unchanged blocks are the same
|
|
725
|
+
// objects in both documents, so this is a pointer walk plus a few attr reads.
|
|
726
|
+
export function sameStructure(a, b) {
|
|
727
|
+
if (a.childCount !== b.childCount) return false;
|
|
728
|
+
for (let i = 0; i < a.childCount; i++) {
|
|
729
|
+
const x = a.child(i), y = b.child(i);
|
|
730
|
+
if (x === y) continue;
|
|
731
|
+
if (x.type !== y.type) return false;
|
|
732
|
+
for (const k of STRUCTURAL) if (x.attrs[k] !== y.attrs[k]) return false;
|
|
733
|
+
}
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function collapseState(doc) {
|
|
738
|
+
const ents = outline(doc);
|
|
739
|
+
return { decos: decorationsFor(doc, ents), ranges: hiddenRanges(ents) };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function decorationsFor(doc, ents = outline(doc)) {
|
|
743
|
+
const { Decoration, DecorationSet } = pmView;
|
|
744
|
+
const markers = orderedMarkers(ents);
|
|
745
|
+
const decos = [];
|
|
746
|
+
for (const e of ents) {
|
|
747
|
+
const attrs = {};
|
|
748
|
+
if (e.hidden) { attrs.class = "page-hidden"; attrs["aria-hidden"] = "true"; }
|
|
749
|
+
if (e.depth) attrs.style = `--page-depth:${e.depth}`;
|
|
750
|
+
if (markers.has(e.i)) attrs["data-marker"] = markers.get(e.i);
|
|
751
|
+
if (isToggle(e.node)) attrs["aria-expanded"] = e.node.attrs.open ? "true" : "false";
|
|
752
|
+
if (Object.keys(attrs).length) decos.push(Decoration.node(e.at, e.end, attrs));
|
|
753
|
+
if (isToggle(e.node) && !e.hidden) {
|
|
754
|
+
decos.push(Decoration.widget(e.at + 1, () => disclosure(e.node), { side: -1, ignoreSelection: true, key: `t:${e.node.attrs.id}:${e.node.attrs.open}` }));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return DecorationSet.create(doc, decos);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function disclosure(node) {
|
|
761
|
+
const b = document.createElement("button");
|
|
762
|
+
b.type = "button";
|
|
763
|
+
b.className = "page-toggle-button";
|
|
764
|
+
b.contentEditable = "false";
|
|
765
|
+
b.setAttribute("aria-label", node.attrs.open ? "Collapse" : "Expand");
|
|
766
|
+
b.setAttribute("data-toggle", node.attrs.id || "");
|
|
767
|
+
return b;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function collapsePlugin() {
|
|
771
|
+
let dragging = false;
|
|
772
|
+
return new Plugin({
|
|
773
|
+
key: collapseKey,
|
|
774
|
+
state: {
|
|
775
|
+
init: (_, s) => collapseState(s.doc),
|
|
776
|
+
apply(tr, value, old, s) {
|
|
777
|
+
if (!tr.docChanged) return value;
|
|
778
|
+
if (!sameStructure(old.doc, s.doc)) return collapseState(s.doc);
|
|
779
|
+
const m = tr.mapping;
|
|
780
|
+
return {
|
|
781
|
+
decos: value.decos.map(m, s.doc),
|
|
782
|
+
ranges: value.ranges.map((r) => ({ line: m.map(r.line, -1), sumEnd: m.map(r.sumEnd), from: m.map(r.from), to: m.map(r.to, -1) })),
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
appendTransaction(trs, old, next) {
|
|
787
|
+
if (!trs.some((tr) => tr.docChanged || tr.selectionSet)) return null;
|
|
788
|
+
// A caret hidden by a collapse goes back to the summary; one the user
|
|
789
|
+
// moved into a hidden body keeps going the way it was moving.
|
|
790
|
+
const moved = trs.some((tr) => tr.selectionSet);
|
|
791
|
+
const dir = moved && next.selection.head >= old.selection.head ? 1 : -1;
|
|
792
|
+
const sel = guardSelection(next.doc, next.selection, collapseKey.getState(next).ranges, dir, dragging);
|
|
793
|
+
return sel ? next.tr.setSelection(sel) : null;
|
|
794
|
+
},
|
|
795
|
+
props: {
|
|
796
|
+
decorations: (s) => collapseKey.getState(s).decos,
|
|
797
|
+
handleDOMEvents: {
|
|
798
|
+
mousedown: (view, event) => {
|
|
799
|
+
const btn = event.target.closest?.(".page-toggle-button");
|
|
800
|
+
if (btn) {
|
|
801
|
+
event.preventDefault();
|
|
802
|
+
const ents = outline(view.state.doc);
|
|
803
|
+
const ti = ents.findIndex((e) => e.node.attrs.id === btn.dataset.toggle);
|
|
804
|
+
if (ti >= 0) view.dispatch(setOpen(view.state, ti, !ents[ti].node.attrs.open, ents));
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
dragging = true;
|
|
808
|
+
const win = view.dom.ownerDocument.defaultView;
|
|
809
|
+
const up = () => {
|
|
810
|
+
dragging = false;
|
|
811
|
+
win.removeEventListener("mouseup", up, true);
|
|
812
|
+
const sel = guardSelection(view.state.doc, view.state.selection, collapseKey.getState(view.state).ranges, 1, false);
|
|
813
|
+
if (sel) view.dispatch(view.state.tr.setSelection(sel));
|
|
814
|
+
};
|
|
815
|
+
win.addEventListener("mouseup", up, true);
|
|
816
|
+
return false;
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
handleKeyDown(view, event) {
|
|
820
|
+
const dir = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
|
821
|
+
if (!dir || event.altKey || event.metaKey || event.ctrlKey) return false;
|
|
822
|
+
const { head } = view.state.selection;
|
|
823
|
+
const vertical = event.key === "ArrowDown" || event.key === "ArrowUp";
|
|
824
|
+
const edge = vertical ? (dir > 0 ? "down" : "up") : (dir > 0 ? "forward" : "backward");
|
|
825
|
+
if (!view.endOfTextblock(edge)) return false;
|
|
826
|
+
const ents = outline(view.state.doc);
|
|
827
|
+
const { ranges } = collapseKey.getState(view.state);
|
|
828
|
+
const $h = view.state.doc.resolve(head);
|
|
829
|
+
const i = $h.index(0);
|
|
830
|
+
let to = null;
|
|
831
|
+
if (dir > 0) {
|
|
832
|
+
const r = ranges.find((x) => x.from === ents[i].end);
|
|
833
|
+
if (r) to = snap(view.state.doc, ranges, r, 1);
|
|
834
|
+
} else {
|
|
835
|
+
const ti = collapsedBefore(ents, i);
|
|
836
|
+
// Extending backward takes the closed toggle whole: a head at the
|
|
837
|
+
// summary end would be read as A1 (skip the toggle) and never move.
|
|
838
|
+
if (ti >= 0) to = event.shiftKey ? ents[ti].at + 1 : ents[ti].end - 1;
|
|
839
|
+
}
|
|
840
|
+
if (to === null || to === head) return false;
|
|
841
|
+
const anchor = event.shiftKey ? view.state.selection.anchor : to;
|
|
842
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, anchor, to)).scrollIntoView());
|
|
843
|
+
return true;
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ---- paste ------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
const LIST_LINE = /^([ \t]*)(?:([-*+•])|(\d+)[.)])[ \t]+(?:\[([ xX])\][ \t]+)?(.*)$/;
|
|
852
|
+
|
|
853
|
+
// Plain text whose lines are list lines becomes flat list items. Indent levels
|
|
854
|
+
// come from a stack of leading widths (a tab counts 4), so 2-space, 4-space and
|
|
855
|
+
// tab markdown all work. Text with no list line returns null: ProseMirror's
|
|
856
|
+
// default plain-text handling then applies.
|
|
857
|
+
export function parseListText(text) {
|
|
858
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
859
|
+
if (!lines.some((l) => LIST_LINE.test(l))) return null;
|
|
860
|
+
const widths = [];
|
|
861
|
+
const nodes = [];
|
|
862
|
+
for (const line of lines) {
|
|
863
|
+
if (!line.trim()) continue;
|
|
864
|
+
const m = LIST_LINE.exec(line);
|
|
865
|
+
if (!m) { widths.length = 0; nodes.push(N.paragraph.create(null, line.trim() ? schema.text(line.trim()) : null)); continue; }
|
|
866
|
+
const w = m[1].replace(/\t/g, " ").length;
|
|
867
|
+
while (widths.length && widths[widths.length - 1] > w) widths.pop();
|
|
868
|
+
if (!widths.length || widths[widths.length - 1] < w) widths.push(w);
|
|
869
|
+
const indent = Math.min(widths.length - 1, nodes.length && isList(nodes[nodes.length - 1]) ? nodes[nodes.length - 1].attrs.indent + 1 : 0);
|
|
870
|
+
const kind = m[4] !== undefined ? "check" : m[3] !== undefined ? "ordered" : "bullet";
|
|
871
|
+
nodes.push(N.list_item.create({ kind, indent, checked: m[4] === "x" || m[4] === "X" }, m[5] ? schema.text(m[5]) : null));
|
|
872
|
+
}
|
|
873
|
+
return new model.Slice(model.Fragment.from(nodes), 0, 0);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const LIST_TAGS = new Set(["UL", "OL"]);
|
|
877
|
+
const BLOCK_TAGS = new Set(["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "PRE", "BLOCKQUOTE", "UL", "OL", "LI", "DETAILS", "SUMMARY", "TABLE", "HR", "FIGURE"]);
|
|
878
|
+
|
|
879
|
+
function checkState(li) {
|
|
880
|
+
const box = li.querySelector(":scope > input[type=checkbox], :scope > p > input[type=checkbox], :scope > label > input[type=checkbox]");
|
|
881
|
+
if (box) return { check: true, checked: box.checked || box.hasAttribute("checked") };
|
|
882
|
+
const role = li.getAttribute("role") === "checkbox" || li.hasAttribute("aria-checked");
|
|
883
|
+
if (role) return { check: true, checked: li.getAttribute("aria-checked") === "true" };
|
|
884
|
+
if (li.hasAttribute("data-checked")) return { check: true, checked: li.getAttribute("data-checked") === "true" };
|
|
885
|
+
const cls = li.className || "";
|
|
886
|
+
if (/\b(checked|task-list-item|to-do)\b/.test(cls) || li.closest?.("ul.to-do-list, ul.contains-task-list")) {
|
|
887
|
+
return { check: true, checked: /\bchecked\b/.test(cls) || !!li.querySelector(".checkbox-on") };
|
|
888
|
+
}
|
|
889
|
+
return { check: false, checked: false };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function listKind(li) {
|
|
893
|
+
const style = (li.getAttribute("style") || "") + ";" + (li.parentElement?.getAttribute("style") || "");
|
|
894
|
+
const t = /list-style-type\s*:\s*([a-z-]+)/i.exec(li.getAttribute("style") || "")?.[1];
|
|
895
|
+
if (t) return /decimal|alpha|roman|latin|greek/.test(t) ? "ordered" : "bullet";
|
|
896
|
+
void style;
|
|
897
|
+
return li.parentElement?.tagName === "OL" ? "ordered" : "bullet";
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Rewrites pasted HTML so every list item, at any nesting, is one flat
|
|
901
|
+
// <div data-page-li> carrying kind, indent and checked, and every <details> is
|
|
902
|
+
// a <div data-page-toggle> followed by its body, each top-level body block
|
|
903
|
+
// marked with data-page-parent naming a temporary toggle key. Indent comes from
|
|
904
|
+
// aria-level when present (Google Docs), otherwise from the number of list
|
|
905
|
+
// ancestors (Notion, markdown renderers, GitHub), and is made relative to the
|
|
906
|
+
// shallowest item pasted.
|
|
907
|
+
export function flattenPastedHTML(html, doc = globalThis.document) {
|
|
908
|
+
const root = doc.createElement("div");
|
|
909
|
+
root.innerHTML = html;
|
|
910
|
+
for (const b of root.querySelectorAll('b[style*="font-weight:normal"], b[style*="font-weight: normal"]')) b.replaceWith(...b.childNodes);
|
|
911
|
+
let key = 0;
|
|
912
|
+
const out = doc.createElement("div");
|
|
913
|
+
|
|
914
|
+
const listDepth = (li) => {
|
|
915
|
+
const aria = parseInt(li.getAttribute("aria-level"), 10);
|
|
916
|
+
if (aria > 0) return aria - 1;
|
|
917
|
+
let d = -1;
|
|
918
|
+
for (let el = li.parentElement; el && el !== root; el = el.parentElement) if (LIST_TAGS.has(el.tagName)) d++;
|
|
919
|
+
return Math.max(0, d);
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const items = [];
|
|
923
|
+
function emitLi(li, parentKey, target) {
|
|
924
|
+
const div = doc.createElement("div");
|
|
925
|
+
const { check, checked } = checkState(li);
|
|
926
|
+
div.setAttribute("data-page-li", "");
|
|
927
|
+
div.setAttribute("data-kind", check ? "check" : listKind(li));
|
|
928
|
+
div.setAttribute("data-checked", checked ? "true" : "false");
|
|
929
|
+
div.dataset.depth = String(listDepth(li));
|
|
930
|
+
if (parentKey) div.setAttribute("data-page-parent", parentKey);
|
|
931
|
+
let first = true;
|
|
932
|
+
const rest = [];
|
|
933
|
+
for (const child of [...li.childNodes]) {
|
|
934
|
+
if (child.nodeType === 1 && LIST_TAGS.has(child.tagName)) { rest.push(child); continue; }
|
|
935
|
+
if (child.nodeType === 1 && child.tagName === "INPUT") continue;
|
|
936
|
+
if (child.nodeType === 1 && BLOCK_TAGS.has(child.tagName)) {
|
|
937
|
+
if (!first) div.appendChild(doc.createElement("br"));
|
|
938
|
+
for (const n of [...child.childNodes]) { if (!(n.nodeType === 1 && n.tagName === "INPUT")) div.appendChild(n); }
|
|
939
|
+
first = false;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
div.appendChild(child);
|
|
943
|
+
if (child.nodeType !== 3 || child.textContent.trim()) first = false;
|
|
944
|
+
}
|
|
945
|
+
target.appendChild(div);
|
|
946
|
+
items.push(div);
|
|
947
|
+
for (const list of rest) walk(list, parentKey, target);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Text and inline elements between blocks gather into one paragraph per
|
|
951
|
+
// line; a <br> between them starts the next line.
|
|
952
|
+
function walk(node, parentKey, target) {
|
|
953
|
+
let line = null;
|
|
954
|
+
const inline = (n) => {
|
|
955
|
+
if (!line) {
|
|
956
|
+
line = doc.createElement("p");
|
|
957
|
+
if (parentKey) line.setAttribute("data-page-parent", parentKey);
|
|
958
|
+
target.appendChild(line);
|
|
959
|
+
}
|
|
960
|
+
line.appendChild(n);
|
|
961
|
+
};
|
|
962
|
+
for (const child of [...node.childNodes]) {
|
|
963
|
+
if (child.nodeType === 1 && (child.tagName === "META" || child.tagName === "STYLE")) continue;
|
|
964
|
+
if (child.nodeType === 1 && child.tagName === "BR") { line = null; continue; }
|
|
965
|
+
if (child.nodeType !== 1) {
|
|
966
|
+
if (child.nodeType === 3 && (line || child.textContent.trim())) inline(child);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (!BLOCK_TAGS.has(child.tagName) && child.tagName !== "ASIDE") { inline(child); continue; }
|
|
970
|
+
line = null;
|
|
971
|
+
if (LIST_TAGS.has(child.tagName)) { walk(child, parentKey, target); continue; }
|
|
972
|
+
if (child.tagName === "LI") { emitLi(child, parentKey, target); continue; }
|
|
973
|
+
if (child.tagName === "DETAILS") {
|
|
974
|
+
const k = `paste-${++key}`;
|
|
975
|
+
const summary = child.querySelector(":scope > summary");
|
|
976
|
+
const t = doc.createElement("div");
|
|
977
|
+
t.setAttribute("data-page-toggle", k);
|
|
978
|
+
if (child.hasAttribute("open")) t.setAttribute("data-open", "true");
|
|
979
|
+
if (parentKey) t.setAttribute("data-page-parent", parentKey);
|
|
980
|
+
if (summary) t.append(...summary.childNodes);
|
|
981
|
+
target.appendChild(t);
|
|
982
|
+
for (const c of [...child.childNodes]) if (c !== summary) { const box = doc.createElement("div"); box.appendChild(c); walk(box, k, target); }
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (child.tagName === "DIV" && !child.hasAttribute("data-page-li") && [...child.children].some((c) => BLOCK_TAGS.has(c.tagName))) { walk(child, parentKey, target); continue; }
|
|
986
|
+
if (parentKey) child.setAttribute("data-page-parent", parentKey);
|
|
987
|
+
target.appendChild(child);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
walk(root, null, out);
|
|
991
|
+
const min = items.length ? Math.min(...items.map((d) => +d.dataset.depth)) : 0;
|
|
992
|
+
let prev = null;
|
|
993
|
+
for (const d of items) {
|
|
994
|
+
const want = +d.dataset.depth - min;
|
|
995
|
+
const adj = prev && d.previousElementSibling === prev ? +prev.getAttribute("data-indent") + 1 : 0;
|
|
996
|
+
d.setAttribute("data-indent", String(Math.min(want, adj)));
|
|
997
|
+
d.removeAttribute("data-depth");
|
|
998
|
+
prev = d;
|
|
999
|
+
}
|
|
1000
|
+
const splitImages = (node) => {
|
|
1001
|
+
if (node.nodeType !== 1 || node.tagName === "IMG") return [node.cloneNode(true)];
|
|
1002
|
+
const parts = [];
|
|
1003
|
+
let part = node.cloneNode(false);
|
|
1004
|
+
for (const child of node.childNodes) for (const piece of splitImages(child)) {
|
|
1005
|
+
if (piece.nodeType === 1 && piece.tagName === "IMG") {
|
|
1006
|
+
if (part.hasChildNodes()) parts.push(part);
|
|
1007
|
+
parts.push(piece);
|
|
1008
|
+
part = node.cloneNode(false);
|
|
1009
|
+
} else part.appendChild(piece);
|
|
1010
|
+
}
|
|
1011
|
+
if (part.hasChildNodes() || !parts.length) parts.push(part);
|
|
1012
|
+
return parts;
|
|
1013
|
+
};
|
|
1014
|
+
for (const child of [...out.children]) {
|
|
1015
|
+
if (!child.matches("p, h1, h2, h3, h4, h5, h6, aside, div[data-page-li], div.page-li") || !child.querySelector("img")) continue;
|
|
1016
|
+
const pieces = splitImages(child);
|
|
1017
|
+
const parent = child.getAttribute("data-page-parent");
|
|
1018
|
+
if (parent) for (const piece of pieces) piece.setAttribute("data-page-parent", parent);
|
|
1019
|
+
child.replaceWith(...pieces);
|
|
1020
|
+
}
|
|
1021
|
+
return out.innerHTML;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const numberAttr = (value, min = 0) => value !== null && value !== "" && Number.isInteger(+value) && +value >= min ? +value : null;
|
|
1025
|
+
const parentAttr = (dom) => dom.getAttribute("data-page-parent") || null;
|
|
1026
|
+
|
|
1027
|
+
// The clipboard parser: the schema has toDOM only, so paste gets its own rules.
|
|
1028
|
+
export const clipboardParser = new model.DOMParser(schema, [
|
|
1029
|
+
{ tag: "div[data-page-li]", node: "list_item", getAttrs: (d) => ({ kind: d.getAttribute("data-kind") || "bullet", indent: +d.getAttribute("data-indent") || 0, checked: d.getAttribute("data-checked") === "true", parent: parentAttr(d) }) },
|
|
1030
|
+
{ tag: "div.page-li", node: "list_item", getAttrs: (d) => ({ kind: d.getAttribute("data-kind") || "bullet", indent: +d.getAttribute("data-indent") || 0, checked: d.getAttribute("data-checked") === "true", parent: parentAttr(d) }) },
|
|
1031
|
+
{ tag: "div[data-page-toggle]", node: "toggle", getAttrs: (d) => ({ id: d.getAttribute("data-page-toggle"), open: d.getAttribute("data-open") === "true", parent: parentAttr(d) }) },
|
|
1032
|
+
{ tag: "div.page-toggle", node: "toggle", getAttrs: (d) => ({ id: d.getAttribute("data-id"), open: d.getAttribute("data-open") === "true", parent: null }) },
|
|
1033
|
+
{ tag: "h1", node: "heading", getAttrs: (d) => ({ level: 1, parent: parentAttr(d) }) },
|
|
1034
|
+
{ tag: "h2", node: "heading", getAttrs: (d) => ({ level: 2, parent: parentAttr(d) }) },
|
|
1035
|
+
{ tag: "h3", node: "heading", getAttrs: (d) => ({ level: 3, parent: parentAttr(d) }) },
|
|
1036
|
+
{ tag: "h4", node: "heading", getAttrs: (d) => ({ level: 3, parent: parentAttr(d) }) },
|
|
1037
|
+
{ tag: "pre", node: "code_block", preserveWhitespace: "full", getAttrs: (d) => ({ lang: d.getAttribute("data-lang") || "", parent: parentAttr(d) }) },
|
|
1038
|
+
{ tag: "hr", node: "divider", getAttrs: (d) => ({ parent: parentAttr(d) }) },
|
|
1039
|
+
{ tag: "aside.page-callout", node: "callout", getAttrs: (d) => ({ parent: parentAttr(d) }) },
|
|
1040
|
+
{ tag: 'figure:not([data-type]), figure[data-type="image"]', node: "image", getAttrs: (d) => {
|
|
1041
|
+
const img = d.querySelector("img"), src = safeSrc(img?.getAttribute("src"));
|
|
1042
|
+
return src ? { src, alt: img.getAttribute("alt") || "", caption: d.querySelector("figcaption")?.textContent || "", width: numberAttr(d.getAttribute("data-width") ?? img.getAttribute("width"), 1), align: alignOf(d.getAttribute("data-align")), parent: parentAttr(d) } : false;
|
|
1043
|
+
} },
|
|
1044
|
+
{ tag: "img[src]", node: "image", getAttrs: (d) => {
|
|
1045
|
+
const src = safeSrc(d.getAttribute("src"));
|
|
1046
|
+
return src ? { src, alt: d.getAttribute("alt") || "", width: numberAttr(d.getAttribute("width"), 1), parent: parentAttr(d) } : false;
|
|
1047
|
+
} },
|
|
1048
|
+
{ tag: 'div.page-file, div[data-type="file"]', node: "file", getAttrs: (d) => {
|
|
1049
|
+
const src = safeHref(d.getAttribute("data-src"));
|
|
1050
|
+
return src ? { src, name: d.getAttribute("data-name") || "file", size: numberAttr(d.getAttribute("data-size")), mime: d.getAttribute("data-mime") || null, parent: parentAttr(d) } : false;
|
|
1051
|
+
} },
|
|
1052
|
+
{ tag: "p", node: "paragraph", getAttrs: (d) => ({ parent: parentAttr(d) }) },
|
|
1053
|
+
{ tag: "br", node: "hard_break" },
|
|
1054
|
+
{ tag: "strong", mark: "strong" },
|
|
1055
|
+
{ tag: "b", mark: "strong", getAttrs: (d) => (/font-weight\s*:\s*(normal|[1-5]00)\b/.test(d.getAttribute("style") || "") ? false : null) },
|
|
1056
|
+
{ tag: "span", mark: "strong", getAttrs: (d) => (/font-weight\s*:\s*(bold|[6-9]00)\b/.test(d.getAttribute("style") || "") ? null : false) },
|
|
1057
|
+
{ tag: "em", mark: "em" },
|
|
1058
|
+
{ tag: "i", mark: "em" },
|
|
1059
|
+
{ tag: "span", mark: "em", getAttrs: (d) => (/font-style\s*:\s*italic/.test(d.getAttribute("style") || "") ? null : false) },
|
|
1060
|
+
{ tag: "s", mark: "strike" },
|
|
1061
|
+
{ tag: "del", mark: "strike" },
|
|
1062
|
+
{ tag: "code", mark: "code" },
|
|
1063
|
+
{ tag: "a[href]", mark: "link", getAttrs: (d) => { const href = safeHref(d.getAttribute("href")); return href ? { href } : false; } },
|
|
1064
|
+
]);
|
|
1065
|
+
|
|
1066
|
+
// Every pasted block arrives with no id and no pos (identityPlugin mints and
|
|
1067
|
+
// places them). Toggles alone get a fresh id here, because their bodies must
|
|
1068
|
+
// name it; each pasted body is rewired to its toggle's new id. A block whose
|
|
1069
|
+
// parent is not in the slice takes the parent of the block being pasted into,
|
|
1070
|
+
// and pasted list items shift by that item's indent.
|
|
1071
|
+
// Pasted into a paragraph or heading, a slice that starts or ends with a list
|
|
1072
|
+
// item or toggle is closed on that side, so the items arrive whole instead of
|
|
1073
|
+
// their first line merging into the paragraph.
|
|
1074
|
+
export function remapPasted(slice, st) {
|
|
1075
|
+
const $from = st.selection.$from;
|
|
1076
|
+
const selected = st.selection instanceof BlockSelection;
|
|
1077
|
+
const host = selected ? st.doc.child(st.selection.span().lo) : $from.depth >= 1 ? $from.node(1) : null;
|
|
1078
|
+
const destParent = host ? parentOf(host) : null;
|
|
1079
|
+
const base = host && isList(host) ? host.attrs.indent : 0;
|
|
1080
|
+
const ids = new Map();
|
|
1081
|
+
slice.content.forEach((n) => { if (isToggle(n) && n.attrs.id) ids.set(n.attrs.id, newId()); });
|
|
1082
|
+
const nodes = [];
|
|
1083
|
+
slice.content.forEach((n) => {
|
|
1084
|
+
if (!n.type.spec.group?.includes("block")) { nodes.push(n); return; }
|
|
1085
|
+
const p = parentOf(n);
|
|
1086
|
+
const attrs = { ...n.attrs, pos: null, id: isToggle(n) ? (ids.get(n.attrs.id) ?? null) : null, parent: p !== null && ids.has(p) ? ids.get(p) : destParent };
|
|
1087
|
+
if (isList(n)) attrs.indent = n.attrs.indent + base;
|
|
1088
|
+
nodes.push(n.type.create(attrs, n.content, n.marks));
|
|
1089
|
+
});
|
|
1090
|
+
const intoText = !host || !(isList(host) || isToggle(host));
|
|
1091
|
+
const structural = (n) => n && (isList(n) || isToggle(n));
|
|
1092
|
+
const openStart = intoText && structural(nodes[0]) ? 0 : slice.openStart;
|
|
1093
|
+
const last = nodes[nodes.length - 1];
|
|
1094
|
+
const fresh = new Set(ids.values());
|
|
1095
|
+
const openEnd = intoText && (structural(last) || (last && fresh.has(parentOf(last)))) ? 0 : slice.openEnd;
|
|
1096
|
+
return new model.Slice(model.Fragment.from(nodes), selected ? 0 : openStart, selected ? 0 : openEnd);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// The clipboard serializer: the schema's toDOM, plus each copied block's
|
|
1100
|
+
// owner (data-page-parent) and each toggle's id (data-page-toggle), so a copied
|
|
1101
|
+
// toggle pastes with its body still inside it.
|
|
1102
|
+
class ClipboardSerializer extends model.DOMSerializer {
|
|
1103
|
+
serializeFragment(fragment, options, target) {
|
|
1104
|
+
const out = super.serializeFragment(fragment, options, target);
|
|
1105
|
+
if (!fragment.firstChild?.isBlock) return out;
|
|
1106
|
+
const doms = [...out.childNodes];
|
|
1107
|
+
fragment.forEach((n, _, i) => {
|
|
1108
|
+
const dom = doms[i];
|
|
1109
|
+
if (!dom || dom.nodeType !== 1) return;
|
|
1110
|
+
if (parentOf(n) !== null) dom.setAttribute("data-page-parent", parentOf(n));
|
|
1111
|
+
if (isToggle(n) && n.attrs.id) dom.setAttribute("data-page-toggle", n.attrs.id);
|
|
1112
|
+
if (n.type === N.image && n.attrs.width) dom.setAttribute("data-width", String(n.attrs.width));
|
|
1113
|
+
});
|
|
1114
|
+
return out;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const base = model.DOMSerializer.fromSchema(schema);
|
|
1118
|
+
export const clipboardSerializer = new ClipboardSerializer(base.nodes, base.marks);
|
|
1119
|
+
|
|
1120
|
+
// Several blocks pasted into a closed toggle's summary join its body, so the
|
|
1121
|
+
// toggle opens first instead of hiding what was just pasted.
|
|
1122
|
+
function pasteIntoClosedSummary(view, _event, slice) {
|
|
1123
|
+
const { $from } = view.state.selection;
|
|
1124
|
+
const host = $from.depth >= 1 ? $from.node(1) : null;
|
|
1125
|
+
if (!host || !isToggle(host) || host.attrs.open || slice.content.childCount < 2) return false;
|
|
1126
|
+
const tr = view.state.tr.setNodeMarkup($from.before(1), null, { ...host.attrs, open: true }).replaceSelection(slice);
|
|
1127
|
+
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"));
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export function pastePlugin() {
|
|
1132
|
+
return new Plugin({
|
|
1133
|
+
props: {
|
|
1134
|
+
handlePaste: pasteIntoClosedSummary,
|
|
1135
|
+
clipboardSerializer,
|
|
1136
|
+
transformPastedHTML: (html) => flattenPastedHTML(html),
|
|
1137
|
+
clipboardParser,
|
|
1138
|
+
clipboardTextParser: (text, _$ctx, plain) => (plain ? null : parseListText(text)),
|
|
1139
|
+
transformPasted: (slice, view) => remapPasted(slice, view.state),
|
|
1140
|
+
},
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---- keymap -----------------------------------------------------------------
|
|
1145
|
+
|
|
1146
|
+
const backspace = commands.chainCommands(backspaceListItem, backspaceToggle, backspaceCodeEdge, backspaceBodyBlock, backspaceAfterCollapsed);
|
|
1147
|
+
const del = commands.chainCommands(deleteAtCollapsedEnd, deleteBeforeToggle, deleteCodeEdge);
|
|
1148
|
+
|
|
1149
|
+
// Modified Backspace and Delete get the same guards as the plain keys, so none
|
|
1150
|
+
// of them can reach baseKeymap's joins into a hidden body. Where no guard
|
|
1151
|
+
// applies they fall through to the base keymap or the browser as before.
|
|
1152
|
+
export const blockKeys = {
|
|
1153
|
+
Enter: commands.chainCommands(enterListItem, enterToggle, exitToggleOnEmpty, enterBodyBlock),
|
|
1154
|
+
Backspace: backspace,
|
|
1155
|
+
"Shift-Backspace": backspace,
|
|
1156
|
+
"Mod-Backspace": backspace,
|
|
1157
|
+
"Alt-Backspace": backspace,
|
|
1158
|
+
"Ctrl-h": backspace,
|
|
1159
|
+
Delete: del,
|
|
1160
|
+
"Shift-Delete": del,
|
|
1161
|
+
"Mod-Delete": del,
|
|
1162
|
+
"Alt-Delete": del,
|
|
1163
|
+
"Ctrl-d": del,
|
|
1164
|
+
"Ctrl-Alt-Backspace": del,
|
|
1165
|
+
Tab: indentBlocks,
|
|
1166
|
+
"Shift-Tab": outdentBlocks,
|
|
1167
|
+
"Mod-Shift-ArrowUp": moveBlock(-1),
|
|
1168
|
+
"Mod-Shift-ArrowDown": moveBlock(1),
|
|
1169
|
+
"Mod-Enter": toggleOpenAtSelection,
|
|
1170
|
+
};
|