@voithos-labs/aragonite 0.10.3 → 0.10.4
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/dist/a11y-strings.d.ts +1 -0
- package/dist/a11y-strings.js +1 -0
- package/dist/components/BlockDragHandle.svelte +2 -0
- package/dist/components/BlockHost.svelte +2 -2
- package/dist/components/Editor.svelte +56 -3
- package/dist/components/SelectionOverlay.svelte +17 -23
- package/dist/components/SelectionOverlay.svelte.d.ts +1 -1
- package/dist/components/blocks/code/CodeBlockRail.svelte +42 -24
- package/dist/components/blocks/code/code-bootstrap.js +12 -0
- package/dist/components/blocks/code/code-languages.d.ts +5 -5
- package/dist/components/blocks/code/code-languages.js +20 -12
- package/dist/components/blocks/editable-leaf.d.ts +3 -3
- package/dist/components/blocks/editable-leaf.js +6 -2
- package/dist/components/blocks/list/ListBlock.svelte +1 -0
- package/dist/components/blocks/list/ListItemBlock.svelte +12 -3
- package/dist/components/blocks/list/ListItemBlock.svelte.d.ts +2 -0
- package/dist/components/blocks/text/click-snap-guard.d.ts +3 -0
- package/dist/components/blocks/text/click-snap-guard.js +8 -0
- package/dist/components/blocks/text/edge-policy-dispatch.js +33 -18
- package/dist/components/blocks/text/live-selection-edit.d.ts +5 -5
- package/dist/components/blocks/text/live-selection-edit.js +10 -7
- package/dist/components/blocks/text/text-clipboard.js +2 -2
- package/dist/components/blocks/text/widget-interaction.js +45 -23
- package/dist/components/drag-handle.d.ts +6 -0
- package/dist/components/drag-handle.js +8 -0
- package/dist/components/editor-root-geometry.d.ts +1 -1
- package/dist/components/editor-root-geometry.js +1 -1
- package/dist/components/editor-root-listeners.d.ts +0 -7
- package/dist/components/editor-root-listeners.js +0 -22
- package/dist/components/image/ImageResizeHandles.svelte +18 -10
- package/dist/components/menu/SelectionToolbar.svelte +385 -0
- package/dist/components/menu/SelectionToolbar.svelte.d.ts +16 -0
- package/dist/core/inline/inline-widgets.d.ts +12 -4
- package/dist/core/inline/inline-widgets.js +5 -0
- package/dist/core/inline/transparency.js +3 -3
- package/dist/cursor/height-oracle.d.ts +2 -3
- package/dist/cursor/height-oracle.js +0 -1
- package/dist/cursor/scroll-hold.d.ts +10 -0
- package/dist/cursor/scroll-hold.js +22 -0
- package/dist/cursor/scrollport.d.ts +9 -0
- package/dist/cursor/scrollport.js +30 -1
- package/dist/cursor/visual-lines.d.ts +5 -4
- package/dist/cursor/visual-lines.js +56 -11
- package/dist/cursor/widget-edge-snap.d.ts +28 -0
- package/dist/cursor/widget-edge-snap.js +38 -0
- package/dist/cursor/widget-offset.d.ts +3 -0
- package/dist/cursor/widget-offset.js +10 -0
- package/dist/decorations/reserved-attrs.js +1 -0
- package/dist/editor-actions/focus/focus-dispatch.js +7 -4
- package/dist/editor-actions/focus/focus-landing.d.ts +10 -1
- package/dist/editor-actions/focus/focus-landing.js +20 -6
- package/dist/editor-actions/plugin/container.d.ts +7 -0
- package/dist/editor-actions/plugin/container.js +3 -1
- package/dist/editor-props.d.ts +3 -0
- package/dist/plugin.d.ts +2 -1
- package/dist/plugin.js +6 -1
- package/dist/plugins/latex/latex-kind.js +48 -31
- package/dist/plugins/latex/math-source.d.ts +3 -3
- package/dist/plugins/latex/math-source.js +32 -26
- package/dist/plugins/mermaid/MermaidBlock.svelte +19 -9
- package/dist/plugins/parrot/ParrotBlock.svelte +3 -1
- package/dist/reactivity/list-windowing.svelte.js +26 -6
- package/dist/schema/commands.d.ts +9 -5
- package/dist/schema/commands.js +12 -7
- package/dist/schema/operations.d.ts +1 -1
- package/dist/schema/reserved-chords.js +12 -0
- package/dist/selection/cross-block/keydown.js +2 -28
- package/dist/selection/cross-block/paste.js +7 -13
- package/dist/selection/cross-block/type-replace.js +5 -15
- package/dist/selection/dead-space-caret.d.ts +3 -0
- package/dist/selection/dead-space-caret.js +12 -2
- package/dist/selection/drag-pointer.d.ts +19 -0
- package/dist/selection/drag-pointer.js +42 -0
- package/dist/selection/keyboard-extend.d.ts +3 -2
- package/dist/selection/keyboard-extend.js +3 -2
- package/dist/selection/multi-click.d.ts +42 -0
- package/dist/selection/multi-click.js +203 -0
- package/dist/selection/native-bridge.d.ts +3 -0
- package/dist/selection/native-bridge.js +31 -2
- package/dist/selection/path-lookup.d.ts +10 -2
- package/dist/selection/path-lookup.js +10 -3
- package/dist/selection/pointer-gesture.d.ts +9 -0
- package/dist/selection/pointer-gesture.js +11 -0
- package/dist/selection/primitives.d.ts +7 -0
- package/dist/selection/primitives.js +19 -1
- package/dist/selection/range-delete.d.ts +4 -0
- package/dist/selection/range-delete.js +1 -1
- package/dist/selection/selection-drop.d.ts +34 -0
- package/dist/selection/selection-drop.js +253 -0
- package/dist/styles/editor-theme.css +18 -3
- package/dist/styles/editor.css +64 -13
- package/dist/tree-operations/paste/replace-block-at-parent.d.ts +3 -1
- package/dist/tree-operations/paste/replace-block-at-parent.js +4 -1
- package/dist/tree-operations/paste/replacement-parse.d.ts +17 -0
- package/dist/tree-operations/paste/replacement-parse.js +22 -0
- package/docs/guide/consumer-guide.md +10 -8
- package/docs/guide/plugin-api.md +54 -38
- package/docs/guide/plugin-guide.md +31 -22
- package/package.json +7 -7
- package/dist/selection/double-click-trim.d.ts +0 -17
- package/dist/selection/double-click-trim.js +0 -57
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dragging a selection and dropping it. The browser's own version is two native edits committed
|
|
3
|
+
* apart — `deleteByDrag` on the source, `insertFromDrop` on the target — so undo takes two presses
|
|
4
|
+
* over a document that lost bytes in between, and inside one block the source commit rebuilds the
|
|
5
|
+
* surface under the drop. Owned here instead: one snapshot over two raw writes, which splice
|
|
6
|
+
* nothing, so no path moves under the second.
|
|
7
|
+
*/
|
|
8
|
+
import { emitClipboardError } from '../editor-events';
|
|
9
|
+
import { ambientLengthOf } from '../ambient/ambient-dom';
|
|
10
|
+
import { toClampedRawOffset } from '../cursor/coordinate-spaces';
|
|
11
|
+
import { domTextOffsetAtNode } from '../cursor/widget-offset';
|
|
12
|
+
import { trailingLineEnding, trimTrailingLineEnding } from '../core/lines';
|
|
13
|
+
import { replaceRangeRaw } from '../components/blocks/text/live-selection-edit';
|
|
14
|
+
import { blockNodeAt, emptyParagraph, writeOwnRaw } from '../tree-operations/node-primitives';
|
|
15
|
+
import { cloneNode } from '../tree-operations/clone';
|
|
16
|
+
import { cutRangeFromDisplay } from '../tree-operations/node-ops';
|
|
17
|
+
import { rebuildAncestryRaw } from '../schema/container-raw';
|
|
18
|
+
import { applyPasteTransforms } from '../tree-operations/paste/paste-transforms';
|
|
19
|
+
import { replaceBlockAtParent } from '../tree-operations/paste/replace-block-at-parent';
|
|
20
|
+
import { parseReplacement } from '../tree-operations/paste/replacement-parse';
|
|
21
|
+
import { blockNearPoint } from './nearest-block';
|
|
22
|
+
import { findSurfaceForElement } from './path-lookup';
|
|
23
|
+
import { containerAmbientPrefix } from './range-delete';
|
|
24
|
+
/** A drag that IS this gesture but whose shape the seam does not move — a range leaving its
|
|
25
|
+
* surface, an empty one. The drop cancels it rather than handing it back. */
|
|
26
|
+
const DECLINED = 'declined';
|
|
27
|
+
// ── Public entry ───────────────────────────────────────────────────────────
|
|
28
|
+
export function installSelectionDrop(deps) {
|
|
29
|
+
let source = null;
|
|
30
|
+
const onDragStart = (e) => {
|
|
31
|
+
source = readSelectionSource(deps.editorRoot, e.target);
|
|
32
|
+
};
|
|
33
|
+
const onDragEnd = () => {
|
|
34
|
+
source = null;
|
|
35
|
+
};
|
|
36
|
+
// The editor is the drop target for every drag this seam recognized, declined ones included:
|
|
37
|
+
// the drop below must RUN to cancel the browser's pair of native edits.
|
|
38
|
+
const onDragOver = (e) => {
|
|
39
|
+
if (source)
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
};
|
|
42
|
+
const onDrop = (e) => {
|
|
43
|
+
const from = source;
|
|
44
|
+
source = null;
|
|
45
|
+
if (!from)
|
|
46
|
+
return;
|
|
47
|
+
// Before every decline below, not after: the browser's two edits land apart and its undo
|
|
48
|
+
// is not ours, so a shape this seam declines mutates nothing rather than half of it.
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
if (from === DECLINED || deps.isReadOnly())
|
|
51
|
+
return;
|
|
52
|
+
const target = dropTarget(deps, e.clientX, e.clientY);
|
|
53
|
+
if (!target)
|
|
54
|
+
return;
|
|
55
|
+
const text = movedText(deps, from);
|
|
56
|
+
if (text === null)
|
|
57
|
+
return;
|
|
58
|
+
void runDrop(deps, from, target, text, e.ctrlKey || e.altKey).catch((error) => {
|
|
59
|
+
// A throw between the two writes leaves a snapshot pushed and half the move applied;
|
|
60
|
+
// the host hears it on the channel the paste route reports through.
|
|
61
|
+
emitClipboardError(deps.events, { error, path: from.path });
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
deps.editorRoot.addEventListener('dragstart', onDragStart);
|
|
65
|
+
deps.editorRoot.addEventListener('dragend', onDragEnd);
|
|
66
|
+
deps.editorRoot.addEventListener('dragover', onDragOver);
|
|
67
|
+
deps.editorRoot.addEventListener('drop', onDrop);
|
|
68
|
+
return () => {
|
|
69
|
+
deps.editorRoot.removeEventListener('dragstart', onDragStart);
|
|
70
|
+
deps.editorRoot.removeEventListener('dragend', onDragEnd);
|
|
71
|
+
deps.editorRoot.removeEventListener('dragover', onDragOver);
|
|
72
|
+
deps.editorRoot.removeEventListener('drop', onDrop);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Where a splice of `delta` blocks at `at` inside `parent` leaves `target`: what the drop's
|
|
76
|
+
* second write addresses once the first one's reparse changed its slot's block count. */
|
|
77
|
+
export function shiftPathAfterSplice(target, parent, at, delta) {
|
|
78
|
+
if (delta === 0 || target.length <= parent.length)
|
|
79
|
+
return target;
|
|
80
|
+
for (let i = 0; i < parent.length; i++)
|
|
81
|
+
if (target[i] !== parent[i])
|
|
82
|
+
return target;
|
|
83
|
+
if (target[parent.length] <= at)
|
|
84
|
+
return target;
|
|
85
|
+
const shifted = target.slice();
|
|
86
|
+
shifted[parent.length] += delta;
|
|
87
|
+
return shifted;
|
|
88
|
+
}
|
|
89
|
+
/** Where a drop at `offset` lands once `[start, end)` left the same block, `shrunkBy` bytes
|
|
90
|
+
* shorter than the range was wide once the join seam cleaned up. Null inside the range. */
|
|
91
|
+
export function dropOffsetAfterCut(offset, start, end, shrunkBy) {
|
|
92
|
+
if (offset > start && offset < end)
|
|
93
|
+
return null;
|
|
94
|
+
return offset <= start ? offset : Math.max(0, offset - shrunkBy);
|
|
95
|
+
}
|
|
96
|
+
// ── Reading the gesture ────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* The native range the drag carries, in its surface's raw offsets. `null` is "not this gesture" and
|
|
99
|
+
* leaves the drag to the browser; {@link DECLINED} is this gesture in a shape the seam does not
|
|
100
|
+
* move, which the drop cancels. A cross-block selection reaches neither: it paints through the
|
|
101
|
+
* overlay and leaves no native range for the browser to drag.
|
|
102
|
+
*/
|
|
103
|
+
function readSelectionSource(editorRoot, dragged) {
|
|
104
|
+
const sel = window.getSelection();
|
|
105
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0)
|
|
106
|
+
return null;
|
|
107
|
+
const range = sel.getRangeAt(0);
|
|
108
|
+
// A draggable of its own (a rendered link, an image) is not this gesture, even with a range
|
|
109
|
+
// painted elsewhere: a selection drag grips a node the range covers.
|
|
110
|
+
if (!(dragged instanceof Node) || !range.intersectsNode(dragged))
|
|
111
|
+
return null;
|
|
112
|
+
const surface = surfaceOf(range.startContainer);
|
|
113
|
+
if (!surface || !editorRoot.contains(surface))
|
|
114
|
+
return null;
|
|
115
|
+
// Past this point the drag IS the editor's selection, so every remaining shape declines.
|
|
116
|
+
if (!surface.contains(range.endContainer))
|
|
117
|
+
return DECLINED;
|
|
118
|
+
const found = findSurfaceForElement(surface);
|
|
119
|
+
if (!found)
|
|
120
|
+
return DECLINED;
|
|
121
|
+
const ambient = ambientLengthOf(surface);
|
|
122
|
+
const start = toClampedRawOffset(domTextOffsetAtNode(surface, range.startContainer, range.startOffset), ambient);
|
|
123
|
+
const end = toClampedRawOffset(domTextOffsetAtNode(surface, range.endContainer, range.endOffset), ambient);
|
|
124
|
+
return start < end ? { ...found, start, end } : DECLINED;
|
|
125
|
+
}
|
|
126
|
+
/** The block and offset a drop point addresses, as a single-click caret would land. Null where
|
|
127
|
+
* the point names no character position (a whole-block unit, a table cell). */
|
|
128
|
+
function dropTarget(deps, clientX, clientY) {
|
|
129
|
+
const near = blockNearPoint(deps.editorRoot, clientX, clientY);
|
|
130
|
+
const point = near?.endpointHere();
|
|
131
|
+
if (!point || !('offset' in point) || point.cellCoordinate)
|
|
132
|
+
return null;
|
|
133
|
+
return blockNodeAt(deps.getDoc(), point.path) ? { path: point.path, offset: point.offset } : null;
|
|
134
|
+
}
|
|
135
|
+
/** The source surface's own bytes for the range, past the paste transforms. Null for a payload
|
|
136
|
+
* this seam does not own: a line break needs the structural paste route. */
|
|
137
|
+
function movedText(deps, from) {
|
|
138
|
+
const node = blockNodeAt(deps.getDoc(), from.path);
|
|
139
|
+
if (!node)
|
|
140
|
+
return null;
|
|
141
|
+
const raw = trimTrailingLineEnding(node.raw).slice(from.start, from.end);
|
|
142
|
+
const text = applyPasteTransforms(raw, deps.activePlugins);
|
|
143
|
+
return text && !/[\r\n]/.test(text) ? text : null;
|
|
144
|
+
}
|
|
145
|
+
// ── The commit ceremony ────────────────────────────────────────────────────
|
|
146
|
+
async function runDrop(deps, from, to, text, copy) {
|
|
147
|
+
if (copy) {
|
|
148
|
+
await writeBlockRaw(deps, to.path, insert(to.offset, text), to.offset + text.length, true);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const cut = cutFrom(deps, from);
|
|
152
|
+
if (!cut)
|
|
153
|
+
return;
|
|
154
|
+
if (pathsEqual(cut.path, to.path)) {
|
|
155
|
+
const offset = dropOffsetAfterCut(to.offset, from.start, from.end, cut.shrunkBy);
|
|
156
|
+
if (offset === null)
|
|
157
|
+
return;
|
|
158
|
+
const merged = spliceAt(cut.raw, offset, text);
|
|
159
|
+
await writeBlockRaw(deps, cut.path, () => merged, offset + text.length, true);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
deps.controller.pushUndoSnapshotPath(from.path, from.start);
|
|
163
|
+
// A table's caret door reads a cell landing, never a character offset (G1.29), so the
|
|
164
|
+
// transient caret the second write moves off is its first cell.
|
|
165
|
+
const sourceCaret = from.inCell ? 0 : from.start;
|
|
166
|
+
const spliced = await writeBlockRaw(deps, cut.path, () => cut.raw, sourceCaret, false);
|
|
167
|
+
const at = cut.path[cut.path.length - 1];
|
|
168
|
+
const target = shiftPathAfterSplice(to.path, cut.path.slice(0, -1), at, spliced);
|
|
169
|
+
await writeBlockRaw(deps, target, insert(to.offset, text), to.offset + text.length, false);
|
|
170
|
+
}
|
|
171
|
+
function insert(offset, text) {
|
|
172
|
+
return (raw) => spliceAt(raw, offset, text);
|
|
173
|
+
}
|
|
174
|
+
/** The source surface's display bytes with the dragged range gone, through the delete seam so a
|
|
175
|
+
* live-mode join cleans up after itself. */
|
|
176
|
+
function cutFrom(deps, from) {
|
|
177
|
+
const node = blockNodeAt(deps.getDoc(), from.path);
|
|
178
|
+
if (!node)
|
|
179
|
+
return null;
|
|
180
|
+
if (from.inCell)
|
|
181
|
+
return cutFromCell(deps, from, node);
|
|
182
|
+
const before = trimTrailingLineEnding(node.raw);
|
|
183
|
+
const edit = replaceRangeRaw(node, { start: from.start, end: from.end }, '', deps.getPresentationMode?.(), deps.linkRef, containerAmbientPrefix(deps.getDoc(), from.path));
|
|
184
|
+
const raw = trimTrailingLineEnding(edit.raw);
|
|
185
|
+
return { path: from.path, raw, shrunkBy: before.length - raw.length };
|
|
186
|
+
}
|
|
187
|
+
/** A cell's bytes are joined into its row, so the TABLE is the block the cut rewrites: the cell's
|
|
188
|
+
* own range-delete on a copy, then the kind's escape and the ancestry rebuild around it. */
|
|
189
|
+
function cutFromCell(deps, from, cell) {
|
|
190
|
+
const tablePath = from.path.slice(0, -2);
|
|
191
|
+
// The cell path is resolved from a DOM selector contract, so the kind is read, not assumed.
|
|
192
|
+
const table = blockNodeAt(deps.getDoc(), tablePath);
|
|
193
|
+
if (!table || table.kind !== 'table')
|
|
194
|
+
return null;
|
|
195
|
+
const cut = cutRangeFromDisplay(cell, cell.raw, { start: from.start, end: from.end }, deps.getPresentationMode?.(), deps.linkRef);
|
|
196
|
+
const rebuilt = cloneNode(table);
|
|
197
|
+
const inner = from.path.slice(-2);
|
|
198
|
+
const [rowIdx, colIdx] = inner;
|
|
199
|
+
const written = rebuilt.children?.[rowIdx]?.children?.[colIdx];
|
|
200
|
+
if (!written)
|
|
201
|
+
return null;
|
|
202
|
+
writeOwnRaw(written, cut.display, deps.grammar);
|
|
203
|
+
rebuildAncestryRaw(rebuilt, inner);
|
|
204
|
+
const raw = trimTrailingLineEnding(rebuilt.raw);
|
|
205
|
+
return { path: tablePath, raw, shrunkBy: trimTrailingLineEnding(table.raw).length - raw.length };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Replace the block at `path` with the reparse of the bytes `rewrite` returns, at its parent
|
|
209
|
+
* scope. Answers how many blocks the slot grew or shrank by, which is what keeps a second
|
|
210
|
+
* write's path honest. `own` pushes this write's own undo entry.
|
|
211
|
+
*/
|
|
212
|
+
async function writeBlockRaw(deps, path, rewrite, caret, own) {
|
|
213
|
+
const doc = deps.getDoc();
|
|
214
|
+
const node = blockNodeAt(doc, path);
|
|
215
|
+
if (!node)
|
|
216
|
+
return 0;
|
|
217
|
+
const written = rewrite(trimTrailingLineEnding(node.raw));
|
|
218
|
+
// A block emptied by the cut keeps its slot as a blank paragraph: no splice, so the second
|
|
219
|
+
// write's path is still the one this gesture resolved.
|
|
220
|
+
const parsed = parseReplacement(node, written, deps.grammar, () => [
|
|
221
|
+
emptyParagraph(node.leadingTrivia ?? '', trailingLineEnding(node.raw))
|
|
222
|
+
]);
|
|
223
|
+
if (!parsed)
|
|
224
|
+
return 0;
|
|
225
|
+
const landed = await replaceBlockAtParent({
|
|
226
|
+
doc,
|
|
227
|
+
blockPath: path,
|
|
228
|
+
replacement: parsed.replacement,
|
|
229
|
+
controller: deps.coordinator,
|
|
230
|
+
undoEntry: own ? 'own' : 'join',
|
|
231
|
+
focusReplacementIndex: parsed.replacement.length - 1,
|
|
232
|
+
focusOffset: caret,
|
|
233
|
+
source: 'selection-drop',
|
|
234
|
+
...(deps.grammar ? { grammar: deps.grammar } : {})
|
|
235
|
+
});
|
|
236
|
+
// The LANDED count, not the parse's: a body rule inside the scope can rewrite the list. Zero
|
|
237
|
+
// is the unmounted-scope decline, which wrote nothing and so moved nothing.
|
|
238
|
+
return Math.max(0, landed - 1);
|
|
239
|
+
}
|
|
240
|
+
// ── Small helpers ──────────────────────────────────────────────────────────
|
|
241
|
+
function spliceAt(raw, offset, text) {
|
|
242
|
+
const at = Math.max(0, Math.min(offset, raw.length));
|
|
243
|
+
return raw.slice(0, at) + text + raw.slice(at);
|
|
244
|
+
}
|
|
245
|
+
function pathsEqual(a, b) {
|
|
246
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
247
|
+
}
|
|
248
|
+
function surfaceOf(node) {
|
|
249
|
+
let el = node instanceof HTMLElement ? node : node.parentElement;
|
|
250
|
+
while (el && el.contentEditable !== 'true')
|
|
251
|
+
el = el.parentElement;
|
|
252
|
+
return el;
|
|
253
|
+
}
|
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
--color-bg-secondary, which is measured against the base bg. */
|
|
70
70
|
--color-ui-faint: rgba(255, 255, 255, 0.07);
|
|
71
71
|
|
|
72
|
+
/* One radius for a picture, the crop frame that clips it and the selection ring on the
|
|
73
|
+
overlay above it: half the surface radius, enough to soften a photo without a card look. */
|
|
74
|
+
--md-image-radius: 4px;
|
|
75
|
+
|
|
72
76
|
/* Markdown syntax override points, deliberately neutral so the default rendering carries no
|
|
73
77
|
extra palette. Mode-independent: the chrome tokens they chain to carry the light/dark
|
|
74
78
|
split. */
|
|
@@ -112,8 +116,8 @@
|
|
|
112
116
|
/* Presentational tokens (mode-independent). */
|
|
113
117
|
/* Washes over the host-declarable selection base, alpha apart, so a host that names one
|
|
114
118
|
base moves the selection, the search matches and the reorder chrome together. */
|
|
115
|
-
--selection-overlay-bg:
|
|
116
|
-
--search-match-bg:
|
|
119
|
+
--selection-overlay-bg: rgba(100, 150, 255, 0.3);
|
|
120
|
+
--search-match-bg: rgba(100, 150, 255, 0.22);
|
|
117
121
|
/* Painted on top of the match text, so it must keep an alpha channel or it hides it. */
|
|
118
122
|
--search-match-active-bg: rgba(216, 166, 87, 0.55);
|
|
119
123
|
--md-ref-label-color: rgba(128, 128, 128, 0.45);
|
|
@@ -125,7 +129,7 @@
|
|
|
125
129
|
--md-reorder-indicator: var(--color-selection, #6496ff);
|
|
126
130
|
/* Wash on the container being reordered within during a nested drag. Tinted with the reorder
|
|
127
131
|
accent, not neutral grey, so the shift stays perceptible for low-contrast vision. */
|
|
128
|
-
--reorder-scope-bg:
|
|
132
|
+
--reorder-scope-bg: rgba(100, 150, 255, 0.14);
|
|
129
133
|
--syntax-marker-dim: 0.65;
|
|
130
134
|
|
|
131
135
|
/* Menu/popout elevation, per mode: a shadow tuned for a near-black surface is invisible
|
|
@@ -143,6 +147,17 @@
|
|
|
143
147
|
--vr-spacer-bg: rgba(255, 255, 255, 0.025);
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
/* The washes follow a host's `--color-selection` where the engine can mix; the static values
|
|
151
|
+
above are the same tints of the default base, for engines without `color-mix` (WebKit before
|
|
152
|
+
Safari 16.2). */
|
|
153
|
+
@supports (color: color-mix(in srgb, red, blue)) {
|
|
154
|
+
:where(.editor, .aragonite-editor-theme) {
|
|
155
|
+
--selection-overlay-bg: color-mix(in srgb, var(--color-selection, #6496ff) 30%, transparent);
|
|
156
|
+
--search-match-bg: color-mix(in srgb, var(--color-selection, #6496ff) 22%, transparent);
|
|
157
|
+
--reorder-scope-bg: color-mix(in srgb, var(--color-selection, #6496ff) 14%, transparent);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
:where(.editor, .aragonite-editor-theme)[data-editor-theme='light'] {
|
|
147
162
|
--color-bg-secondary: rgba(0, 0, 0, 0.035);
|
|
148
163
|
--color-bg-elevated: #f5f6f8;
|
package/dist/styles/editor.css
CHANGED
|
@@ -168,9 +168,7 @@
|
|
|
168
168
|
max-width: 100%;
|
|
169
169
|
height: auto;
|
|
170
170
|
display: block;
|
|
171
|
-
|
|
172
|
-
card: half the surface radius the block chrome uses. */
|
|
173
|
-
border-radius: 4px;
|
|
171
|
+
border-radius: var(--md-image-radius);
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
/* A cropped image: the widget is the frame (width and aspect set inline from the `|WxH` hint,
|
|
@@ -178,15 +176,16 @@
|
|
|
178
176
|
:where(.editor) .md-image-widget.md-image-cropped {
|
|
179
177
|
overflow: hidden;
|
|
180
178
|
max-width: 100%;
|
|
181
|
-
/* The frame clips the picture, so the rounding belongs to it
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
:where(.editor) .md-image-widget.md-image-cropped img {
|
|
187
|
-
border-radius: 0;
|
|
179
|
+
/* The frame clips the picture, so the rounding belongs to it. `clip-path` alongside
|
|
180
|
+
`overflow`, because a rounded overflow clip is the one Chromium drops when the picture
|
|
181
|
+
inside gets its own compositing layer, and a crop is exactly when that happens. */
|
|
182
|
+
border-radius: var(--md-image-radius);
|
|
183
|
+
clip-path: inset(0 round var(--md-image-radius));
|
|
188
184
|
}
|
|
189
185
|
|
|
186
|
+
/* The picture keeps its own radius rather than going square inside the frame: at zoom 1 it sits
|
|
187
|
+
exactly on the frame, so the two corners coincide, and above 1 its corners are outside the
|
|
188
|
+
frame entirely. Either way the crop cannot come out with harder corners than the plain image. */
|
|
190
189
|
:where(.editor) .md-image-widget.md-image-cropped img {
|
|
191
190
|
position: absolute;
|
|
192
191
|
max-width: none;
|
|
@@ -321,6 +320,20 @@
|
|
|
321
320
|
z-index: 10;
|
|
322
321
|
}
|
|
323
322
|
|
|
323
|
+
/* A selected image says so with a hairline ring. It rides the overlay, not the widget: a cropped
|
|
324
|
+
widget clips its own pseudo-elements, and a border on the picture would move the layout. Held
|
|
325
|
+
just off the edge so it reads as a ring around the picture rather than part of it, over a
|
|
326
|
+
translucent dark line that keeps it legible against a pale photo. */
|
|
327
|
+
:where(.editor) .md-image-overlay::before {
|
|
328
|
+
content: '';
|
|
329
|
+
position: absolute;
|
|
330
|
+
inset: -2px;
|
|
331
|
+
border: 1px solid var(--color-accent, #567b67);
|
|
332
|
+
border-radius: calc(var(--md-image-radius, 4px) + 2px);
|
|
333
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18);
|
|
334
|
+
pointer-events: none;
|
|
335
|
+
}
|
|
336
|
+
|
|
324
337
|
:where(.editor) .md-image-overlay > * {
|
|
325
338
|
pointer-events: auto;
|
|
326
339
|
}
|
|
@@ -652,12 +665,16 @@
|
|
|
652
665
|
.task-checkbox::before {
|
|
653
666
|
content: '';
|
|
654
667
|
visibility: visible;
|
|
668
|
+
/* Centred by layout and sized to whole pixels, never by a translate: a transformed box
|
|
669
|
+
rasterises at fractional edges on a scaled desktop and reads shorter than it is wide.
|
|
670
|
+
The unrounded pair is the fallback for a browser without `round()`. */
|
|
655
671
|
position: absolute;
|
|
656
|
-
|
|
657
|
-
|
|
672
|
+
inset: 0;
|
|
673
|
+
margin: auto;
|
|
658
674
|
width: 1.2em;
|
|
659
675
|
height: 1.2em;
|
|
660
|
-
|
|
676
|
+
width: round(1.2em, 1px);
|
|
677
|
+
height: round(1.2em, 1px);
|
|
661
678
|
box-sizing: border-box;
|
|
662
679
|
border: 1.5px solid var(--color-ui-muted, #8f8f89);
|
|
663
680
|
border-radius: 0.28em;
|
|
@@ -699,6 +716,40 @@
|
|
|
699
716
|
transform: translate(-50%, -50%) rotate(45deg);
|
|
700
717
|
}
|
|
701
718
|
|
|
719
|
+
/* The span is the SLOT, a full line-height tall; the box is the pseudo-element inside it. The
|
|
720
|
+
hover tint the source modes put on the span (ListItemBlock) would draw a tall rectangle
|
|
721
|
+
round the square, so in the rendered modes it moves onto the box: an open box fills with
|
|
722
|
+
the tint, a checked one is already a solid fill and keeps it. */
|
|
723
|
+
:where(.editor):is(
|
|
724
|
+
[data-presentation='reading'],
|
|
725
|
+
[data-presentation^='preview-'],
|
|
726
|
+
[data-presentation='live']
|
|
727
|
+
)
|
|
728
|
+
.list-item-block[data-list-marker='task']
|
|
729
|
+
.task-checkbox:hover {
|
|
730
|
+
background-color: transparent;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
:where(.editor):is(
|
|
734
|
+
[data-presentation='reading'],
|
|
735
|
+
[data-presentation^='preview-'],
|
|
736
|
+
[data-presentation='live']
|
|
737
|
+
)
|
|
738
|
+
.list-item-block[data-list-marker='task']
|
|
739
|
+
.task-checkbox::before {
|
|
740
|
+
transition: background-color 60ms ease-out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
:where(.editor):is(
|
|
744
|
+
[data-presentation='reading'],
|
|
745
|
+
[data-presentation^='preview-'],
|
|
746
|
+
[data-presentation='live']
|
|
747
|
+
)
|
|
748
|
+
.list-item-block[data-list-marker='task']:not([data-task-checked='true'])
|
|
749
|
+
.task-checkbox:hover::before {
|
|
750
|
+
background-color: var(--md-marker-hover-bg, rgba(128, 128, 128, 0.15));
|
|
751
|
+
}
|
|
752
|
+
|
|
702
753
|
/* ── Reading mode ─────────────────────────────────────────────────────────────
|
|
703
754
|
The marker-hiding families reading shares with the preview rungs and `live` are in the
|
|
704
755
|
section above; reading adds only its read-only inertness on top — `live` takes none of it. */
|
|
@@ -26,4 +26,6 @@ export interface ReplaceBlockAtParentArgs {
|
|
|
26
26
|
/** The clipboard's own trailing blank line, where nothing in the splice stands for it. */
|
|
27
27
|
trailingSeparator?: string;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
/** Answers how many blocks LANDED in the slot — the body rule below can rewrite the list, so a
|
|
30
|
+
* caller whose next write addresses a later sibling asks here rather than counting its own. */
|
|
31
|
+
export declare function replaceBlockAtParent(args: ReplaceBlockAtParentArgs): Promise<number>;
|
|
@@ -25,12 +25,14 @@ function landTrailingSeparator(args, children, afterIndex, ending) {
|
|
|
25
25
|
return;
|
|
26
26
|
args.doc.suffix = ending;
|
|
27
27
|
}
|
|
28
|
+
/** Answers how many blocks LANDED in the slot — the body rule below can rewrite the list, so a
|
|
29
|
+
* caller whose next write addresses a later sibling asks here rather than counting its own. */
|
|
28
30
|
export async function replaceBlockAtParent(args) {
|
|
29
31
|
const { doc, blockPath, controller, undoEntry, focusOffset, source } = args;
|
|
30
32
|
const blockIdx = blockPath[blockPath.length - 1];
|
|
31
33
|
const scope = resolveParentScope(doc, blockPath, controller);
|
|
32
34
|
if (!scope)
|
|
33
|
-
return;
|
|
35
|
+
return 0;
|
|
34
36
|
// A replacement is minted before any byte sink sees it, so the owner's bodyWrite escape
|
|
35
37
|
// lands here — on the clipboard blocks AND the target's split halves alike.
|
|
36
38
|
const ownerKind = blockPath.length > 1 ? scope.node.kind : undefined;
|
|
@@ -72,4 +74,5 @@ export async function replaceBlockAtParent(args) {
|
|
|
72
74
|
return controller.landCaret([...scope.path, caret.index], landedPasteOffset(landed, caret, focusOffset));
|
|
73
75
|
}
|
|
74
76
|
});
|
|
77
|
+
return replacement.length;
|
|
75
78
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes a block is replaced BY, parsed the one way every replace-at-parent caller needs them:
|
|
3
|
+
* terminated in the original's own line ending (G4.20), reparsed in the instance grammar, carrying
|
|
4
|
+
* the original's leading trivia, with editable containers ensured.
|
|
5
|
+
*/
|
|
6
|
+
import type { CstNode } from '../../core/nodes';
|
|
7
|
+
import type { GrammarView } from '../../schema/block-openers';
|
|
8
|
+
export interface ParsedReplacement {
|
|
9
|
+
replacement: CstNode[];
|
|
10
|
+
/** The parse's own trailing blank, for a caller that lands one (`paste/dispatch.ts`). */
|
|
11
|
+
suffix: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Null where `raw` parses to nothing and the caller named no `fallback` — the block keeps its
|
|
15
|
+
* bytes rather than being replaced by an empty splice.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseReplacement(original: CstNode, raw: string, grammar: GrammarView | undefined, fallback?: () => CstNode[]): ParsedReplacement | null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes a block is replaced BY, parsed the one way every replace-at-parent caller needs them:
|
|
3
|
+
* terminated in the original's own line ending (G4.20), reparsed in the instance grammar, carrying
|
|
4
|
+
* the original's leading trivia, with editable containers ensured.
|
|
5
|
+
*/
|
|
6
|
+
import { parse } from '../../core/parser';
|
|
7
|
+
import { terminateLine } from '../../core/lines';
|
|
8
|
+
import { ensureEditableContainers, normalizeReplacementTrivia } from '../node-primitives';
|
|
9
|
+
/**
|
|
10
|
+
* Null where `raw` parses to nothing and the caller named no `fallback` — the block keeps its
|
|
11
|
+
* bytes rather than being replaced by an empty splice.
|
|
12
|
+
*/
|
|
13
|
+
export function parseReplacement(original, raw, grammar, fallback) {
|
|
14
|
+
const parsed = parse(terminateLine(raw, original.raw), { grammar, scope: 'fragment' });
|
|
15
|
+
const children = parsed.children.length > 0 ? parsed.children : fallback?.();
|
|
16
|
+
if (!children || children.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const replacement = normalizeReplacementTrivia(original, children);
|
|
19
|
+
for (const node of replacement)
|
|
20
|
+
ensureEditableContainers(node);
|
|
21
|
+
return { replacement, suffix: parsed.suffix };
|
|
22
|
+
}
|
|
@@ -130,10 +130,11 @@ Everything supported is exported from `@voithos-labs/aragonite`. Before 1.0 the
|
|
|
130
130
|
| `blockDragHandles` | The block drag handle, revealed on hover and shown outright on touch (default on; reading mode hides it). Only object blocks carry one — code, tables, equations, diagrams, pictures, list items, dividers, cards — never prose. `false` removes them, except on a picture; keyboard reorder (Alt+Arrow) and the cell menu need no opt-in |
|
|
131
131
|
| `searchBar` | The built-in find/replace bar and its Mod+F / Mod+H shortcuts (default on) |
|
|
132
132
|
| `searchBarAnchor` | An element to render that same bar into, instead of inside the editor root (see [Where the find bar lives](#where-the-find-bar-lives)) |
|
|
133
|
+
| `selectionToolbar` | The built-in formatting popover over a selection: the marks, the link, a heading picker, inline code and copy (default on; reading mode never shows it; see [Recipe: a selection toolbar](#recipe-a-selection-toolbar)) |
|
|
133
134
|
|
|
134
135
|
**Set once at mount:** `resolveImageUrl`, `resolveLinkUrl`, `imageLoadPolicy`, `onLinkActivate`, `onPasteImage`, `onRunCode`, `codeMenuItems`, `blockDragHandles`, `scrollMode`, and `plugins`. Set them at mount and leave them; a swap later isn't guaranteed to reach blocks that are already built.
|
|
135
136
|
|
|
136
|
-
**Read live:** `theme`, `searchBar`, `searchBarAnchor`, `presentationMode`, and `keybindings` may change after mount, and `header` re-renders like any other Svelte snippet.
|
|
137
|
+
**Read live:** `theme`, `searchBar`, `searchBarAnchor`, `selectionToolbar`, `presentationMode`, and `keybindings` may change after mount, and `header` re-renders like any other Svelte snippet.
|
|
137
138
|
|
|
138
139
|
## The instance surface
|
|
139
140
|
|
|
@@ -283,8 +284,8 @@ editor.runCommand('nope'); // false, unknown id, nothing changed
|
|
|
283
284
|
|
|
284
285
|
The ids you can pass:
|
|
285
286
|
|
|
286
|
-
- **`TOOLBAR_COMMANDS`** (exported from the package) has what a selection toolbar needs: `toggleStrong`, `toggleEmphasis`, `toggleStrikethrough`, `toggleCode`, and `
|
|
287
|
-
- **`
|
|
287
|
+
- **`TOOLBAR_COMMANDS`** (exported from the package) has what a selection toolbar needs: `toggleStrong`, `toggleEmphasis`, `toggleStrikethrough`, `toggleCode`, `editLink`, and `setHeading`. The rest of the built-in commands stay internal for now.
|
|
288
|
+
- **`setHeading`, with a level.** The arm behind `Mod+0` to `Mod+6`: `runCommand(TOOLBAR_COMMANDS.setHeading, 2)` re-marks the focused prose block as a level-2 heading and `0` makes it a paragraph, which is what a heading picker calls. A heading level belongs to one block, so over a selection spanning blocks it declines rather than guessing which block you meant.
|
|
288
289
|
- **A plugin's global command name.** `registerGlobalCommand` registers it (see the [plugin guide](plugin-guide.md)), and it resolves ahead of the focused block, so you can fire a plugin's editor-wide action without a keystroke. A plugin's per-block command stays keyboard-only.
|
|
289
290
|
|
|
290
291
|
`arg` is the argument a keymap binding would bake in (`{ chord: 'Mod+2', command: 'heading.cycle', arg: 2 }`), handed to the command as it is; a command that takes none ignores it.
|
|
@@ -292,7 +293,7 @@ The ids you can pass:
|
|
|
292
293
|
What the boolean means:
|
|
293
294
|
|
|
294
295
|
- **`true` means the editor took the command, not that the edit has landed.** A toggle inside a construct whose markers a preview mode has revealed (see [Presentation modes](#presentation-modes)) settles that reveal first, so read the outcome on the `edit` channel rather than polling `getSource()`.
|
|
295
|
-
- **`false` means nothing changed**: an unknown id, reading mode, a command that needs a focused block when none is, or the link editor over a selection spanning blocks (a link lives inside one block, and a range across blocks gives
|
|
296
|
+
- **`false` means nothing changed**: an unknown id, reading mode, a command that needs a focused block when none is, or the link editor or a heading level over a selection spanning blocks (a link lives inside one block, a heading level is one block's, and a range across blocks gives them none).
|
|
296
297
|
|
|
297
298
|
Two more things before you wire buttons:
|
|
298
299
|
|
|
@@ -301,12 +302,13 @@ Two more things before you wire buttons:
|
|
|
301
302
|
|
|
302
303
|
`canRunCommand(commandId: string): boolean`
|
|
303
304
|
|
|
304
|
-
Tells you whether `runCommand(id)` would reach the command right now, which is what greys a toolbar button out instead of hiding it. It answers `false` exactly where `runCommand` declines before dispatch: an unknown id, reading mode, a block-scoped id with nothing focused, and the link editor while the selection spans blocks. `true` means reachable, not that it'll write (across blocks it may find no block that can hold the mark), so keep reading `runCommand`'s boolean too.
|
|
305
|
+
Tells you whether `runCommand(id)` would reach the command right now, which is what greys a toolbar button out instead of hiding it. It answers `false` exactly where `runCommand` declines before dispatch: an unknown id, reading mode, a block-scoped id with nothing focused, and the link editor or a heading level while the selection spans blocks. `true` means reachable, not that it'll write (across blocks it may find no block that can hold the mark), so keep reading `runCommand`'s boolean too.
|
|
305
306
|
|
|
306
307
|
```ts
|
|
307
308
|
// with a selection spanning two paragraphs
|
|
308
309
|
editor.canRunCommand(TOOLBAR_COMMANDS.toggleStrong); // true
|
|
309
310
|
editor.canRunCommand(TOOLBAR_COMMANDS.editLink); // false, a link can't span blocks
|
|
311
|
+
editor.canRunCommand(TOOLBAR_COMMANDS.setHeading); // false, a heading level is one block's
|
|
310
312
|
```
|
|
311
313
|
|
|
312
314
|
`isCommandActive(commandId: string): boolean`
|
|
@@ -1047,7 +1049,7 @@ The bundled toc plugin does exactly that walk over its live document, and clicki
|
|
|
1047
1049
|
|
|
1048
1050
|
### Recipe: a selection toolbar
|
|
1049
1051
|
|
|
1050
|
-
|
|
1052
|
+
The editor ships one: a popover that opens beside a prose selection with the marks, the link, a heading picker (inside one block only), inline code and copy, on by default and off with `selectionToolbar={false}`. It is built on the doors below and nothing else, so this recipe is also how to replace it with your own. Nine steps, and the anchoring ones have a snippet after the list:
|
|
1051
1053
|
|
|
1052
1054
|
1. **Subscribe to `selectionChange`.** A `null` payload or a collapsed selection (anchor equals focus) hides the bar.
|
|
1053
1055
|
2. **Put the endpoints in document order first.** `normalizeSelection(snapshot)` answers `{ start, end }` (by path, then by offset when the paths match), so a backward drag anchors exactly like a forward one. Anchor to `start`; a hand-rolled comparison gets the container-and-its-child pair wrong, where the shorter path is the earlier one.
|
|
@@ -1055,7 +1057,7 @@ Float a formatting bar above the user's selection. Nine steps, and the anchoring
|
|
|
1055
1057
|
4. **Single-block selections**: `getSelection()` reports the range's real endpoints, so anchor with `rangeRects(start.path, start.offset, end.offset)`, the same call with a real end offset in place of `SELECTION_END`. (Reading the native `window.getSelection()` range works too, since within one block the editor delegates selection to the browser.) A selection **inside a table** shares the table's path on both endpoints and carries cell indices in `offset`, which the `cellCoordinate` flag need not mark, so exclude it with `getBlockKindAt(start.path) === 'table'`, never by the flag alone.
|
|
1056
1058
|
5. **Re-anchor on the next `selectionChange`, not on scroll.** Rects are viewport-space snapshots; a `position: fixed` bar drifts under scroll until the selection next changes. Wire a scroll listener only if your UX demands live tracking.
|
|
1057
1059
|
6. **Fire the buttons through `runCommand`, not synthetic keystrokes.** `runCommand(TOOLBAR_COMMANDS.toggleStrong)` says what the button means; a synthesized `Ctrl+B` says which key the button impersonates, and a user's rebind then silently rewires it.
|
|
1058
|
-
7. **Grey the declining buttons out with `canRunCommand`, on the same `selectionChange`.** Ask it per button and disable the ones that answer `false`, so a selection spanning blocks shows the link button dimmed rather than dead while the format toggles stay live. Still read `runCommand`'s boolean, per [Toolbar commands](#toolbar-commands).
|
|
1060
|
+
7. **Grey the declining buttons out with `canRunCommand`, on the same `selectionChange`.** Ask it per button and disable the ones that answer `false`, so a selection spanning blocks shows the link button dimmed rather than dead while the format toggles stay live (the editor's own bar goes one further and drops a labelled row the door declines, which is why its heading picker vanishes there). Still read `runCommand`'s boolean, per [Toolbar commands](#toolbar-commands).
|
|
1059
1061
|
8. **Paint the pressed states with `isCommandActive`, on that same `selectionChange`.** A selection already inside a bold run shows the bold button pressed (`aria-pressed` is the accessible spelling), and pressing it then unwraps: the pressed paint and the press read the same bytes, so they agree by construction. In live mode a selection sitting inside a link shows the link button pressed the same way, off the link the card would edit, and clicking it opens that link's card with the selection left alone; a selection that runs out of the link isn't inside it, so the button unpresses and the click falls back to creating a new link over the range.
|
|
1060
1062
|
9. **Keep focus in the document**, for the same reason the insert toolbar does: cancel the button's mousedown default, or restore a `getSelection()` snapshot before calling.
|
|
1061
1063
|
|
|
@@ -1073,7 +1075,7 @@ editor.getEvents().on('selectionChange', (sel) => {
|
|
|
1073
1075
|
});
|
|
1074
1076
|
```
|
|
1075
1077
|
|
|
1076
|
-
The
|
|
1078
|
+
The editor's own bar (`src/lib/components/menu/SelectionToolbar.svelte`) is this recipe end to end: both anchoring branches, the table exclusion, the `TOOLBAR_COMMANDS` buttons greyed by `canRunCommand` and pressed by `isCommandActive`, and the mousedown cancel that keeps the caret in the document.
|
|
1077
1079
|
|
|
1078
1080
|
### Recipe: an insert toolbar
|
|
1079
1081
|
|