@atom-forge/brickee 0.0.1 → 0.0.3
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/CHANGELOG.md +59 -1
- package/README-AI.md +34 -28
- package/dist/Editor.svelte +713 -867
- package/dist/Editor.svelte.d.ts +0 -3
- package/dist/LinkPrompt.svelte +1 -14
- package/dist/Preview.svelte +27 -58
- package/dist/Preview.svelte.d.ts +2 -7
- package/dist/PropsBar.svelte +75 -249
- package/dist/PropsBar.svelte.d.ts +0 -3
- package/dist/code.d.ts +12 -0
- package/dist/code.js +15 -0
- package/dist/dom.d.ts +8 -0
- package/dist/dom.js +9 -0
- package/dist/editor/BlockMenu.svelte +102 -0
- package/dist/editor/BlockMenu.svelte.d.ts +32 -0
- package/dist/editor/FormatBar.svelte +41 -0
- package/dist/editor/FormatBar.svelte.d.ts +16 -0
- package/dist/editor/drag.svelte.d.ts +63 -0
- package/dist/editor/drag.svelte.js +95 -0
- package/dist/editor/history.d.ts +24 -0
- package/dist/editor/history.js +86 -0
- package/dist/editor/sweep.svelte.d.ts +21 -0
- package/dist/editor/sweep.svelte.js +56 -0
- package/dist/html.d.ts +12 -0
- package/dist/html.js +206 -0
- package/dist/i18n.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -3
- package/dist/kit/Empty.svelte +4 -15
- package/dist/kit/Frame.svelte +7 -37
- package/dist/locales/hu.js +6 -8
- package/dist/markdown.d.ts +20 -6
- package/dist/markdown.js +380 -85
- package/dist/model.js +2 -3
- package/dist/plugins/Gap.svelte +5 -8
- package/dist/plugins/GapPreview.svelte +4 -6
- package/dist/plugins/Map.svelte +2 -17
- package/dist/plugins/MapPreview.svelte +8 -10
- package/dist/plugins/Table.svelte +233 -282
- package/dist/plugins/TablePreview.svelte +28 -28
- package/dist/plugins/YouTube.svelte +3 -31
- package/dist/plugins/YouTubePreview.svelte +45 -43
- package/dist/plugins/index.js +15 -2
- package/dist/schema.js +1 -1
- package/dist/types.d.ts +27 -10
- package/dist/types.js +8 -1
- package/dist/view.js +1 -1
- package/docs/en/bundled-plugins.md +1 -1
- package/docs/en/documents-and-markdown.md +24 -19
- package/docs/en/editor-and-preview.md +7 -10
- package/docs/en/getting-started.md +14 -2
- package/docs/en/host-contexts.md +26 -132
- package/docs/en/index.md +5 -5
- package/docs/en/recipes-and-pitfalls.md +12 -11
- package/docs/en/server-side.md +0 -17
- package/docs/en/upgrading.md +34 -4
- package/docs/en/writing-plugins.md +147 -21
- package/docs/hu/bundled-plugins.md +1 -1
- package/docs/hu/documents-and-markdown.md +24 -19
- package/docs/hu/editor-and-preview.md +7 -10
- package/docs/hu/getting-started.md +14 -2
- package/docs/hu/host-contexts.md +26 -132
- package/docs/hu/index.md +5 -5
- package/docs/hu/recipes-and-pitfalls.md +12 -11
- package/docs/hu/server-side.md +0 -17
- package/docs/hu/upgrading.md +34 -4
- package/docs/hu/writing-plugins.md +147 -21
- package/package.json +14 -2
- package/dist/ImagePicker.svelte +0 -131
- package/dist/ImagePicker.svelte.d.ts +0 -13
- package/dist/media.d.ts +0 -17
- package/dist/media.js +0 -12
package/dist/Editor.svelte
CHANGED
|
@@ -1,34 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import { Bold, Check, Code, GripVertical, Italic, Link, Plus, RemoveFormatting, Replace, Strikethrough, Subscript, Superscript, Trash2, Underline, Ungroup } from '@lucide/svelte';
|
|
4
|
-
import {
|
|
5
|
-
rich,
|
|
6
|
-
clean,
|
|
7
|
-
textLen,
|
|
8
|
-
splitAtCaret,
|
|
9
|
-
atStart,
|
|
10
|
-
atEnd,
|
|
11
|
-
caretOffset,
|
|
12
|
-
caretRect,
|
|
13
|
-
caretAt,
|
|
14
|
-
setCaret,
|
|
15
|
-
removeBeforeCaret,
|
|
16
|
-
onEdgeLine,
|
|
17
|
-
focusAtEdge,
|
|
18
|
-
toggleCode,
|
|
19
|
-
plainText
|
|
20
|
-
} from './dom';
|
|
2
|
+
import { tick, untrack } from 'svelte';
|
|
3
|
+
import { ArrowDown, ArrowUp, Bold, Check, Code, GripVertical, Italic, Link, Plus, RemoveFormatting, Replace, Strikethrough, Subscript, Superscript, Trash2, Underline, Ungroup } from '@lucide/svelte';
|
|
4
|
+
import { rich, clean, textLen, splitAtCaret, atStart, atEnd, caretOffset, caretRect, caretAt, setCaret, removeBeforeCaret, onEdgeLine, focusAtEdge, toggleCode, plainText, portal } from './dom';
|
|
21
5
|
import { fromMarkdown, fromStored, plainHtml, toMarkdown, toStored } from './markdown';
|
|
22
|
-
import {
|
|
6
|
+
import { fromHtml } from './html';
|
|
7
|
+
import { useCodeLanguages } from './code';
|
|
23
8
|
import { provideText, useText, type Messages } from './i18n';
|
|
24
9
|
import { PopupContainer, createPopupManager, getPopupManager } from '@atom-forge/ui';
|
|
25
10
|
import LinkPrompt from './LinkPrompt.svelte';
|
|
26
11
|
import PluginHost from './PluginHost.svelte';
|
|
27
12
|
import PropsBar from './PropsBar.svelte';
|
|
13
|
+
import BlockMenu, { type MenuRow } from './editor/BlockMenu.svelte';
|
|
14
|
+
import FormatBar from './editor/FormatBar.svelte';
|
|
15
|
+
import { History } from './editor/history';
|
|
16
|
+
import { createSweep } from './editor/sweep.svelte';
|
|
17
|
+
import { createDrag, useHub } from './editor/drag.svelte';
|
|
28
18
|
import { acceptsId, BUILT_IN_IDS, PALETTE_ITEMS, type Icon, type PaletteItem } from './palette';
|
|
29
19
|
import { MAX_INDENT, newBlock, numbering, uid, type Block, type BlockPlugin, type BlockType, type StoredBlock, type PropField, type Content, BUILT_IN_CONTENT, isMd, MD } from './types';
|
|
30
20
|
|
|
31
|
-
const HUB = 'block-editor:hub';
|
|
32
21
|
|
|
33
22
|
interface Props {
|
|
34
23
|
/** The document: built-in blocks as markdown snippets, plugin blocks with their props. */
|
|
@@ -41,8 +30,6 @@
|
|
|
41
30
|
plugins?: BlockPlugin[];
|
|
42
31
|
/** Set by plugins with nested blocks: a compact editor inside another block. */
|
|
43
32
|
nested?: boolean;
|
|
44
|
-
/** Images the blocks can pick from (the page's attachments); nested editors share their parent's. */
|
|
45
|
-
media?: MediaItem[];
|
|
46
33
|
/** UI text in the host's language (English text -> translation); nested editors share their parent's. */
|
|
47
34
|
messages?: Messages;
|
|
48
35
|
}
|
|
@@ -53,18 +40,15 @@
|
|
|
53
40
|
palette: paletteProp,
|
|
54
41
|
plugins = [],
|
|
55
42
|
nested = false,
|
|
56
|
-
media,
|
|
57
43
|
messages
|
|
58
44
|
}: Props = $props();
|
|
59
45
|
|
|
60
|
-
provideMedia(() => media);
|
|
61
46
|
provideText(() => messages);
|
|
62
47
|
// Popups (the link field) are atom-forge popups; the top editor has their container, nested editors share it.
|
|
63
48
|
// svelte-ignore state_referenced_locally
|
|
64
49
|
const popups = nested ? getPopupManager() : createPopupManager(getPopupManager());
|
|
65
50
|
const tr = useText();
|
|
66
51
|
const placeholder = $derived(placeholderProp ?? tr("Write something, or press '/' for blocks…"));
|
|
67
|
-
const getMedia = useMedia();
|
|
68
52
|
|
|
69
53
|
// One level of nesting: an editor inside a block offers no block types that hold blocks themselves, nor unique
|
|
70
54
|
// ones (those belong to the top level).
|
|
@@ -77,21 +61,50 @@
|
|
|
77
61
|
const pluginOf = (type: string | undefined) => plugins.find((p) => p.type === type);
|
|
78
62
|
/** Blocks the caret cannot enter from outside: dividers and plugin blocks. */
|
|
79
63
|
const atomic = (b: Block) => b.type === 'hr' || b.type === 'plugin';
|
|
64
|
+
/** Rendering on the server: the blocks' text goes into the markup (in the browser the `rich` action puts it in). */
|
|
65
|
+
const ssr = typeof document === 'undefined';
|
|
80
66
|
|
|
81
67
|
/* ------------------------------------------------------------ document io */
|
|
82
68
|
|
|
83
|
-
// The editor works on html blocks; `doc` is their markdown form, written back on every change
|
|
69
|
+
// The editor works on html blocks; `doc` is their markdown form, written back on every change (not when read-only:
|
|
70
|
+
// showing a document must not change it).
|
|
84
71
|
const load = (d: StoredBlock[]) => (d.length ? d.map(fromStored) : [newBlock()]);
|
|
85
72
|
let value = $state<Block[]>(load(doc));
|
|
86
|
-
|
|
73
|
+
|
|
74
|
+
// The stored form of a built-in block, kept while the block does not change: typing in one block converts only that
|
|
75
|
+
// block (its inline html goes through the DOM), and the others keep their objects, so a preview re-renders only it.
|
|
76
|
+
// Plugin blocks are cheap to convert and keep their `props` and `blocks` objects anyway.
|
|
77
|
+
const stored = new Map<string, { key: string; out: StoredBlock }>();
|
|
78
|
+
function storedOf(b: Block): StoredBlock {
|
|
79
|
+
if (b.type === 'plugin') return toStored(b);
|
|
80
|
+
const key = `${b.type}|${b.indent}|${b.checked ?? ''}|${b.props?.lang ?? ''}|${b.props?.start ?? ''}|${b.html}`;
|
|
81
|
+
const hit = stored.get(b.id);
|
|
82
|
+
if (hit?.key === key) return hit.out;
|
|
83
|
+
const out = toStored(b);
|
|
84
|
+
stored.set(b.id, { key, out });
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What we wrote last, and `doc` as read back after writing it (the host's state may wrap it in its own proxy).
|
|
89
|
+
let written: StoredBlock[] | null = null;
|
|
90
|
+
let readBack: unknown = untrack(() => doc);
|
|
87
91
|
$effect(() => {
|
|
88
|
-
const out = value.map(
|
|
89
|
-
|
|
92
|
+
const out = value.map(storedOf);
|
|
93
|
+
if (stored.size > out.length * 2) for (const id of [...stored.keys()]) if (!value.some((b) => b.id === id)) stored.delete(id);
|
|
94
|
+
if (untrack(() => readonly)) return;
|
|
95
|
+
written = out;
|
|
90
96
|
doc = out;
|
|
97
|
+
readBack = untrack(() => doc);
|
|
91
98
|
});
|
|
92
|
-
// A document replaced from outside (not by our own write) is loaded again.
|
|
99
|
+
// A document replaced from outside (not by our own write) is loaded again; an equal copy is not.
|
|
93
100
|
$effect(() => {
|
|
94
|
-
|
|
101
|
+
const d = doc;
|
|
102
|
+
if (d === readBack) return;
|
|
103
|
+
readBack = d;
|
|
104
|
+
if (JSON.stringify(d) === JSON.stringify(written ?? untrack(() => value.map(storedOf)))) return;
|
|
105
|
+
value = load(d);
|
|
106
|
+
// Another document (or this one reverted by the editor around a nested one): the old steps no longer apply.
|
|
107
|
+
untrack(() => forget());
|
|
95
108
|
});
|
|
96
109
|
|
|
97
110
|
/* ------------------------------------------------------------------ state */
|
|
@@ -105,17 +118,9 @@
|
|
|
105
118
|
|
|
106
119
|
// State shared by an editor and every editor nested in its plugin blocks: blocks can be dragged between them,
|
|
107
120
|
// and only the editor last worked in keeps a block selection.
|
|
108
|
-
type DragSource = { editor: string; ids: string[]; payload: StoredBlock[]; rows: HTMLElement[]; remove: () => void };
|
|
109
|
-
type Hub = {
|
|
110
|
-
src: DragSource | null;
|
|
111
|
-
target: { editor: string; over: string; after: boolean } | null;
|
|
112
|
-
active: string | null;
|
|
113
|
-
};
|
|
114
121
|
// A string, not an object: $state would proxy an object and break identity checks.
|
|
115
122
|
const token = uid();
|
|
116
|
-
const
|
|
117
|
-
const hub: Hub = getContext<Hub>(HUB) ?? ownHub;
|
|
118
|
-
setContext(HUB, hub);
|
|
123
|
+
const hub = useHub();
|
|
119
124
|
|
|
120
125
|
$effect(() => {
|
|
121
126
|
if (selA) hub.active = token;
|
|
@@ -125,6 +130,8 @@
|
|
|
125
130
|
});
|
|
126
131
|
|
|
127
132
|
const idx = (id: string) => value.findIndex((b) => b.id === id);
|
|
133
|
+
/** Puts blocks into the document before position `at`. */
|
|
134
|
+
const insertAt = (at: number, ...blocks: Block[]) => (value = [...value.slice(0, at), ...blocks, ...value.slice(at)]);
|
|
128
135
|
// Direct children only: a plugin row may hold a nested editor with its own blocks.
|
|
129
136
|
const el = (id: string) => root?.querySelector<HTMLElement>(`[data-block="${id}"] > .body > .ce`) ?? null;
|
|
130
137
|
const ownRow = (n: Element | null) => {
|
|
@@ -152,66 +159,39 @@
|
|
|
152
159
|
|
|
153
160
|
/* ---------------------------------------------------------------- history */
|
|
154
161
|
|
|
155
|
-
|
|
162
|
+
// A step holds the document as frozen copies of its blocks, and an unchanged block's copy is shared by every step
|
|
163
|
+
// (a step costs only the blocks that changed, not the whole document).
|
|
164
|
+
type Snap = { blocks: readonly Block[]; id: string | null; off: number };
|
|
165
|
+
const copies = new Map<string, { key: string; copy: Block }>();
|
|
166
|
+
function copyOf(b: Block): Block {
|
|
167
|
+
const key = `${b.type}|${b.indent}|${b.checked ?? ''}|${b.plugin ?? ''}|${JSON.stringify(b.props ?? null)}|${JSON.stringify(b.blocks ?? null)}|${b.html}`;
|
|
168
|
+
const hit = copies.get(b.id);
|
|
169
|
+
if (hit?.key === key) return hit.copy;
|
|
170
|
+
const copy = Object.freeze(JSON.parse(JSON.stringify($state.snapshot(b))) as Block);
|
|
171
|
+
copies.set(b.id, { key, copy });
|
|
172
|
+
return copy;
|
|
173
|
+
}
|
|
156
174
|
const take = (): Snap => {
|
|
157
175
|
const e = focusedId ? el(focusedId) : null;
|
|
158
|
-
|
|
176
|
+
const blocks = value.map(copyOf);
|
|
177
|
+
// copies of deleted blocks: the steps that hold them keep them; the map does not need them
|
|
178
|
+
if (copies.size > blocks.length * 2) for (const id of [...copies.keys()]) if (!value.some((b) => b.id === id)) copies.delete(id);
|
|
179
|
+
return { blocks, id: focusedId, off: e ? caretOffset(e) : 0 };
|
|
159
180
|
};
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
/** Call on every text edit: consecutive typing collapses into one undo step. */
|
|
167
|
-
function typed() {
|
|
168
|
-
if (!pending) {
|
|
169
|
-
past.push(last);
|
|
170
|
-
if (past.length > 300) past.shift();
|
|
171
|
-
future = [];
|
|
172
|
-
pending = true;
|
|
173
|
-
}
|
|
174
|
-
clearTimeout(timer);
|
|
175
|
-
timer = setTimeout(() => {
|
|
176
|
-
pending = false;
|
|
177
|
-
last = take();
|
|
178
|
-
}, 600);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Wrap every structural change: pushes an undo step. */
|
|
182
|
-
function mutate(fn: () => void) {
|
|
183
|
-
clearTimeout(timer);
|
|
184
|
-
pending = false;
|
|
185
|
-
past.push(take());
|
|
186
|
-
if (past.length > 300) past.shift();
|
|
187
|
-
future = [];
|
|
188
|
-
fn();
|
|
189
|
-
last = take();
|
|
190
|
-
}
|
|
181
|
+
const history = new History<Snap>(take, restore);
|
|
182
|
+
const typed = () => history.typed();
|
|
183
|
+
const mutate = (fn: () => void) => history.mutate(fn);
|
|
184
|
+
const forget = () => history.forget();
|
|
185
|
+
const undo = () => history.undo();
|
|
186
|
+
const redo = () => history.redo();
|
|
191
187
|
|
|
192
188
|
function restore(s: Snap) {
|
|
193
|
-
|
|
189
|
+
// fresh objects: the editor changes its blocks in place, the step's copies stay as they are
|
|
190
|
+
value = s.blocks.map((b) => JSON.parse(JSON.stringify(b)) as Block);
|
|
194
191
|
selA = selH = null;
|
|
195
192
|
slash = null;
|
|
196
|
-
last = s;
|
|
197
193
|
if (s.id && idx(s.id) >= 0) focusBlock(s.id, s.off);
|
|
198
194
|
}
|
|
199
|
-
function undo() {
|
|
200
|
-
clearTimeout(timer);
|
|
201
|
-
pending = false;
|
|
202
|
-
const s = past.pop();
|
|
203
|
-
if (!s) return;
|
|
204
|
-
future.push(take());
|
|
205
|
-
restore(s);
|
|
206
|
-
}
|
|
207
|
-
function redo() {
|
|
208
|
-
clearTimeout(timer);
|
|
209
|
-
pending = false;
|
|
210
|
-
const s = future.pop();
|
|
211
|
-
if (!s) return;
|
|
212
|
-
past.push(take());
|
|
213
|
-
restore(s);
|
|
214
|
-
}
|
|
215
195
|
|
|
216
196
|
/* ---------------------------------------------------------- block catalog */
|
|
217
197
|
|
|
@@ -239,7 +219,7 @@
|
|
|
239
219
|
blocks
|
|
240
220
|
.filter((b) => b.type !== 'hr' || allowed.has('hr'))
|
|
241
221
|
.map((b) => {
|
|
242
|
-
if (!allowed.has(b.type)) convert(b, 'p');
|
|
222
|
+
if (b.type !== 'plugin' && !allowed.has(b.type)) convert(b, 'p');
|
|
243
223
|
return b;
|
|
244
224
|
});
|
|
245
225
|
|
|
@@ -317,12 +297,6 @@
|
|
|
317
297
|
};
|
|
318
298
|
}
|
|
319
299
|
|
|
320
|
-
// Keep the highlighted item visible while moving through the menu with the arrow keys.
|
|
321
|
-
$effect(() => {
|
|
322
|
-
if (slash?.index === undefined && turn?.index === undefined) return;
|
|
323
|
-
root.querySelector(':scope > .menu .item.active')?.scrollIntoView({ block: 'nearest' });
|
|
324
|
-
});
|
|
325
|
-
|
|
326
300
|
function applySlash(item: PaletteItem) {
|
|
327
301
|
if (!slash) return;
|
|
328
302
|
const b = value[idx(slash.id)];
|
|
@@ -341,7 +315,7 @@
|
|
|
341
315
|
if (type === 'hr') {
|
|
342
316
|
const i = idx(b.id);
|
|
343
317
|
const nb = newBlock();
|
|
344
|
-
|
|
318
|
+
insertAt(i + 1, nb);
|
|
345
319
|
focusBlock(nb.id, 'start');
|
|
346
320
|
} else focusBlock(b.id, off);
|
|
347
321
|
});
|
|
@@ -395,7 +369,7 @@
|
|
|
395
369
|
b.type = 'hr';
|
|
396
370
|
b.html = '';
|
|
397
371
|
const nb = newBlock();
|
|
398
|
-
|
|
372
|
+
insertAt(i + 1, nb);
|
|
399
373
|
focusBlock(nb.id, 'start');
|
|
400
374
|
});
|
|
401
375
|
return;
|
|
@@ -436,7 +410,7 @@
|
|
|
436
410
|
const nb = newBlock(type, post, { indent: b.indent, ...(type === 'todo' ? { checked: false } : {}) });
|
|
437
411
|
mutate(() => {
|
|
438
412
|
b.html = pre;
|
|
439
|
-
|
|
413
|
+
insertAt(i + 1, nb);
|
|
440
414
|
});
|
|
441
415
|
focusBlock(nb.id, 'start');
|
|
442
416
|
}
|
|
@@ -473,6 +447,11 @@
|
|
|
473
447
|
else if (k === 'k') fmt('link');
|
|
474
448
|
else if (k === '\\') fmt('plain');
|
|
475
449
|
else if (k === 'x' && e.shiftKey) fmt('strikeThrough');
|
|
450
|
+
else if (k === '/') {
|
|
451
|
+
// The turn-into menu of this block, from the text.
|
|
452
|
+
selectOnly(b.id);
|
|
453
|
+
void openTurnByKey();
|
|
454
|
+
}
|
|
476
455
|
else if (k === 'a' && (!ce.textContent || getSelection()?.toString() === ce.textContent)) {
|
|
477
456
|
selA = value[0].id;
|
|
478
457
|
selH = value[value.length - 1].id;
|
|
@@ -481,7 +460,7 @@
|
|
|
481
460
|
} else if (e.shiftKey && (key === 'ArrowUp' || key === 'ArrowDown')) nudge([b.id], key === 'ArrowUp' ? -1 : 1);
|
|
482
461
|
else if (key === 'Enter' && b.type === 'code') {
|
|
483
462
|
const nb = newBlock();
|
|
484
|
-
mutate(() => (
|
|
463
|
+
mutate(() => insertAt(i + 1, nb));
|
|
485
464
|
focusBlock(nb.id, 'start');
|
|
486
465
|
} else handled = false;
|
|
487
466
|
if (handled) return e.preventDefault();
|
|
@@ -497,7 +476,7 @@
|
|
|
497
476
|
if (b.type === 'code') {
|
|
498
477
|
if (e.shiftKey) {
|
|
499
478
|
const nb = newBlock();
|
|
500
|
-
mutate(() => (
|
|
479
|
+
mutate(() => insertAt(i + 1, nb));
|
|
501
480
|
focusBlock(nb.id, 'start');
|
|
502
481
|
} else document.execCommand('insertLineBreak');
|
|
503
482
|
} else if (e.shiftKey) document.execCommand('insertLineBreak');
|
|
@@ -621,21 +600,94 @@
|
|
|
621
600
|
detectSlash(ce, b);
|
|
622
601
|
}
|
|
623
602
|
|
|
603
|
+
/** The editor's own clipboard format: the copied blocks in their stored form, beside their markdown. */
|
|
604
|
+
const CLIP = 'application/x-brickee+json';
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Blocks from the editor's clipboard format, or null when it is missing, of another version or malformed. Each block
|
|
608
|
+
* carries its markdown form as the copying editor wrote it, for an editor that cannot hold the block (`md`).
|
|
609
|
+
*/
|
|
610
|
+
function fromClip(data: string | undefined): (Block & { md?: string })[] | null {
|
|
611
|
+
if (!data) return null;
|
|
612
|
+
try {
|
|
613
|
+
const clip = JSON.parse(data);
|
|
614
|
+
const ok = (s: unknown, depth: number): s is StoredBlock => {
|
|
615
|
+
const b = s as StoredBlock;
|
|
616
|
+
return (
|
|
617
|
+
!!b && typeof b === 'object' && typeof b.type === 'string' && Number.isInteger(b.indent) && b.indent >= 0 && b.indent <= MAX_INDENT &&
|
|
618
|
+
(b.text === undefined || typeof b.text === 'string') &&
|
|
619
|
+
(b.props === undefined || (typeof b.props === 'object' && !Array.isArray(b.props))) &&
|
|
620
|
+
(b.blocks === undefined || (depth === 0 && Array.isArray(b.blocks) && b.blocks.every((x) => ok(x, 1))))
|
|
621
|
+
);
|
|
622
|
+
};
|
|
623
|
+
if (clip?.v !== 1 || !Array.isArray(clip.blocks) || clip.blocks.length > 2000 || !clip.blocks.every((x: unknown) => ok(x, 0))) return null;
|
|
624
|
+
const md: unknown[] = Array.isArray(clip.md) ? clip.md : [];
|
|
625
|
+
return (clip.blocks as StoredBlock[]).map((s, n) => ({ ...fromStored(s), md: typeof md[n] === 'string' ? (md[n] as string) : undefined }));
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Pasted blocks this editor can hold: a plugin block it does not offer (or a unique one it already has) falls back
|
|
633
|
+
* to its markdown form; built-in types outside the palette become text (fit).
|
|
634
|
+
*/
|
|
635
|
+
function admit(blocks: (Block & { md?: string })[]): Block[] {
|
|
636
|
+
const has = new Set(value.filter((b) => b.type === 'plugin').map((b) => b.plugin));
|
|
637
|
+
const offered = plugins.filter((p) => allowed.has(p.type));
|
|
638
|
+
return fit(
|
|
639
|
+
blocks.flatMap(({ md, ...b }): Block[] => {
|
|
640
|
+
if (b.type !== 'plugin') return [b];
|
|
641
|
+
const p = pluginOf(b.plugin);
|
|
642
|
+
if (p && allowed.has(p.type) && !(p.unique && has.has(p.type))) {
|
|
643
|
+
if (p.unique) has.add(p.type);
|
|
644
|
+
return [b];
|
|
645
|
+
}
|
|
646
|
+
return fromMarkdown(md ?? toMarkdown([b], { plugins }), { plugins: offered }).map((x) => ({ ...x, indent: Math.min(MAX_INDENT, x.indent + b.indent) }));
|
|
647
|
+
})
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* What a paste brings, as blocks: our own blocks as they were, otherwise formatted HTML (a web page, Google Docs,
|
|
653
|
+
* Word), otherwise markdown text; a GFM table goes to a plugin that takes tables, if this editor offers one.
|
|
654
|
+
*/
|
|
655
|
+
function pasted(data: DataTransfer | null): Block[] | null {
|
|
656
|
+
const own = fromClip(data?.getData(CLIP));
|
|
657
|
+
if (own) return admit(own);
|
|
658
|
+
const offered = { plugins: plugins.filter((p) => allowed.has(p.type)) };
|
|
659
|
+
const html = data?.getData('text/html');
|
|
660
|
+
if (html) return fit(fromHtml(html, offered));
|
|
661
|
+
const text = data?.getData('text/plain');
|
|
662
|
+
return text == null ? null : fit(fromMarkdown(text.replace(/\r/g, ''), offered));
|
|
663
|
+
}
|
|
664
|
+
|
|
624
665
|
function onPaste(e: ClipboardEvent, b: Block) {
|
|
625
|
-
const
|
|
626
|
-
|
|
666
|
+
const data = e.clipboardData;
|
|
667
|
+
// Files (a copied image, a screenshot) to a plugin that takes them; an empty block makes room for it.
|
|
668
|
+
const files = [...(data?.files ?? [])];
|
|
669
|
+
if (files.length && b.type !== 'code' && fileTakers().length) {
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
const ce = e.currentTarget as HTMLElement;
|
|
672
|
+
void takeFiles(files, idx(b.id) + 1, clean(ce.innerHTML) ? undefined : b);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const text = data?.getData('text/plain');
|
|
676
|
+
const rich = b.type !== 'code' && (data?.types.includes(CLIP) || data?.types.includes('text/html'));
|
|
677
|
+
if (!rich && text == null) return;
|
|
627
678
|
e.preventDefault();
|
|
628
679
|
const ce = e.currentTarget as HTMLElement;
|
|
629
|
-
const norm = text.replace(/\r/g, '');
|
|
680
|
+
const norm = (text ?? '').replace(/\r/g, '');
|
|
630
681
|
|
|
631
|
-
|
|
682
|
+
// Plain text into a code block, or a single line of plain text: typed in as it is.
|
|
683
|
+
if (!rich && (b.type === 'code' || !norm.includes('\n'))) {
|
|
632
684
|
norm.split('\n').forEach((line, n) => {
|
|
633
685
|
if (n) document.execCommand('insertLineBreak');
|
|
634
686
|
if (line) document.execCommand('insertText', false, line);
|
|
635
687
|
});
|
|
636
688
|
return;
|
|
637
689
|
}
|
|
638
|
-
const parsed =
|
|
690
|
+
const parsed = pasted(data) ?? [];
|
|
639
691
|
if (!parsed.length) return;
|
|
640
692
|
const parts = splitAtCaret(ce);
|
|
641
693
|
if (!parts) return;
|
|
@@ -643,28 +695,59 @@
|
|
|
643
695
|
const post = clean(parts[1]);
|
|
644
696
|
const i = idx(b.id);
|
|
645
697
|
|
|
698
|
+
// An empty block takes pasted plugin blocks or dividers in its place.
|
|
699
|
+
if (!pre && !post && atomic(parsed[0])) {
|
|
700
|
+
mutate(() => (value = [...value.slice(0, i), ...parsed, ...value.slice(i + 1)]));
|
|
701
|
+
const last = parsed[parsed.length - 1];
|
|
702
|
+
if (atomic(last)) selectOnly(last.id);
|
|
703
|
+
else focusBlock(last.id, 'end');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
646
706
|
let rest = parsed;
|
|
647
707
|
const first = parsed[0];
|
|
648
708
|
let curHtml = pre;
|
|
649
|
-
if (!pre && first.type !== 'hr') {
|
|
709
|
+
if (!pre && first.type !== 'hr' && first.type !== 'plugin') {
|
|
650
710
|
b.type = first.type;
|
|
651
711
|
b.indent = first.indent;
|
|
652
712
|
if (first.type === 'todo') b.checked = first.checked;
|
|
713
|
+
if (first.props) b.props = first.props;
|
|
714
|
+
else delete b.props;
|
|
653
715
|
curHtml = first.html;
|
|
654
716
|
rest = parsed.slice(1);
|
|
655
717
|
} else if (first.type === 'p') {
|
|
656
718
|
curHtml = pre + first.html;
|
|
657
719
|
rest = parsed.slice(1);
|
|
658
720
|
}
|
|
721
|
+
// Text after the caret goes into the last pasted block, or a new paragraph after a pasted plugin block or divider.
|
|
722
|
+
if (post && rest.length && atomic(rest[rest.length - 1])) rest = [...rest, newBlock('p', '')];
|
|
659
723
|
const target = rest.length ? rest[rest.length - 1] : b;
|
|
660
724
|
const off = textLen(target === b ? curHtml : target.html);
|
|
661
725
|
mutate(() => {
|
|
662
726
|
if (target === b) curHtml += post;
|
|
663
727
|
else target.html += post;
|
|
664
728
|
b.html = curHtml;
|
|
665
|
-
|
|
729
|
+
insertAt(i + 1, ...rest);
|
|
666
730
|
});
|
|
667
|
-
|
|
731
|
+
if (atomic(target)) selectOnly(target.id);
|
|
732
|
+
else focusBlock(target.id, off);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Pasting while whole blocks are selected: the blocks go in after the selection. */
|
|
736
|
+
function onRootPaste(e: ClipboardEvent) {
|
|
737
|
+
if (e.target !== root || readonly || !selH) return;
|
|
738
|
+
const at = Math.max(...value.map((b, n) => (selSet.has(b.id) ? n : -1))) + 1;
|
|
739
|
+
const files = [...(e.clipboardData?.files ?? [])];
|
|
740
|
+
if (files.length && fileTakers().length) {
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
void takeFiles(files, at);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const parsed = pasted(e.clipboardData) ?? [];
|
|
746
|
+
e.preventDefault();
|
|
747
|
+
if (!parsed.length) return;
|
|
748
|
+
mutate(() => insertAt(at, ...parsed));
|
|
749
|
+
selA = parsed[0].id;
|
|
750
|
+
selH = parsed[parsed.length - 1].id;
|
|
668
751
|
}
|
|
669
752
|
|
|
670
753
|
/* ---------------------------------------------------------- inline format */
|
|
@@ -771,8 +854,23 @@
|
|
|
771
854
|
}
|
|
772
855
|
|
|
773
856
|
function onRootKey(e: KeyboardEvent) {
|
|
857
|
+
// Undo and redo pressed inside a plugin's own field (a table cell): this editor's history, not the field's
|
|
858
|
+
// native one (which the next re-render would lose anyway). A nested editor handles its own and prevents this.
|
|
859
|
+
const mod0 = e.metaKey || e.ctrlKey;
|
|
860
|
+
const k0 = e.key.toLowerCase();
|
|
861
|
+
if (mod0 && (k0 === 'z' || k0 === 'y') && !e.defaultPrevented && e.target !== root && !(e.target as Element).closest?.('.ce')) {
|
|
862
|
+
e.preventDefault();
|
|
863
|
+
if (k0 === 'y' || e.shiftKey) redo();
|
|
864
|
+
else undo();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
774
867
|
if (e.target !== root || !selSet.size || !selH) return;
|
|
775
868
|
if (onTurnKey(e)) return;
|
|
869
|
+
// The turn-into menu from the keyboard: '/' or Ctrl+/ (also the menu key, Shift+F10) on selected blocks.
|
|
870
|
+
if (!readonly && (e.key === '/' || e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) {
|
|
871
|
+
e.preventDefault();
|
|
872
|
+
return void openTurnByKey();
|
|
873
|
+
}
|
|
776
874
|
const mod = e.metaKey || e.ctrlKey;
|
|
777
875
|
const key = e.key;
|
|
778
876
|
const h = idx(selH);
|
|
@@ -794,7 +892,7 @@
|
|
|
794
892
|
if (el(b.id)) focusBlock(b.id, 'end');
|
|
795
893
|
else {
|
|
796
894
|
const nb = newBlock();
|
|
797
|
-
mutate(() => (
|
|
895
|
+
mutate(() => insertAt(h + 1, nb));
|
|
798
896
|
focusBlock(nb.id, 'start');
|
|
799
897
|
}
|
|
800
898
|
} else if (key === 'Escape') {
|
|
@@ -818,7 +916,13 @@
|
|
|
818
916
|
function onCopy(e: ClipboardEvent, cut = false) {
|
|
819
917
|
if (!selSet.size) return;
|
|
820
918
|
e.preventDefault();
|
|
821
|
-
|
|
919
|
+
const blocks = value.filter((b) => selSet.has(b.id));
|
|
920
|
+
e.clipboardData?.setData('text/plain', toMarkdown(blocks, { plugins }));
|
|
921
|
+
// The blocks as they are, for pasting into this or another editor (the markdown is for everything else).
|
|
922
|
+
e.clipboardData?.setData(
|
|
923
|
+
CLIP,
|
|
924
|
+
JSON.stringify({ v: 1, blocks: blocks.map((b) => $state.snapshot(toStored(b))), md: blocks.map((b) => toMarkdown([b], { plugins })) })
|
|
925
|
+
);
|
|
822
926
|
if (cut) deleteSelected();
|
|
823
927
|
}
|
|
824
928
|
|
|
@@ -835,19 +939,41 @@
|
|
|
835
939
|
blurActive();
|
|
836
940
|
root.focus({ preventScroll: true });
|
|
837
941
|
} else selectOnly(b.id);
|
|
838
|
-
if (
|
|
839
|
-
|
|
942
|
+
if (menu) openTurn(e.currentTarget as HTMLElement);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** The turn-into menu for the selected blocks, beside `anchor` (a handle, a bubble button). */
|
|
946
|
+
function openTurn(anchor: Element) {
|
|
947
|
+
const r = anchor.getBoundingClientRect();
|
|
840
948
|
const up = r.bottom + 340 > innerHeight && r.top > 340;
|
|
841
949
|
turn = { x: Math.min(r.right + 6, innerWidth - 250), y: up ? r.bottom : r.top, up, index: 0 };
|
|
842
950
|
}
|
|
843
951
|
|
|
952
|
+
/** From the keyboard: the turn-into menu of the selected blocks, beside the handle of the selection's head. */
|
|
953
|
+
async function openTurnByKey() {
|
|
954
|
+
await tick();
|
|
955
|
+
const handle = selH ? root.querySelector(`[data-block="${selH}"] > .gutter .handle`) : null;
|
|
956
|
+
if (handle) openTurn(handle);
|
|
957
|
+
}
|
|
958
|
+
|
|
844
959
|
/* ------------------------------------------------------ properties panel */
|
|
845
960
|
|
|
846
|
-
// Plugin blocks show their tag and fields on their top (PropsBar); a code block
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
961
|
+
// Plugin blocks and code blocks show their tag and fields on their top (PropsBar); read-only, a code block shows its
|
|
962
|
+
// language in its corner.
|
|
963
|
+
// Free text, or a dropdown when the host gives a list (provideCodeLanguages). A stored language missing from the
|
|
964
|
+
// list stays an option, so opening an older document loses nothing.
|
|
965
|
+
const codeLanguages = useCodeLanguages();
|
|
966
|
+
const codeFields = (b: Block): PropField[] => {
|
|
967
|
+
const list = codeLanguages();
|
|
968
|
+
if (!list.length)
|
|
969
|
+
// Only characters an opening fence can carry (```c++, ```objective-c).
|
|
970
|
+
return [{ key: 'lang', label: 'Language', kind: 'text', placeholder: 'e.g. ts, py, css', parse: (s) => s.replace(/[^\w+#.-]/g, '') }];
|
|
971
|
+
const lang = typeof b.props?.lang === 'string' ? b.props.lang : '';
|
|
972
|
+
const options = [{ value: '', label: 'None' }, ...list.map((l) => ({ value: l.value, label: l.label ?? l.value }))];
|
|
973
|
+
if (lang && !list.some((l) => l.value === lang)) options.push({ value: lang, label: lang });
|
|
974
|
+
return [{ key: 'lang', label: 'Language', kind: 'select', options }];
|
|
975
|
+
};
|
|
976
|
+
const codeTag = (b: Block) => [tr('code'), b.props?.lang].filter(Boolean).join(' · ');
|
|
851
977
|
function tagOf(b: Block) {
|
|
852
978
|
const p = pluginOf(b.plugin);
|
|
853
979
|
return p ? p.tag(b.props ?? {}).split(' · ').map((s) => tr(s)).join(' · ') : (b.plugin ?? '');
|
|
@@ -858,19 +984,17 @@
|
|
|
858
984
|
return f.format ? f.format(v) : v == null ? '' : String(v);
|
|
859
985
|
};
|
|
860
986
|
|
|
861
|
-
/**
|
|
862
|
-
|
|
987
|
+
/**
|
|
988
|
+
* A change from a plugin's view (a table cell, a nested editor): an undo step like typing, but only when the value
|
|
989
|
+
* really differs (a nested editor writes its document back when it mounts).
|
|
990
|
+
*/
|
|
991
|
+
function pluginChange(b: Block, key: 'props' | 'blocks', v: Block['props'] | StoredBlock[]) {
|
|
992
|
+
if (JSON.stringify(v) === JSON.stringify(b[key])) return;
|
|
863
993
|
typed();
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
if (next[f.key] === id) delete next[f.key];
|
|
867
|
-
else next[f.key] = id;
|
|
868
|
-
} else {
|
|
869
|
-
const ids = (next[f.key] as string[] | undefined) ?? [];
|
|
870
|
-
next[f.key] = ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id];
|
|
871
|
-
}
|
|
872
|
-
b.props = next;
|
|
994
|
+
if (key === 'props') b.props = v as Block['props'];
|
|
995
|
+
else b.blocks = v as StoredBlock[];
|
|
873
996
|
}
|
|
997
|
+
|
|
874
998
|
/** Sets one property to a value as it is ('' or undefined removes it). */
|
|
875
999
|
function setValue(b: Block, key: string, v: unknown) {
|
|
876
1000
|
typed();
|
|
@@ -901,6 +1025,8 @@
|
|
|
901
1025
|
| { kind: 'type'; item: PaletteItem; type: BlockType; current: boolean; lossy: boolean }
|
|
902
1026
|
| { kind: 'wrap'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
|
|
903
1027
|
| { kind: 'retype'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
|
|
1028
|
+
| { kind: 'up' }
|
|
1029
|
+
| { kind: 'down' }
|
|
904
1030
|
| { kind: 'unwrap' }
|
|
905
1031
|
| { kind: 'delete' };
|
|
906
1032
|
|
|
@@ -945,6 +1071,11 @@
|
|
|
945
1071
|
const lossy = to === 'plain' && sel.some((b) => contentOf(b) === 'text' && formatted(b.html));
|
|
946
1072
|
out.push({ kind: 'type', item, type, current: single?.type === type, lossy });
|
|
947
1073
|
}
|
|
1074
|
+
// Moving without dragging (touch, keyboard).
|
|
1075
|
+
const at = value.findIndex((b) => selSet.has(b.id));
|
|
1076
|
+
const end = value.length - 1 - [...value].reverse().findIndex((b) => selSet.has(b.id));
|
|
1077
|
+
if (at > 0) out.push({ kind: 'up' });
|
|
1078
|
+
if (end < value.length - 1) out.push({ kind: 'down' });
|
|
948
1079
|
if (known && kinds.includes('blocks')) out.push({ kind: 'unwrap' });
|
|
949
1080
|
out.push({ kind: 'delete' });
|
|
950
1081
|
return out;
|
|
@@ -960,6 +1091,7 @@
|
|
|
960
1091
|
if (!opt) return;
|
|
961
1092
|
const sel = selected;
|
|
962
1093
|
if (opt.kind === 'delete') return deleteSelected();
|
|
1094
|
+
if (opt.kind === 'up' || opt.kind === 'down') return nudge([...selSet], opt.kind === 'up' ? -1 : 1);
|
|
963
1095
|
if (opt.kind === 'unwrap') return unwrap(sel);
|
|
964
1096
|
if (opt.kind === 'wrap') return wrap(sel, opt.plugin);
|
|
965
1097
|
if (opt.kind === 'retype') return mutate(() => sel.forEach((b) => toPlugin(b, opt.plugin)));
|
|
@@ -1023,10 +1155,35 @@
|
|
|
1023
1155
|
selA = selH = null;
|
|
1024
1156
|
}
|
|
1025
1157
|
|
|
1158
|
+
// Looks shared by several elements (Tailwind; whole class names, so Tailwind finds them).
|
|
1159
|
+
/** The block actions in the bubble: inverted, the theme's text colour as the background. */
|
|
1160
|
+
const BUBBLE =
|
|
1161
|
+
'flex h-[26px] w-7 cursor-pointer items-center justify-center border-0 bg-surface-contrast p-0 text-surface shadow-[0_4px_12px_rgb(0_0_0/0.2)] first:rounded-l-md first:pl-0.5 last:rounded-r-md last:pr-0.5 hover:bg-[color-mix(in_srgb,var(--color-surface-contrast,#1d1d1f)_82%,var(--color-surface,#fff))]';
|
|
1162
|
+
const BUBBLE_DELETE =
|
|
1163
|
+
'flex h-[26px] w-7 cursor-pointer items-center justify-center border-0 bg-surface-contrast p-0 text-surface shadow-[0_4px_12px_rgb(0_0_0/0.2)] first:rounded-l-md first:pl-0.5 last:rounded-r-md last:pr-0.5 hover:bg-[#c62828]';
|
|
1164
|
+
/** A block's small label (its type, a code block's language). */
|
|
1165
|
+
const TAG =
|
|
1166
|
+
'max-w-[18em] overflow-hidden rounded-[5px] px-1.5 py-px font-mono text-[11px] leading-[1.5] font-medium text-ellipsis whitespace-nowrap text-muted-contrast select-none';
|
|
1167
|
+
|
|
1026
1168
|
const optionKey = (o: TurnOption) => ('item' in o ? o.kind + ':' + o.item.id : o.kind);
|
|
1169
|
+
/** The actions after the types: a line above the first of them. */
|
|
1170
|
+
const ACTIONS = new Set<TurnOption['kind']>(['up', 'down', 'unwrap', 'delete']);
|
|
1171
|
+
/** The turn-into options as menu rows. */
|
|
1172
|
+
const turnRows = $derived(
|
|
1173
|
+
turnOptions.map((o, n): MenuRow => ({
|
|
1174
|
+
key: optionKey(o),
|
|
1175
|
+
...optionView(o),
|
|
1176
|
+
warn: 'lossy' in o && o.lossy,
|
|
1177
|
+
danger: o.kind === 'delete',
|
|
1178
|
+
current: 'current' in o && o.current,
|
|
1179
|
+
gap: ACTIONS.has(o.kind) && n > 0 && !ACTIONS.has(turnOptions[n - 1].kind)
|
|
1180
|
+
}))
|
|
1181
|
+
);
|
|
1027
1182
|
function optionView(o: TurnOption): { icon: Icon; label: string; hint: string } {
|
|
1028
1183
|
const n = selected.length;
|
|
1029
1184
|
if (o.kind === 'delete') return { icon: Trash2, label: tr('Delete'), hint: n > 1 ? tr('{n} blocks', { n }) : tr('Remove this block') };
|
|
1185
|
+
if (o.kind === 'up') return { icon: ArrowUp, label: tr('Move up'), hint: 'Ctrl+Shift+↑' };
|
|
1186
|
+
if (o.kind === 'down') return { icon: ArrowDown, label: tr('Move down'), hint: 'Ctrl+Shift+↓' };
|
|
1030
1187
|
if (o.kind === 'unwrap') return { icon: Ungroup, label: tr('Unwrap'), hint: tr('Move the inner blocks out') };
|
|
1031
1188
|
if (o.kind === 'wrap') return { icon: o.item.icon, label: o.item.label, hint: n > 1 ? tr('Put {n} blocks inside', { n }) : tr('Put inside') };
|
|
1032
1189
|
if (o.kind === 'type' && o.lossy) return { icon: o.item.icon, label: o.item.label, hint: tr('Loses formatting') };
|
|
@@ -1047,117 +1204,76 @@
|
|
|
1047
1204
|
|
|
1048
1205
|
/* ------------------------------------------------- selection across blocks */
|
|
1049
1206
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
sweepFrom = t;
|
|
1061
|
-
addEventListener('mousemove', sweep);
|
|
1062
|
-
addEventListener('mouseup', endSweep, { once: true });
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
function sweep(e: MouseEvent) {
|
|
1066
|
-
const from = ownRow(sweepFrom);
|
|
1067
|
-
const here = document.elementFromPoint(e.clientX, e.clientY);
|
|
1068
|
-
if (!from || !here) return;
|
|
1069
|
-
const row = root.contains(here) ? ownRow(here) : null;
|
|
1070
|
-
// Left this editor, or came back to the starting block: back to plain text selection.
|
|
1071
|
-
if (!root.contains(here) || row === from) {
|
|
1072
|
-
if (sweeping) {
|
|
1073
|
-
sweeping = false;
|
|
1074
|
-
selA = selH = null;
|
|
1075
|
-
}
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
if (!row) return;
|
|
1079
|
-
if (!sweeping) {
|
|
1080
|
-
sweeping = true;
|
|
1207
|
+
const sweep = createSweep({
|
|
1208
|
+
root: () => root,
|
|
1209
|
+
ownRow,
|
|
1210
|
+
readonly: () => readonly,
|
|
1211
|
+
select: (a, h) => {
|
|
1212
|
+
selA = a;
|
|
1213
|
+
selH = h;
|
|
1214
|
+
},
|
|
1215
|
+
clear: () => (selA = selH = null),
|
|
1216
|
+
focusRoot: () => {
|
|
1081
1217
|
blurActive();
|
|
1082
1218
|
root.focus({ preventScroll: true });
|
|
1083
1219
|
}
|
|
1084
|
-
|
|
1085
|
-
selA = from.dataset.block!;
|
|
1086
|
-
selH = row.dataset.block!;
|
|
1087
|
-
}
|
|
1220
|
+
});
|
|
1088
1221
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1222
|
+
// Removed in the middle of a sweep or of typing: no listener or timer is left behind. (An effect's cleanup, not
|
|
1223
|
+
// onDestroy: that also runs after server-side rendering, where there is no window.)
|
|
1224
|
+
$effect(() => () => {
|
|
1225
|
+
sweep.end();
|
|
1226
|
+
history.dispose();
|
|
1227
|
+
});
|
|
1094
1228
|
|
|
1095
1229
|
/* ---------------------------------------------------------------- dragging */
|
|
1096
1230
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
hub.target = null;
|
|
1108
|
-
if (!e.dataTransfer) return;
|
|
1109
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
1110
|
-
e.dataTransfer.setData('text/plain', ' ');
|
|
1111
|
-
const row = (e.currentTarget as HTMLElement).closest('.row');
|
|
1112
|
-
if (row) e.dataTransfer.setDragImage(row, 0, 0);
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/** Can the dragged blocks land in this editor? Not inside themselves, and only plugins this editor offers. */
|
|
1116
|
-
function accepts(src: DragSource) {
|
|
1117
|
-
if (src.editor === token) return true;
|
|
1118
|
-
if (src.rows.some((r) => r.contains(root))) return false;
|
|
1119
|
-
return src.payload.every((s) => isMd(s) || (allowed.has(s.type) && !!pluginOf(s.type)));
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function onDragOver(e: DragEvent) {
|
|
1123
|
-
// A nested editor deeper in the tree already took this event.
|
|
1124
|
-
if (!hub.src || e.defaultPrevented) return;
|
|
1125
|
-
const row = ownRow(e.target as Element);
|
|
1126
|
-
if (!row || !accepts(hub.src)) return;
|
|
1127
|
-
e.preventDefault();
|
|
1128
|
-
const r = row.getBoundingClientRect();
|
|
1129
|
-
hub.target = { editor: token, over: row.dataset.block!, after: e.clientY > r.top + r.height / 2 };
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
function onDrop(e: DragEvent) {
|
|
1133
|
-
const src = hub.src;
|
|
1134
|
-
const at = hub.target;
|
|
1135
|
-
if (!src || at?.editor !== token) return;
|
|
1136
|
-
e.preventDefault();
|
|
1137
|
-
hub.src = hub.target = null;
|
|
1138
|
-
const { over, after } = at;
|
|
1139
|
-
if (src.ids.includes(over)) return;
|
|
1140
|
-
if (src.editor === token) {
|
|
1231
|
+
const drag = createDrag({
|
|
1232
|
+
token,
|
|
1233
|
+
hub,
|
|
1234
|
+
root: () => root,
|
|
1235
|
+
ownRow,
|
|
1236
|
+
moving: (id) => (selSet.has(id) ? value.filter((x) => selSet.has(x.id)).map((x) => x.id) : [id]),
|
|
1237
|
+
payload: (ids) => value.filter((x) => ids.includes(x.id)).map((x) => $state.snapshot(toStored(x)) as StoredBlock),
|
|
1238
|
+
remove: (ids) => mutate(() => (value = value.filter((x) => !ids.includes(x.id)).concat(value.length === ids.length ? [newBlock()] : []))),
|
|
1239
|
+
holds: (payload) => payload.every((s) => isMd(s) || (allowed.has(s.type) && !!pluginOf(s.type))),
|
|
1240
|
+
move: (ids, over, after) =>
|
|
1141
1241
|
mutate(() => {
|
|
1142
|
-
const moving = value.filter((b) =>
|
|
1143
|
-
const rest = value.filter((b) => !
|
|
1242
|
+
const moving = value.filter((b) => ids.includes(b.id));
|
|
1243
|
+
const rest = value.filter((b) => !ids.includes(b.id));
|
|
1144
1244
|
const t = rest.findIndex((b) => b.id === over) + (after ? 1 : 0);
|
|
1145
1245
|
value = [...rest.slice(0, t), ...moving, ...rest.slice(t)];
|
|
1146
|
-
})
|
|
1147
|
-
|
|
1246
|
+
}),
|
|
1247
|
+
insert: (payload, over, after) => {
|
|
1248
|
+
const incoming = fit(payload.map(fromStored));
|
|
1249
|
+
mutate(() => insertAt(idx(over) + (after ? 1 : 0), ...incoming));
|
|
1250
|
+
},
|
|
1251
|
+
takesFiles: () => fileTakers().length > 0,
|
|
1252
|
+
files: (files, over, after) => void takeFiles(files, idx(over) + (after ? 1 : 0))
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
/** The plugins this editor offers that take files. */
|
|
1256
|
+
const fileTakers = () => plugins.filter((p) => p.fromFiles && allowed.has(p.type));
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Files pasted or dropped: the first plugin that takes them gets one block, at `at` (or in place of the empty block
|
|
1260
|
+
* `empty`). False when none takes them.
|
|
1261
|
+
*/
|
|
1262
|
+
async function takeFiles(files: File[], at: number, empty?: Block): Promise<boolean> {
|
|
1263
|
+
for (const p of fileTakers()) {
|
|
1264
|
+
const props = await p.fromFiles!(files);
|
|
1265
|
+
if (!props) continue;
|
|
1266
|
+
const pb = newBlock('plugin', '', { plugin: p.type, indent: empty?.indent ?? 0, props });
|
|
1267
|
+
fresh.add(pb.id);
|
|
1268
|
+
const i = empty ? idx(empty.id) : -1;
|
|
1269
|
+
mutate(() => (i >= 0 ? (value = [...value.slice(0, i), pb, ...value.slice(i + 1)]) : insertAt(Math.min(at, value.length), pb)));
|
|
1270
|
+
await tick();
|
|
1271
|
+
selectOnly(pb.id);
|
|
1272
|
+
return true;
|
|
1148
1273
|
}
|
|
1149
|
-
|
|
1150
|
-
const incoming = fit(src.payload.map(fromStored));
|
|
1151
|
-
mutate(() => {
|
|
1152
|
-
const t = idx(over) + (after ? 1 : 0);
|
|
1153
|
-
value = [...value.slice(0, t), ...incoming, ...value.slice(t)];
|
|
1154
|
-
});
|
|
1155
|
-
src.remove();
|
|
1274
|
+
return false;
|
|
1156
1275
|
}
|
|
1157
1276
|
|
|
1158
|
-
const dropMark = (id: string, after: boolean) =>
|
|
1159
|
-
hub.target?.editor === token && hub.target.over === id && hub.target.after === after && !hub.src?.ids.includes(id);
|
|
1160
|
-
|
|
1161
1277
|
/* ------------------------------------------------------------------- misc */
|
|
1162
1278
|
|
|
1163
1279
|
/** The + button: a new paragraph below (or this one, if it is an empty paragraph) with the block menu open. */
|
|
@@ -1166,7 +1282,7 @@
|
|
|
1166
1282
|
if (!(b.type === 'p' && !b.html)) {
|
|
1167
1283
|
target = newBlock('p', '', { indent: b.indent });
|
|
1168
1284
|
const i = idx(b.id);
|
|
1169
|
-
mutate(() => (
|
|
1285
|
+
mutate(() => insertAt(i + 1, target));
|
|
1170
1286
|
}
|
|
1171
1287
|
await tick();
|
|
1172
1288
|
const ce = el(target.id);
|
|
@@ -1199,28 +1315,30 @@
|
|
|
1199
1315
|
<div
|
|
1200
1316
|
class="editor"
|
|
1201
1317
|
class:nested
|
|
1202
|
-
class:sweeping
|
|
1318
|
+
class:sweeping={sweep.sweeping}
|
|
1203
1319
|
role="presentation"
|
|
1204
1320
|
bind:this={root}
|
|
1205
1321
|
tabindex="-1"
|
|
1206
1322
|
onkeydown={onRootKey}
|
|
1207
1323
|
oncopy={(e) => onCopy(e)}
|
|
1208
1324
|
oncut={(e) => onCopy(e, true)}
|
|
1325
|
+
onpaste={onRootPaste}
|
|
1209
1326
|
onmousedown={(e) => {
|
|
1210
1327
|
if (selA && !(e.target as HTMLElement).closest('.grab')) selA = selH = null;
|
|
1211
|
-
|
|
1328
|
+
sweep.start(e);
|
|
1212
1329
|
}}
|
|
1213
|
-
ondragover={
|
|
1214
|
-
ondrop={
|
|
1215
|
-
ondragend={
|
|
1330
|
+
ondragover={drag.over}
|
|
1331
|
+
ondrop={drag.drop}
|
|
1332
|
+
ondragend={drag.end}
|
|
1333
|
+
ondragleave={drag.leave}
|
|
1216
1334
|
>
|
|
1217
1335
|
{#each value as b, i (b.id)}
|
|
1218
1336
|
<div
|
|
1219
1337
|
class="row"
|
|
1220
1338
|
class:selected={selSet.has(b.id)}
|
|
1221
|
-
class:dragging={
|
|
1222
|
-
class:drop-before={
|
|
1223
|
-
class:drop-after={
|
|
1339
|
+
class:dragging={drag.moves(b.id)}
|
|
1340
|
+
class:drop-before={drag.mark(b.id, false)}
|
|
1341
|
+
class:drop-after={drag.mark(b.id, true)}
|
|
1224
1342
|
data-block={b.id}
|
|
1225
1343
|
data-type={b.type}
|
|
1226
1344
|
style:--indent={b.indent}
|
|
@@ -1231,17 +1349,20 @@
|
|
|
1231
1349
|
middle. Drag to move, click for the turn-into menu. Hovering it shows a small dark bubble on its right
|
|
1232
1350
|
with the block actions (add a block below, turn into, delete). -->
|
|
1233
1351
|
<div class="grab">
|
|
1234
|
-
|
|
1235
|
-
|
|
1352
|
+
<button
|
|
1353
|
+
type="button"
|
|
1236
1354
|
class="handle"
|
|
1237
1355
|
draggable="true"
|
|
1356
|
+
tabindex="-1"
|
|
1238
1357
|
title={tr('Drag to move, click to select')}
|
|
1239
|
-
|
|
1240
|
-
|
|
1358
|
+
aria-label={tr('Select the block')}
|
|
1359
|
+
ondragstart={(e) => drag.start(e, b.id)}
|
|
1360
|
+
onclick={(e) => onHandleClick(e, b)}><span class="grip"><GripVertical size={16} /></span></button
|
|
1241
1361
|
>
|
|
1242
1362
|
<div class="bubble" role="toolbar" aria-label={tr('Block actions')}>
|
|
1243
1363
|
<button
|
|
1244
1364
|
type="button"
|
|
1365
|
+
class={BUBBLE}
|
|
1245
1366
|
title={tr('Add a block below')}
|
|
1246
1367
|
aria-label={tr('Add a block below')}
|
|
1247
1368
|
onmousedown={(e) => e.preventDefault()}
|
|
@@ -1249,6 +1370,7 @@
|
|
|
1249
1370
|
>
|
|
1250
1371
|
<button
|
|
1251
1372
|
type="button"
|
|
1373
|
+
class={BUBBLE}
|
|
1252
1374
|
title={tr('Turn into')}
|
|
1253
1375
|
aria-label={tr('Turn into')}
|
|
1254
1376
|
onmousedown={(e) => e.preventDefault()}
|
|
@@ -1256,7 +1378,7 @@
|
|
|
1256
1378
|
>
|
|
1257
1379
|
<button
|
|
1258
1380
|
type="button"
|
|
1259
|
-
class=
|
|
1381
|
+
class={BUBBLE_DELETE}
|
|
1260
1382
|
title={tr('Delete')}
|
|
1261
1383
|
aria-label={tr('Delete')}
|
|
1262
1384
|
onmousedown={(e) => e.preventDefault()}
|
|
@@ -1268,18 +1390,18 @@
|
|
|
1268
1390
|
{/if}
|
|
1269
1391
|
<div class="body t-{b.type}">
|
|
1270
1392
|
{#if b.type === 'hr'}
|
|
1271
|
-
<hr />
|
|
1393
|
+
<hr class="m-0 flex-1 border-0 border-t border-frame" />
|
|
1272
1394
|
{:else if b.type === 'plugin'}
|
|
1273
1395
|
{@const plugin = pluginOf(b.plugin)}
|
|
1274
1396
|
{#if plugin}
|
|
1275
|
-
<div class="
|
|
1397
|
+
<div class="my-[0.35em] min-w-0 flex-1">
|
|
1276
1398
|
{#snippet propsBar()}
|
|
1277
|
-
<PropsBar block={b} tag={tagOf(b)} fields={plugin.fields ?? []}
|
|
1399
|
+
<PropsBar block={b} tag={tagOf(b)} fields={plugin.fields ?? []} {tr} {setProp} {setValue} open={fresh.has(b.id)} />
|
|
1278
1400
|
{/snippet}
|
|
1279
1401
|
<PluginHost
|
|
1280
1402
|
{plugin}
|
|
1281
|
-
bind:props={() => b.props ?? {}, (p) => (b
|
|
1282
|
-
bind:blocks={() => b.blocks ?? [], (v) => (b
|
|
1403
|
+
bind:props={() => b.props ?? {}, (p) => pluginChange(b, 'props', p)}
|
|
1404
|
+
bind:blocks={() => b.blocks ?? [], (v) => pluginChange(b, 'blocks', v)}
|
|
1283
1405
|
{readonly}
|
|
1284
1406
|
{palette}
|
|
1285
1407
|
{plugins}
|
|
@@ -1287,18 +1409,19 @@
|
|
|
1287
1409
|
/>
|
|
1288
1410
|
</div>
|
|
1289
1411
|
{:else}
|
|
1290
|
-
<div class="
|
|
1412
|
+
<div class="flex-1 rounded-lg border border-dashed border-frame px-[0.9em] py-[0.6em] text-[0.85em] text-muted-contrast">{tr('Unknown block: {type}', { type: b.plugin ?? '' })}</div>
|
|
1291
1413
|
{/if}
|
|
1292
1414
|
{:else}
|
|
1293
1415
|
{#if b.type === 'ul'}
|
|
1294
|
-
<span class="
|
|
1416
|
+
<span class="w-[1.3em] flex-none text-center text-surface-contrast select-none" aria-hidden="true">{['•', '◦', '▪'][b.indent % 3]}</span>
|
|
1295
1417
|
{:else if b.type === 'ol'}
|
|
1296
|
-
<span class="
|
|
1418
|
+
<span class="w-[1.6em] flex-none text-right text-surface-contrast tabular-nums select-none" aria-hidden="true">{nums[b.id]}.</span>
|
|
1297
1419
|
{:else if b.type === 'todo'}
|
|
1298
1420
|
<button
|
|
1299
1421
|
type="button"
|
|
1300
|
-
class="
|
|
1301
|
-
|
|
1422
|
+
class="mt-[0.3em] mr-[0.2em] ml-[0.1em] grid size-[1.1em] flex-none cursor-pointer place-items-center rounded border-[1.6px] p-0 text-white {b.checked
|
|
1423
|
+
? 'border-(--accent) bg-(--accent)'
|
|
1424
|
+
: 'border-muted-contrast bg-transparent'}"
|
|
1302
1425
|
role="checkbox"
|
|
1303
1426
|
aria-checked={!!b.checked}
|
|
1304
1427
|
aria-label={tr('Done')}
|
|
@@ -1307,6 +1430,10 @@
|
|
|
1307
1430
|
onclick={() => toggleTodo(b)}
|
|
1308
1431
|
>{#if b.checked}<Check size={12} strokeWidth={3} />{/if}</button
|
|
1309
1432
|
>
|
|
1433
|
+
{:else if b.type === 'code' && !readonly}
|
|
1434
|
+
<div class="basis-full">
|
|
1435
|
+
<PropsBar block={b} tag={codeTag(b)} fields={codeFields(b)} {tr} {setProp} {setValue} />
|
|
1436
|
+
</div>
|
|
1310
1437
|
{/if}
|
|
1311
1438
|
<div
|
|
1312
1439
|
class="ce"
|
|
@@ -1332,637 +1459,356 @@
|
|
|
1332
1459
|
if (focusedId === b.id) focusedId = null;
|
|
1333
1460
|
slash = null;
|
|
1334
1461
|
}}
|
|
1335
|
-
|
|
1462
|
+
>{#if ssr}{@html b.html}{/if}</div>
|
|
1336
1463
|
{/if}
|
|
1337
|
-
{#if b.type === 'code'}
|
|
1338
|
-
<!-- the language of a code block, in its corner -->
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
{
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
placeholder={tr('language')}
|
|
1345
|
-
aria-label={tr('Language')}
|
|
1346
|
-
size="6"
|
|
1347
|
-
bind:value={() => fieldText(b, CODE_FIELDS[0]), (v: string) => setProp(b, CODE_FIELDS[0], v)}
|
|
1348
|
-
/>
|
|
1349
|
-
{:else if b.props?.lang}<span class="tag">{b.props.lang}</span>{/if}
|
|
1350
|
-
</label>
|
|
1464
|
+
{#if b.type === 'code' && readonly}
|
|
1465
|
+
<!-- read-only: the language of a code block, in its corner -->
|
|
1466
|
+
<!-- `badge`: a hook for the code's room beside it -->
|
|
1467
|
+
<div class="badge absolute top-[calc(0.15em+5px)] right-1.5 z-[2] flex items-center gap-0.5">
|
|
1468
|
+
<span class={TAG}>{tr('code')}</span>
|
|
1469
|
+
{#if b.props?.lang}<span class={TAG}>{b.props.lang}</span>{/if}
|
|
1470
|
+
</div>
|
|
1351
1471
|
{:else if b.type === 'plugin' && (readonly || !pluginOf(b.plugin)?.fields?.length)}
|
|
1352
|
-
|
|
1472
|
+
<!-- readable over a map or an image -->
|
|
1473
|
+
<div class="absolute top-[calc(0.35em+5px)] right-1.5 z-[2] flex items-center gap-0.5 rounded-md bg-[color-mix(in_srgb,var(--color-surface,#fff)_92%,transparent)]">
|
|
1474
|
+
<span class={TAG}>{tagOf(b)}</span>
|
|
1475
|
+
</div>
|
|
1353
1476
|
{/if}
|
|
1354
1477
|
</div>
|
|
1355
1478
|
</div>
|
|
1356
1479
|
{/each}
|
|
1357
1480
|
|
|
1358
1481
|
{#if !readonly}
|
|
1359
|
-
<button type="button" class="tail" aria-label={tr('New block at the end')} onclick={tailClick}></button>
|
|
1482
|
+
<button type="button" class="tail block w-full cursor-text border-0 bg-transparent {nested ? 'min-h-3' : 'min-h-20'}" aria-label={tr('New block at the end')} onclick={tailClick}></button>
|
|
1360
1483
|
{/if}
|
|
1361
1484
|
|
|
1362
1485
|
{#if toolbar}
|
|
1363
|
-
<
|
|
1364
|
-
{#each tools as t (t.cmd)}
|
|
1365
|
-
<button
|
|
1366
|
-
type="button"
|
|
1367
|
-
title={tr(t.title)}
|
|
1368
|
-
aria-label={tr(t.title)}
|
|
1369
|
-
onmousedown={(e) => e.preventDefault()}
|
|
1370
|
-
onclick={() => fmt(t.cmd)}><t.icon size={16} /></button
|
|
1371
|
-
>
|
|
1372
|
-
{/each}
|
|
1373
|
-
</div>
|
|
1486
|
+
<FormatBar x={toolbar.x} y={toolbar.y} tools={tools.map((t) => ({ ...t, title: tr(t.title) }))} label={tr('Formatting')} onpick={(cmd) => fmt(cmd as Cmd)} />
|
|
1374
1487
|
{/if}
|
|
1375
1488
|
|
|
1376
1489
|
{#if turn}
|
|
1377
|
-
<
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
{
|
|
1386
|
-
|
|
1387
|
-
{
|
|
1388
|
-
|
|
1389
|
-
{@const v = optionView(o)}
|
|
1390
|
-
{#if (o.kind === 'unwrap' || o.kind === 'delete') && n > 0 && turnOptions[n - 1].kind !== 'unwrap'}
|
|
1391
|
-
<div class="sep"></div>
|
|
1392
|
-
{/if}
|
|
1393
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
1394
|
-
<div
|
|
1395
|
-
class="item"
|
|
1396
|
-
class:danger={o.kind === 'delete'}
|
|
1397
|
-
class:active={n === turn.index}
|
|
1398
|
-
role="option"
|
|
1399
|
-
tabindex="-1"
|
|
1400
|
-
aria-selected={n === turn.index}
|
|
1401
|
-
onmousedown={(e) => {
|
|
1402
|
-
e.preventDefault();
|
|
1403
|
-
applyTurn(n);
|
|
1404
|
-
}}
|
|
1405
|
-
onmouseenter={() => turn && (turn.index = n)}
|
|
1406
|
-
>
|
|
1407
|
-
<span class="icon"><v.icon size={14} /></span>
|
|
1408
|
-
<span class="txt"><b>{v.label}</b><small class:warn={'lossy' in o && o.lossy}>{v.hint}</small></span>
|
|
1409
|
-
{#if 'current' in o && o.current}<span class="current"><Check size={16} /></span>{/if}
|
|
1410
|
-
</div>
|
|
1411
|
-
{/each}
|
|
1412
|
-
</div>
|
|
1490
|
+
<BlockMenu
|
|
1491
|
+
turn
|
|
1492
|
+
rows={turnRows}
|
|
1493
|
+
index={turn.index}
|
|
1494
|
+
x={turn.x}
|
|
1495
|
+
y={turn.y}
|
|
1496
|
+
up={turn.up}
|
|
1497
|
+
caption={turnOptions[0] && !ACTIONS.has(turnOptions[0].kind) ? tr('Turn into') : undefined}
|
|
1498
|
+
label={tr('Turn into')}
|
|
1499
|
+
onpick={applyTurn}
|
|
1500
|
+
onhover={(n) => turn && (turn.index = n)}
|
|
1501
|
+
/>
|
|
1413
1502
|
{/if}
|
|
1414
1503
|
|
|
1415
1504
|
{#if slash && filtered.length}
|
|
1416
|
-
<
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
{
|
|
1425
|
-
|
|
1426
|
-
<div
|
|
1427
|
-
class="item"
|
|
1428
|
-
class:active={n === slash.index}
|
|
1429
|
-
role="option"
|
|
1430
|
-
tabindex="-1"
|
|
1431
|
-
aria-selected={n === slash.index}
|
|
1432
|
-
onmousedown={(e) => {
|
|
1433
|
-
e.preventDefault();
|
|
1434
|
-
applySlash(it);
|
|
1435
|
-
}}
|
|
1436
|
-
onmouseenter={() => slash && (slash.index = n)}
|
|
1437
|
-
>
|
|
1438
|
-
<span class="icon"><it.icon size={14} /></span>
|
|
1439
|
-
<span class="txt"><b>{it.label}</b><small>{it.hint}</small></span>
|
|
1440
|
-
</div>
|
|
1441
|
-
{/each}
|
|
1442
|
-
</div>
|
|
1505
|
+
<BlockMenu
|
|
1506
|
+
rows={filtered.map((it) => ({ key: it.id, icon: it.icon, label: it.label, hint: it.hint }))}
|
|
1507
|
+
index={slash.index}
|
|
1508
|
+
x={slash.x}
|
|
1509
|
+
y={slash.y}
|
|
1510
|
+
up={slash.up}
|
|
1511
|
+
label={tr('Block type')}
|
|
1512
|
+
onpick={(n) => applySlash(filtered[n])}
|
|
1513
|
+
onhover={(n) => slash && (slash.index = n)}
|
|
1514
|
+
/>
|
|
1443
1515
|
{/if}
|
|
1444
1516
|
</div>
|
|
1445
1517
|
|
|
1446
1518
|
<!-- the popups of this editor and the ones nested in it -->
|
|
1447
|
-
{#if !nested}<PopupContainer
|
|
1448
|
-
|
|
1449
|
-
<style
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
letter-spacing: 0.04em;
|
|
1744
|
-
text-transform: uppercase;
|
|
1745
|
-
}
|
|
1746
|
-
.t-h5,
|
|
1747
|
-
.t-h6 {
|
|
1748
|
-
padding-top: 0.2em;
|
|
1749
|
-
}
|
|
1750
|
-
.t-quote .ce {
|
|
1751
|
-
border-left: 3px solid var(--fg);
|
|
1752
|
-
padding-left: 0.9em;
|
|
1753
|
-
font-style: italic;
|
|
1754
|
-
}
|
|
1755
|
-
.t-code,
|
|
1756
|
-
.t-plugin {
|
|
1757
|
-
position: relative;
|
|
1758
|
-
}
|
|
1759
|
-
.badge {
|
|
1760
|
-
position: absolute;
|
|
1761
|
-
top: calc(0.15em + 5px);
|
|
1762
|
-
right: 6px;
|
|
1763
|
-
display: flex;
|
|
1764
|
-
align-items: center;
|
|
1765
|
-
gap: 2px;
|
|
1766
|
-
z-index: 2;
|
|
1767
|
-
}
|
|
1768
|
-
.t-plugin > .badge {
|
|
1769
|
-
top: calc(0.35em + 5px);
|
|
1770
|
-
/* Readable over a map or an image. */
|
|
1771
|
-
background: rgba(255, 255, 255, 0.92);
|
|
1772
|
-
border-radius: 6px;
|
|
1773
|
-
}
|
|
1774
|
-
.tag {
|
|
1775
|
-
max-width: 18em;
|
|
1776
|
-
overflow: hidden;
|
|
1777
|
-
text-overflow: ellipsis;
|
|
1778
|
-
white-space: nowrap;
|
|
1779
|
-
padding: 1px 6px;
|
|
1780
|
-
border-radius: 5px;
|
|
1781
|
-
color: var(--muted);
|
|
1782
|
-
font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1783
|
-
user-select: none;
|
|
1784
|
-
}
|
|
1785
|
-
.lang {
|
|
1786
|
-
width: 7em;
|
|
1787
|
-
padding: 1px 6px;
|
|
1788
|
-
border: 1px solid transparent;
|
|
1789
|
-
border-radius: 5px;
|
|
1790
|
-
background: none;
|
|
1791
|
-
color: var(--fg);
|
|
1792
|
-
font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1793
|
-
outline: none;
|
|
1794
|
-
}
|
|
1795
|
-
.lang:hover,
|
|
1796
|
-
.lang:focus {
|
|
1797
|
-
border-color: var(--border);
|
|
1798
|
-
background: var(--bg, #fff);
|
|
1799
|
-
}
|
|
1800
|
-
.t-code .ce {
|
|
1801
|
-
background: var(--code-bg);
|
|
1802
|
-
border-radius: 8px;
|
|
1803
|
-
padding: 0.7em 7.5em 0.7em 0.9em;
|
|
1804
|
-
font: 0.88em/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1805
|
-
white-space: pre-wrap;
|
|
1806
|
-
}
|
|
1807
|
-
.t-hr {
|
|
1808
|
-
padding: 0.6em 0;
|
|
1809
|
-
}
|
|
1810
|
-
hr {
|
|
1811
|
-
flex: 1;
|
|
1812
|
-
border: 0;
|
|
1813
|
-
border-top: 1px solid var(--border);
|
|
1814
|
-
margin: 0;
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
.marker {
|
|
1818
|
-
flex: none;
|
|
1819
|
-
width: 1.3em;
|
|
1820
|
-
text-align: center;
|
|
1821
|
-
color: var(--fg);
|
|
1822
|
-
user-select: none;
|
|
1823
|
-
}
|
|
1824
|
-
.marker.num {
|
|
1825
|
-
width: 1.6em;
|
|
1826
|
-
text-align: right;
|
|
1827
|
-
font-variant-numeric: tabular-nums;
|
|
1828
|
-
}
|
|
1829
|
-
.check {
|
|
1830
|
-
flex: none;
|
|
1831
|
-
width: 1.1em;
|
|
1832
|
-
height: 1.1em;
|
|
1833
|
-
margin: 0.3em 0.2em 0 0.1em;
|
|
1834
|
-
border: 1.6px solid var(--muted);
|
|
1835
|
-
border-radius: 4px;
|
|
1836
|
-
background: transparent;
|
|
1837
|
-
color: #fff;
|
|
1838
|
-
display: grid;
|
|
1839
|
-
place-items: center;
|
|
1840
|
-
cursor: pointer;
|
|
1841
|
-
padding: 0;
|
|
1842
|
-
}
|
|
1843
|
-
.check.done {
|
|
1844
|
-
background: var(--accent);
|
|
1845
|
-
border-color: var(--accent);
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
.tail {
|
|
1849
|
-
display: block;
|
|
1850
|
-
width: 100%;
|
|
1851
|
-
min-height: 5rem;
|
|
1852
|
-
border: 0;
|
|
1853
|
-
background: none;
|
|
1854
|
-
cursor: text;
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
.toolbar {
|
|
1858
|
-
position: fixed;
|
|
1859
|
-
transform: translate(-50%, calc(-100% - 8px));
|
|
1860
|
-
display: flex;
|
|
1861
|
-
gap: 2px;
|
|
1862
|
-
padding: 4px;
|
|
1863
|
-
background: var(--surface);
|
|
1864
|
-
border: 1px solid var(--border);
|
|
1865
|
-
border-radius: 10px;
|
|
1866
|
-
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
|
1867
|
-
z-index: 20;
|
|
1868
|
-
}
|
|
1869
|
-
.toolbar button {
|
|
1870
|
-
min-width: 30px;
|
|
1871
|
-
height: 30px;
|
|
1872
|
-
border: 0;
|
|
1873
|
-
border-radius: 6px;
|
|
1874
|
-
background: none;
|
|
1875
|
-
color: var(--fg);
|
|
1876
|
-
display: grid;
|
|
1877
|
-
place-items: center;
|
|
1878
|
-
cursor: pointer;
|
|
1879
|
-
}
|
|
1880
|
-
.toolbar button:hover {
|
|
1881
|
-
background: var(--code-bg);
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
.menu {
|
|
1885
|
-
position: fixed;
|
|
1886
|
-
width: 260px;
|
|
1887
|
-
max-height: 300px;
|
|
1888
|
-
overflow: auto;
|
|
1889
|
-
padding: 6px;
|
|
1890
|
-
background: var(--surface);
|
|
1891
|
-
border: 1px solid var(--border);
|
|
1892
|
-
border-radius: 12px;
|
|
1893
|
-
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.2);
|
|
1894
|
-
z-index: 30;
|
|
1895
|
-
}
|
|
1896
|
-
.menu.up {
|
|
1897
|
-
transform: translateY(-100%);
|
|
1898
|
-
}
|
|
1899
|
-
.menu.turn {
|
|
1900
|
-
width: 240px;
|
|
1901
|
-
max-height: 340px;
|
|
1902
|
-
}
|
|
1903
|
-
.caption {
|
|
1904
|
-
padding: 4px 8px 6px;
|
|
1905
|
-
font: 600 11px system-ui;
|
|
1906
|
-
letter-spacing: 0.06em;
|
|
1907
|
-
text-transform: uppercase;
|
|
1908
|
-
color: var(--muted);
|
|
1909
|
-
}
|
|
1910
|
-
.sep {
|
|
1911
|
-
height: 1px;
|
|
1912
|
-
margin: 6px 4px;
|
|
1913
|
-
background: var(--border);
|
|
1914
|
-
}
|
|
1915
|
-
.current {
|
|
1916
|
-
margin-left: auto;
|
|
1917
|
-
display: flex;
|
|
1918
|
-
color: var(--accent);
|
|
1919
|
-
}
|
|
1920
|
-
.txt small.warn {
|
|
1921
|
-
color: #b45309;
|
|
1922
|
-
}
|
|
1923
|
-
.item.danger {
|
|
1924
|
-
color: #dc2626;
|
|
1925
|
-
}
|
|
1926
|
-
.item.danger .icon {
|
|
1927
|
-
color: #dc2626;
|
|
1928
|
-
}
|
|
1929
|
-
/* Compact one-line rows: icon, label, and the hint after it in grey. */
|
|
1930
|
-
.item {
|
|
1931
|
-
display: flex;
|
|
1932
|
-
align-items: center;
|
|
1933
|
-
gap: 8px;
|
|
1934
|
-
padding: 3px 6px;
|
|
1935
|
-
border-radius: 6px;
|
|
1936
|
-
cursor: pointer;
|
|
1937
|
-
}
|
|
1938
|
-
.item.active {
|
|
1939
|
-
background: var(--sel);
|
|
1940
|
-
}
|
|
1941
|
-
.icon {
|
|
1942
|
-
flex: none;
|
|
1943
|
-
width: 24px;
|
|
1944
|
-
height: 24px;
|
|
1945
|
-
display: grid;
|
|
1946
|
-
place-items: center;
|
|
1947
|
-
border: 1px solid var(--border);
|
|
1948
|
-
border-radius: 6px;
|
|
1949
|
-
color: var(--fg);
|
|
1950
|
-
}
|
|
1951
|
-
.txt {
|
|
1952
|
-
min-width: 0;
|
|
1953
|
-
display: flex;
|
|
1954
|
-
align-items: baseline;
|
|
1955
|
-
gap: 6px;
|
|
1956
|
-
white-space: nowrap;
|
|
1957
|
-
overflow: hidden;
|
|
1958
|
-
}
|
|
1959
|
-
.txt b {
|
|
1960
|
-
font: 500 13px system-ui;
|
|
1961
|
-
}
|
|
1962
|
-
.txt small {
|
|
1963
|
-
overflow: hidden;
|
|
1964
|
-
text-overflow: ellipsis;
|
|
1965
|
-
color: var(--muted);
|
|
1966
|
-
font-size: 11px;
|
|
1967
|
-
}
|
|
1968
|
-
</style>
|
|
1519
|
+
{#if !nested}<div class="contents" use:portal><PopupContainer /></div>{/if}
|
|
1520
|
+
|
|
1521
|
+
<style>.editor {
|
|
1522
|
+
--fg: var(--color-surface-contrast, #1d1d1f);
|
|
1523
|
+
--muted: var(--color-muted-contrast, #a0a0a8);
|
|
1524
|
+
--accent: var(--color-accent, #3b82f6);
|
|
1525
|
+
--sel: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
1526
|
+
--surface: var(--color-surface, #fff);
|
|
1527
|
+
--border: var(--color-frame, #e5e5ea);
|
|
1528
|
+
--code-bg: rgba(120, 120, 140, 0.14);
|
|
1529
|
+
position: relative;
|
|
1530
|
+
outline: none;
|
|
1531
|
+
color: var(--fg);
|
|
1532
|
+
font-size: 17px;
|
|
1533
|
+
line-height: 1.65;
|
|
1534
|
+
padding-bottom: 6rem;
|
|
1535
|
+
}
|
|
1536
|
+
.editor.sweeping, .editor.sweeping :global(*) {
|
|
1537
|
+
user-select: none;
|
|
1538
|
+
}
|
|
1539
|
+
.editor.nested {
|
|
1540
|
+
font-size: 1em;
|
|
1541
|
+
padding-bottom: 0;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
.row {
|
|
1545
|
+
position: relative;
|
|
1546
|
+
display: flex;
|
|
1547
|
+
border-radius: 6px;
|
|
1548
|
+
margin: 0 -8px;
|
|
1549
|
+
padding: 0 8px;
|
|
1550
|
+
}
|
|
1551
|
+
.row.selected {
|
|
1552
|
+
background: var(--sel);
|
|
1553
|
+
}
|
|
1554
|
+
.row.dragging {
|
|
1555
|
+
opacity: 0.35;
|
|
1556
|
+
}
|
|
1557
|
+
.row.drop-before::before, .row.drop-after::after {
|
|
1558
|
+
content: "";
|
|
1559
|
+
position: absolute;
|
|
1560
|
+
left: 8px;
|
|
1561
|
+
right: 8px;
|
|
1562
|
+
height: 3px;
|
|
1563
|
+
border-radius: 2px;
|
|
1564
|
+
background: var(--accent);
|
|
1565
|
+
z-index: 2;
|
|
1566
|
+
}
|
|
1567
|
+
.row.drop-before::before {
|
|
1568
|
+
top: -2px;
|
|
1569
|
+
}
|
|
1570
|
+
.row.drop-after::after {
|
|
1571
|
+
bottom: -2px;
|
|
1572
|
+
}
|
|
1573
|
+
.row:hover > .gutter .handle {
|
|
1574
|
+
background: rgba(120, 120, 140, 0.06);
|
|
1575
|
+
}
|
|
1576
|
+
.row:focus-within > .gutter .handle, .row.selected > .gutter .handle {
|
|
1577
|
+
box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.22);
|
|
1578
|
+
}
|
|
1579
|
+
.row:focus-within:not(:has(.row:focus-within)) > .gutter .handle {
|
|
1580
|
+
animation: caret-pulse 1.1s ease-in-out infinite alternate;
|
|
1581
|
+
}
|
|
1582
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1583
|
+
.row:focus-within:not(:has(.row:focus-within)) > .gutter .handle {
|
|
1584
|
+
animation: none;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
.row:hover > .gutter .grip, .row:focus-within > .gutter .grip, .row.selected > .gutter .grip {
|
|
1588
|
+
opacity: 1;
|
|
1589
|
+
}
|
|
1590
|
+
.row > .gutter .handle:hover, .row.dragging > .gutter .handle {
|
|
1591
|
+
background: var(--code-bg);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
@keyframes caret-pulse {
|
|
1595
|
+
from {
|
|
1596
|
+
box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.06);
|
|
1597
|
+
}
|
|
1598
|
+
to {
|
|
1599
|
+
box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.3);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
.gutter {
|
|
1603
|
+
position: absolute;
|
|
1604
|
+
left: -22px;
|
|
1605
|
+
top: 1px;
|
|
1606
|
+
bottom: 1px;
|
|
1607
|
+
width: 20px;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
.handle {
|
|
1611
|
+
width: 100%;
|
|
1612
|
+
height: 100%;
|
|
1613
|
+
padding: 0;
|
|
1614
|
+
border: 0;
|
|
1615
|
+
background: transparent;
|
|
1616
|
+
font: inherit;
|
|
1617
|
+
display: flex;
|
|
1618
|
+
align-items: center;
|
|
1619
|
+
justify-content: center;
|
|
1620
|
+
user-select: none;
|
|
1621
|
+
color: var(--muted);
|
|
1622
|
+
border-radius: 4px;
|
|
1623
|
+
cursor: grab;
|
|
1624
|
+
transition: background 0.12s, box-shadow 0.12s;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
.grip {
|
|
1628
|
+
width: 20px;
|
|
1629
|
+
height: 20px;
|
|
1630
|
+
display: flex;
|
|
1631
|
+
align-items: center;
|
|
1632
|
+
justify-content: center;
|
|
1633
|
+
opacity: 0;
|
|
1634
|
+
transition: opacity 0.12s;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
.grab {
|
|
1638
|
+
position: relative;
|
|
1639
|
+
height: 100%;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
.bubble {
|
|
1643
|
+
position: absolute;
|
|
1644
|
+
left: 100%;
|
|
1645
|
+
top: 50%;
|
|
1646
|
+
z-index: 30;
|
|
1647
|
+
display: flex;
|
|
1648
|
+
padding-left: 6px;
|
|
1649
|
+
translate: 0 -50%;
|
|
1650
|
+
opacity: 0;
|
|
1651
|
+
visibility: hidden;
|
|
1652
|
+
transition: opacity 0.12s, visibility 0s 0.12s;
|
|
1653
|
+
}
|
|
1654
|
+
.bubble::before {
|
|
1655
|
+
content: "";
|
|
1656
|
+
position: absolute;
|
|
1657
|
+
left: 2px;
|
|
1658
|
+
top: 50%;
|
|
1659
|
+
translate: 0 -50%;
|
|
1660
|
+
border: 4px solid transparent;
|
|
1661
|
+
border-right-color: var(--color-surface-contrast, #1d1d1f);
|
|
1662
|
+
border-left: 0;
|
|
1663
|
+
}
|
|
1664
|
+
.grab:has(.grip:hover) .bubble, .grab:focus-within .bubble, .bubble:hover {
|
|
1665
|
+
opacity: 1;
|
|
1666
|
+
visibility: visible;
|
|
1667
|
+
transition-delay: 0.15s, 0s;
|
|
1668
|
+
}
|
|
1669
|
+
@media (hover: none) {
|
|
1670
|
+
.row.selected > .gutter .bubble {
|
|
1671
|
+
opacity: 1;
|
|
1672
|
+
visibility: visible;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
.handle:active ~ .bubble, .editor.sweeping .bubble {
|
|
1676
|
+
opacity: 0;
|
|
1677
|
+
visibility: hidden;
|
|
1678
|
+
transition: none;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
.body {
|
|
1682
|
+
flex: 1;
|
|
1683
|
+
min-width: 0;
|
|
1684
|
+
display: flex;
|
|
1685
|
+
align-items: flex-start;
|
|
1686
|
+
gap: 0.5em;
|
|
1687
|
+
padding: 0.15em 0;
|
|
1688
|
+
margin-left: calc(var(--indent, 0) * 1.7em);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
.ce {
|
|
1692
|
+
flex: 1;
|
|
1693
|
+
min-width: 0;
|
|
1694
|
+
min-height: 1.65em;
|
|
1695
|
+
outline: none;
|
|
1696
|
+
white-space: pre-wrap;
|
|
1697
|
+
word-break: break-word;
|
|
1698
|
+
caret-color: var(--accent);
|
|
1699
|
+
}
|
|
1700
|
+
.ce.empty::before {
|
|
1701
|
+
content: attr(data-placeholder);
|
|
1702
|
+
color: var(--muted);
|
|
1703
|
+
pointer-events: none;
|
|
1704
|
+
}
|
|
1705
|
+
.ce.done {
|
|
1706
|
+
color: var(--muted);
|
|
1707
|
+
text-decoration: line-through;
|
|
1708
|
+
}
|
|
1709
|
+
.ce :global(code) {
|
|
1710
|
+
background: var(--code-bg);
|
|
1711
|
+
padding: 0.1em 0.35em;
|
|
1712
|
+
border-radius: 4px;
|
|
1713
|
+
font: 0.88em ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1714
|
+
}
|
|
1715
|
+
.ce :global(a) {
|
|
1716
|
+
color: var(--accent);
|
|
1717
|
+
text-decoration: underline;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
.t-p .ce.empty:not(:focus)::before,
|
|
1721
|
+
.t-ul .ce.empty:not(:focus)::before,
|
|
1722
|
+
.t-ol .ce.empty:not(:focus)::before {
|
|
1723
|
+
content: "";
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
.t-h1 {
|
|
1727
|
+
padding-top: 0.6em;
|
|
1728
|
+
}
|
|
1729
|
+
.t-h1 .ce {
|
|
1730
|
+
font-size: 2em;
|
|
1731
|
+
font-weight: 700;
|
|
1732
|
+
line-height: 1.25;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
.t-h2 {
|
|
1736
|
+
padding-top: 0.45em;
|
|
1737
|
+
}
|
|
1738
|
+
.t-h2 .ce {
|
|
1739
|
+
font-size: 1.5em;
|
|
1740
|
+
font-weight: 650;
|
|
1741
|
+
line-height: 1.3;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
.t-h3 {
|
|
1745
|
+
padding-top: 0.3em;
|
|
1746
|
+
}
|
|
1747
|
+
.t-h3 .ce {
|
|
1748
|
+
font-size: 1.2em;
|
|
1749
|
+
font-weight: 600;
|
|
1750
|
+
line-height: 1.4;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
.t-h4 {
|
|
1754
|
+
padding-top: 0.25em;
|
|
1755
|
+
}
|
|
1756
|
+
.t-h4 .ce {
|
|
1757
|
+
font-size: 1.05em;
|
|
1758
|
+
font-weight: 600;
|
|
1759
|
+
line-height: 1.45;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
.t-h5 {
|
|
1763
|
+
padding-top: 0.2em;
|
|
1764
|
+
}
|
|
1765
|
+
.t-h5 .ce {
|
|
1766
|
+
font-size: 0.95em;
|
|
1767
|
+
font-weight: 600;
|
|
1768
|
+
line-height: 1.5;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
.t-h6 {
|
|
1772
|
+
padding-top: 0.2em;
|
|
1773
|
+
}
|
|
1774
|
+
.t-h6 .ce {
|
|
1775
|
+
font-size: 0.85em;
|
|
1776
|
+
font-weight: 600;
|
|
1777
|
+
line-height: 1.5;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.t-h6 .ce {
|
|
1781
|
+
letter-spacing: 0.04em;
|
|
1782
|
+
text-transform: uppercase;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
.t-quote .ce {
|
|
1786
|
+
border-left: 3px solid var(--fg);
|
|
1787
|
+
padding-left: 0.9em;
|
|
1788
|
+
font-style: italic;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
.t-plugin {
|
|
1792
|
+
position: relative;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
.t-code {
|
|
1796
|
+
position: relative;
|
|
1797
|
+
flex-wrap: wrap;
|
|
1798
|
+
row-gap: 0;
|
|
1799
|
+
}
|
|
1800
|
+
.t-code .ce {
|
|
1801
|
+
flex-basis: 100%;
|
|
1802
|
+
background: var(--code-bg);
|
|
1803
|
+
border-radius: 8px;
|
|
1804
|
+
padding: 0.7em 0.9em;
|
|
1805
|
+
font: 0.88em/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1806
|
+
white-space: pre-wrap;
|
|
1807
|
+
}
|
|
1808
|
+
.t-code:has(> .badge) .ce {
|
|
1809
|
+
padding-right: 7.5em;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
.t-hr {
|
|
1813
|
+
padding: 0.6em 0;
|
|
1814
|
+
}</style>
|