@atom-forge/brickee 0.0.0-stage → 0.0.2

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README-AI.md +286 -0
  4. package/README.md +41 -2
  5. package/dist/Editor.svelte +1956 -0
  6. package/dist/Editor.svelte.d.ts +19 -0
  7. package/dist/LinkPrompt.svelte +42 -0
  8. package/dist/LinkPrompt.svelte.d.ts +8 -0
  9. package/dist/PluginHost.svelte +41 -0
  10. package/dist/PluginHost.svelte.d.ts +13 -0
  11. package/dist/Preview.svelte +182 -0
  12. package/dist/Preview.svelte.d.ts +32 -0
  13. package/dist/PropsBar.svelte +405 -0
  14. package/dist/PropsBar.svelte.d.ts +17 -0
  15. package/dist/code.d.ts +12 -0
  16. package/dist/code.js +15 -0
  17. package/dist/dom.d.ts +29 -0
  18. package/dist/dom.js +224 -0
  19. package/dist/i18n.d.ts +7 -0
  20. package/dist/i18n.js +18 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +16 -0
  23. package/dist/kit/Empty.svelte +24 -0
  24. package/dist/kit/Empty.svelte.d.ts +8 -0
  25. package/dist/kit/Frame.svelte +76 -0
  26. package/dist/kit/Frame.svelte.d.ts +15 -0
  27. package/dist/kit/util.d.ts +13 -0
  28. package/dist/kit/util.js +28 -0
  29. package/dist/locales/hu.d.ts +2 -0
  30. package/dist/locales/hu.js +142 -0
  31. package/dist/markdown.d.ts +23 -0
  32. package/dist/markdown.js +219 -0
  33. package/dist/model.d.ts +21 -0
  34. package/dist/model.js +45 -0
  35. package/dist/palette.d.ts +19 -0
  36. package/dist/palette.js +23 -0
  37. package/dist/plugins/Gap.svelte +28 -0
  38. package/dist/plugins/Gap.svelte.d.ts +15 -0
  39. package/dist/plugins/GapPreview.svelte +17 -0
  40. package/dist/plugins/GapPreview.svelte.d.ts +5 -0
  41. package/dist/plugins/Map.svelte +47 -0
  42. package/dist/plugins/Map.svelte.d.ts +12 -0
  43. package/dist/plugins/MapPreview.svelte +26 -0
  44. package/dist/plugins/MapPreview.svelte.d.ts +5 -0
  45. package/dist/plugins/Table.svelte +537 -0
  46. package/dist/plugins/Table.svelte.d.ts +28 -0
  47. package/dist/plugins/TablePreview.svelte +62 -0
  48. package/dist/plugins/TablePreview.svelte.d.ts +5 -0
  49. package/dist/plugins/YouTube.svelte +52 -0
  50. package/dist/plugins/YouTube.svelte.d.ts +8 -0
  51. package/dist/plugins/YouTubePreview.svelte +76 -0
  52. package/dist/plugins/YouTubePreview.svelte.d.ts +5 -0
  53. package/dist/plugins/index.d.ts +20 -0
  54. package/dist/plugins/index.js +105 -0
  55. package/dist/plugins/util.d.ts +3 -0
  56. package/dist/plugins/util.js +8 -0
  57. package/dist/schema.d.ts +11 -0
  58. package/dist/schema.js +25 -0
  59. package/dist/scroll.d.ts +18 -0
  60. package/dist/scroll.js +57 -0
  61. package/dist/types.d.ts +132 -0
  62. package/dist/types.js +43 -0
  63. package/dist/view.d.ts +6 -0
  64. package/dist/view.js +11 -0
  65. package/docs/en/bundled-plugins.md +99 -0
  66. package/docs/en/documents-and-markdown.md +114 -0
  67. package/docs/en/editor-and-preview.md +139 -0
  68. package/docs/en/getting-started.md +85 -0
  69. package/docs/en/host-contexts.md +169 -0
  70. package/docs/en/index.md +38 -0
  71. package/docs/en/recipes-and-pitfalls.md +123 -0
  72. package/docs/en/server-side.md +95 -0
  73. package/docs/en/upgrading.md +71 -0
  74. package/docs/en/writing-plugins.md +405 -0
  75. package/docs/hu/bundled-plugins.md +99 -0
  76. package/docs/hu/documents-and-markdown.md +114 -0
  77. package/docs/hu/editor-and-preview.md +139 -0
  78. package/docs/hu/getting-started.md +85 -0
  79. package/docs/hu/host-contexts.md +169 -0
  80. package/docs/hu/index.md +38 -0
  81. package/docs/hu/recipes-and-pitfalls.md +123 -0
  82. package/docs/hu/server-side.md +95 -0
  83. package/docs/hu/upgrading.md +71 -0
  84. package/docs/hu/writing-plugins.md +405 -0
  85. package/package.json +114 -5
@@ -0,0 +1,1956 @@
1
+ <script lang="ts">
2
+ import { getContext, setContext, tick, untrack } from 'svelte';
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';
21
+ import { fromMarkdown, fromStored, plainHtml, toMarkdown, toStored } from './markdown';
22
+ import { useCodeLanguages } from './code';
23
+ import { provideText, useText, type Messages } from './i18n';
24
+ import { PopupContainer, createPopupManager, getPopupManager } from '@atom-forge/ui';
25
+ import LinkPrompt from './LinkPrompt.svelte';
26
+ import PluginHost from './PluginHost.svelte';
27
+ import PropsBar from './PropsBar.svelte';
28
+ import { acceptsId, BUILT_IN_IDS, PALETTE_ITEMS, type Icon, type PaletteItem } from './palette';
29
+ 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
+
31
+ const HUB = 'block-editor:hub';
32
+
33
+ interface Props {
34
+ /** The document: built-in blocks as markdown snippets, plugin blocks with their props. */
35
+ value?: StoredBlock[];
36
+ readonly?: boolean;
37
+ placeholder?: string;
38
+ /** Built-in block types and plugin types offered in the slash menu, in this order. Shortcuts and paste respect it too. */
39
+ palette?: string[];
40
+ /** Custom blocks this editor can show and offer. */
41
+ plugins?: BlockPlugin[];
42
+ /** Set by plugins with nested blocks: a compact editor inside another block. */
43
+ nested?: boolean;
44
+ /** UI text in the host's language (English text -> translation); nested editors share their parent's. */
45
+ messages?: Messages;
46
+ }
47
+ let {
48
+ value: doc = $bindable([]),
49
+ readonly = false,
50
+ placeholder: placeholderProp,
51
+ palette: paletteProp,
52
+ plugins = [],
53
+ nested = false,
54
+ messages
55
+ }: Props = $props();
56
+
57
+ provideText(() => messages);
58
+ // Popups (the link field) are atom-forge popups; the top editor has their container, nested editors share it.
59
+ // svelte-ignore state_referenced_locally
60
+ const popups = nested ? getPopupManager() : createPopupManager(getPopupManager());
61
+ const tr = useText();
62
+ const placeholder = $derived(placeholderProp ?? tr("Write something, or press '/' for blocks…"));
63
+
64
+ // One level of nesting: an editor inside a block offers no block types that hold blocks themselves, nor unique
65
+ // ones (those belong to the top level).
66
+ const palette = $derived(
67
+ (paletteProp ?? [...BUILT_IN_IDS, ...plugins.map((p) => p.type)]).filter((id) => {
68
+ const p = plugins.find((x) => x.type === id);
69
+ return !(nested && (p?.content === 'blocks' || p?.unique));
70
+ })
71
+ );
72
+ const pluginOf = (type: string | undefined) => plugins.find((p) => p.type === type);
73
+ /** Blocks the caret cannot enter from outside: dividers and plugin blocks. */
74
+ const atomic = (b: Block) => b.type === 'hr' || b.type === 'plugin';
75
+
76
+ /* ------------------------------------------------------------ document io */
77
+
78
+ // The editor works on html blocks; `doc` is their markdown form, written back on every change.
79
+ const load = (d: StoredBlock[]) => (d.length ? d.map(fromStored) : [newBlock()]);
80
+ let value = $state<Block[]>(load(doc));
81
+ let written = '';
82
+ $effect(() => {
83
+ const out = value.map(toStored);
84
+ written = JSON.stringify(out);
85
+ doc = out;
86
+ });
87
+ // A document replaced from outside (not by our own write) is loaded again.
88
+ $effect(() => {
89
+ if (JSON.stringify(doc) !== written) value = load(doc);
90
+ });
91
+
92
+ /* ------------------------------------------------------------------ state */
93
+
94
+ let root: HTMLDivElement;
95
+ let focusedId = $state<string | null>(null);
96
+ let selA = $state<string | null>(null); // block-selection anchor
97
+ let selH = $state<string | null>(null); // block-selection head
98
+ let slash = $state<{ id: string; query: string; x: number; y: number; up: boolean; index: number } | null>(null);
99
+ let toolbar = $state<{ x: number; y: number } | null>(null);
100
+
101
+ // State shared by an editor and every editor nested in its plugin blocks: blocks can be dragged between them,
102
+ // and only the editor last worked in keeps a block selection.
103
+ type DragSource = { editor: string; ids: string[]; payload: StoredBlock[]; rows: HTMLElement[]; remove: () => void };
104
+ type Hub = {
105
+ src: DragSource | null;
106
+ target: { editor: string; over: string; after: boolean } | null;
107
+ active: string | null;
108
+ };
109
+ // A string, not an object: $state would proxy an object and break identity checks.
110
+ const token = uid();
111
+ const ownHub = $state<Hub>({ src: null, target: null, active: null });
112
+ const hub: Hub = getContext<Hub>(HUB) ?? ownHub;
113
+ setContext(HUB, hub);
114
+
115
+ $effect(() => {
116
+ if (selA) hub.active = token;
117
+ });
118
+ $effect(() => {
119
+ if (hub.active !== token) untrack(() => (selA = selH = null));
120
+ });
121
+
122
+ const idx = (id: string) => value.findIndex((b) => b.id === id);
123
+ // Direct children only: a plugin row may hold a nested editor with its own blocks.
124
+ const el = (id: string) => root?.querySelector<HTMLElement>(`[data-block="${id}"] > .body > .ce`) ?? null;
125
+ const ownRow = (n: Element | null) => {
126
+ let row = n?.closest<HTMLElement>('[data-block]') ?? null;
127
+ while (row && row.parentElement !== root) row = row.parentElement?.closest<HTMLElement>('[data-block]') ?? null;
128
+ return row;
129
+ };
130
+ const nums = $derived(numbering(value));
131
+
132
+ const selSet = $derived.by(() => {
133
+ if (!selA || !selH) return new Set<string>();
134
+ const a = idx(selA);
135
+ const h = idx(selH);
136
+ if (a < 0 || h < 0) return new Set<string>();
137
+ const [lo, hi] = a < h ? [a, h] : [h, a];
138
+ return new Set(value.slice(lo, hi + 1).map((b) => b.id));
139
+ });
140
+
141
+ async function focusBlock(id: string, where: 'start' | 'end' | number = 'end') {
142
+ await tick();
143
+ const e = el(id);
144
+ if (e) setCaret(e, where);
145
+ else selectOnly(id);
146
+ }
147
+
148
+ /* ---------------------------------------------------------------- history */
149
+
150
+ type Snap = { blocks: string; id: string | null; off: number };
151
+ const take = (): Snap => {
152
+ const e = focusedId ? el(focusedId) : null;
153
+ return { blocks: JSON.stringify($state.snapshot(value)), id: focusedId, off: e ? caretOffset(e) : 0 };
154
+ };
155
+ let past: Snap[] = [];
156
+ let future: Snap[] = [];
157
+ let last: Snap = take();
158
+ let pending = false;
159
+ let timer: ReturnType<typeof setTimeout>;
160
+
161
+ /** Call on every text edit: consecutive typing collapses into one undo step. */
162
+ function typed() {
163
+ if (!pending) {
164
+ past.push(last);
165
+ if (past.length > 300) past.shift();
166
+ future = [];
167
+ pending = true;
168
+ }
169
+ clearTimeout(timer);
170
+ timer = setTimeout(() => {
171
+ pending = false;
172
+ last = take();
173
+ }, 600);
174
+ }
175
+
176
+ /** Wrap every structural change: pushes an undo step. */
177
+ function mutate(fn: () => void) {
178
+ clearTimeout(timer);
179
+ pending = false;
180
+ past.push(take());
181
+ if (past.length > 300) past.shift();
182
+ future = [];
183
+ fn();
184
+ last = take();
185
+ }
186
+
187
+ function restore(s: Snap) {
188
+ value = JSON.parse(s.blocks);
189
+ selA = selH = null;
190
+ slash = null;
191
+ last = s;
192
+ if (s.id && idx(s.id) >= 0) focusBlock(s.id, s.off);
193
+ }
194
+ function undo() {
195
+ clearTimeout(timer);
196
+ pending = false;
197
+ const s = past.pop();
198
+ if (!s) return;
199
+ future.push(take());
200
+ restore(s);
201
+ }
202
+ function redo() {
203
+ clearTimeout(timer);
204
+ pending = false;
205
+ const s = future.pop();
206
+ if (!s) return;
207
+ past.push(take());
208
+ restore(s);
209
+ }
210
+
211
+ /* ---------------------------------------------------------- block catalog */
212
+
213
+ // The palette picks and orders the slash menu; plain text ('p') is always allowed as the fallback.
214
+ // Labels and hints in the host's language; the slash menu finds a block by its English and translated words too.
215
+ const catalog = $derived<PaletteItem[]>(
216
+ [...PALETTE_ITEMS, ...plugins.map((p) => ({ ...p, id: p.type }))].map((it) => ({
217
+ ...it,
218
+ label: tr(it.label),
219
+ hint: tr(it.hint),
220
+ keys: `${it.label} ${it.keys} ${tr(it.keys)}`
221
+ }))
222
+ );
223
+ // A unique plugin leaves the menus once the document has one.
224
+ const items = $derived(
225
+ palette
226
+ .map((id) => catalog.find((it) => it.id === id))
227
+ .filter((it): it is PaletteItem => !!it && !(pluginOf(it.id)?.unique && value.some((b) => b.plugin === it.id)))
228
+ );
229
+ // Editor-side names: built-in kinds without the md: prefix (p, h1…), plugin types as they are.
230
+ const allowed = $derived(new Set<string>(['p', ...palette.map((id) => (id.startsWith(MD) ? id.slice(MD.length) : id))]));
231
+
232
+ /** Pasted blocks of a type outside the palette become plain text; dividers are dropped. */
233
+ const fit = (blocks: Block[]) =>
234
+ blocks
235
+ .filter((b) => b.type !== 'hr' || allowed.has('hr'))
236
+ .map((b) => {
237
+ if (!allowed.has(b.type)) convert(b, 'p');
238
+ return b;
239
+ });
240
+
241
+ type Cmd = 'bold' | 'italic' | 'underline' | 'strikeThrough' | 'subscript' | 'superscript' | 'code' | 'link' | 'plain';
242
+ const tools: { cmd: Cmd; icon: Icon; title: string }[] = [
243
+ { cmd: 'bold', icon: Bold, title: 'Bold (Ctrl+B)' },
244
+ { cmd: 'italic', icon: Italic, title: 'Italic (Ctrl+I)' },
245
+ { cmd: 'underline', icon: Underline, title: 'Underline (Ctrl+U)' },
246
+ { cmd: 'strikeThrough', icon: Strikethrough, title: 'Strikethrough (Ctrl+Shift+X)' },
247
+ { cmd: 'subscript', icon: Subscript, title: 'Subscript (NO₂)' },
248
+ { cmd: 'superscript', icon: Superscript, title: 'Superscript (m²)' },
249
+ { cmd: 'code', icon: Code, title: 'Code (Ctrl+E)' },
250
+ { cmd: 'link', icon: Link, title: 'Link (Ctrl+K)' },
251
+ { cmd: 'plain', icon: RemoveFormatting, title: 'Plain text: remove the formatting (Ctrl+\\)' }
252
+ ];
253
+ const hints: Partial<Record<BlockType, string>> = {
254
+ h1: 'Heading 1',
255
+ h2: 'Heading 2',
256
+ h3: 'Heading 3',
257
+ h4: 'Heading 4',
258
+ h5: 'Heading 5',
259
+ h6: 'Heading 6',
260
+ ul: 'List item',
261
+ ol: 'List item',
262
+ todo: 'To-do',
263
+ quote: 'Quote',
264
+ code: 'Code'
265
+ };
266
+ const hintFor = (t: BlockType) => (hints[t] ? tr(hints[t]) : placeholder);
267
+
268
+ const filtered = $derived(
269
+ slash ? items.filter((it) => (it.label + ' ' + it.keys).toLowerCase().includes(slash!.query.toLowerCase())) : []
270
+ );
271
+
272
+ function convert(b: Block, type: BlockType) {
273
+ b.type = type;
274
+ if (type === 'todo') b.checked = false;
275
+ else delete b.checked;
276
+ if (type !== 'code') delete b.props;
277
+ if (type === 'hr') b.html = '';
278
+ delete b.plugin;
279
+ delete b.blocks;
280
+ }
281
+
282
+ /* -------------------------------------------------------------- slash menu */
283
+
284
+ function detectSlash(ce: HTMLElement, b: Block) {
285
+ const sel = getSelection();
286
+ if (!sel || !sel.rangeCount || !sel.isCollapsed || b.type === 'code') {
287
+ slash = null;
288
+ return;
289
+ }
290
+ const r = sel.getRangeAt(0);
291
+ if (!ce.contains(r.startContainer)) {
292
+ slash = null;
293
+ return;
294
+ }
295
+ const pre = document.createRange();
296
+ pre.selectNodeContents(ce);
297
+ pre.setEnd(r.startContainer, r.startOffset);
298
+ const m = /(?:^|[\s ])\/([^\s/]*)$/.exec(pre.toString());
299
+ if (!m) {
300
+ slash = null;
301
+ return;
302
+ }
303
+ const rect = caretRect() ?? ce.getBoundingClientRect();
304
+ const up = rect.bottom + 300 > innerHeight && rect.top > 300;
305
+ slash = {
306
+ id: b.id,
307
+ query: m[1],
308
+ x: Math.min(rect.left, innerWidth - 270),
309
+ y: up ? rect.top - 6 : rect.bottom + 6,
310
+ up,
311
+ index: slash && slash.id === b.id && slash.query === m[1] ? slash.index : 0
312
+ };
313
+ }
314
+
315
+ // Keep the highlighted item visible while moving through the menu with the arrow keys.
316
+ $effect(() => {
317
+ if (slash?.index === undefined && turn?.index === undefined) return;
318
+ root.querySelector(':scope > .menu .item.active')?.scrollIntoView({ block: 'nearest' });
319
+ });
320
+
321
+ function applySlash(item: PaletteItem) {
322
+ if (!slash) return;
323
+ const b = value[idx(slash.id)];
324
+ const ce = el(slash.id);
325
+ if (!b || !ce) return;
326
+ const n = slash.query.length + 1;
327
+ slash = null;
328
+ removeBeforeCaret(n);
329
+ b.html = clean(ce.innerHTML);
330
+ const off = caretOffset(ce);
331
+ const plugin = pluginOf(item.id);
332
+ if (plugin) return insertPlugin(b, plugin);
333
+ const type = item.id.slice(MD.length) as BlockType;
334
+ mutate(() => {
335
+ convert(b, type);
336
+ if (type === 'hr') {
337
+ const i = idx(b.id);
338
+ const nb = newBlock();
339
+ value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
340
+ focusBlock(nb.id, 'start');
341
+ } else focusBlock(b.id, off);
342
+ });
343
+ }
344
+
345
+ // Plugin blocks made in this session: their fields start open (the others show only their small header).
346
+ const fresh = new Set<string>();
347
+
348
+ /** Puts a plugin block in place of an empty block (or after a filled one) and enters its nested editor. */
349
+ async function insertPlugin(b: Block, plugin: BlockPlugin) {
350
+ const pb = newBlock('plugin', '', {
351
+ plugin: plugin.type,
352
+ indent: b.indent,
353
+ props: plugin.props(),
354
+ ...(plugin.content === 'blocks' ? { blocks: [{ type: 'md:p', text: '', indent: 0 }] } : {})
355
+ });
356
+ fresh.add(pb.id);
357
+ const i = idx(b.id);
358
+ mutate(() => {
359
+ const keep = b.html ? [b] : [];
360
+ const after = value[i + 1] ? [] : [newBlock()];
361
+ value = [...value.slice(0, i), ...keep, pb, ...after, ...value.slice(i + 1)];
362
+ });
363
+ await tick();
364
+ await tick();
365
+ const inner = root.querySelector<HTMLElement>(`[data-block="${pb.id}"] .ce`);
366
+ if (inner) setCaret(inner, 'start');
367
+ else selectOnly(pb.id);
368
+ }
369
+
370
+ function handleSlashKey(e: KeyboardEvent): boolean {
371
+ if (!slash || !filtered.length) return false;
372
+ const n = filtered.length;
373
+ if (e.key === 'ArrowDown') slash.index = (slash.index + 1) % n;
374
+ else if (e.key === 'ArrowUp') slash.index = (slash.index - 1 + n) % n;
375
+ else if (e.key === 'Enter' || e.key === 'Tab') applySlash(filtered[Math.min(slash.index, n - 1)]);
376
+ else if (e.key === 'Escape') slash = null;
377
+ else return false;
378
+ e.preventDefault();
379
+ return true;
380
+ }
381
+
382
+ /* ----------------------------------------------------------------- editing */
383
+
384
+ function enter(b: Block, ce: HTMLElement) {
385
+ const i = idx(b.id);
386
+ const text = ce.textContent ?? '';
387
+
388
+ if (b.type === 'p' && text === '---' && allowed.has('hr')) {
389
+ mutate(() => {
390
+ b.type = 'hr';
391
+ b.html = '';
392
+ const nb = newBlock();
393
+ value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
394
+ focusBlock(nb.id, 'start');
395
+ });
396
+ return;
397
+ }
398
+ const fence = /^```([\w+#.-]*)$/.exec(text);
399
+ if (b.type === 'p' && fence && allowed.has('code')) {
400
+ mutate(() => {
401
+ b.type = 'code';
402
+ b.html = '';
403
+ if (fence[1]) b.props = { lang: fence[1] };
404
+ ce.innerHTML = '';
405
+ });
406
+ focusBlock(b.id, 'start');
407
+ return;
408
+ }
409
+ if (!text && (b.type === 'ul' || b.type === 'ol' || b.type === 'todo' || b.type === 'quote')) {
410
+ mutate(() => {
411
+ if (b.indent > 0) b.indent--;
412
+ else convert(b, 'p');
413
+ });
414
+ focusBlock(b.id, 'end');
415
+ return;
416
+ }
417
+
418
+ const parts = splitAtCaret(ce);
419
+ if (!parts) return;
420
+ const pre = clean(parts[0]);
421
+ const post = clean(parts[1]);
422
+
423
+ if (!pre && post) {
424
+ // Enter at the very start of a filled block pushes it down.
425
+ mutate(() => {
426
+ value = [...value.slice(0, i), newBlock(b.type, '', { indent: b.indent }), ...value.slice(i)];
427
+ });
428
+ return;
429
+ }
430
+ const type: BlockType = /^h\d$/.test(b.type) ? 'p' : b.type;
431
+ const nb = newBlock(type, post, { indent: b.indent, ...(type === 'todo' ? { checked: false } : {}) });
432
+ mutate(() => {
433
+ b.html = pre;
434
+ value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
435
+ });
436
+ focusBlock(nb.id, 'start');
437
+ }
438
+
439
+ function goTo(i: number, up: boolean, x: number) {
440
+ const t = value[i];
441
+ if (!t) return;
442
+ const te = el(t.id);
443
+ if (!te) return selectOnly(t.id);
444
+ const r = te.getBoundingClientRect();
445
+ if (!caretAt(te, x, up ? r.bottom - 3 : r.top + 3)) setCaret(te, up ? 'end' : 'start');
446
+ }
447
+
448
+ function onKeydown(e: KeyboardEvent, b: Block) {
449
+ if (e.isComposing || e.keyCode === 229) return;
450
+ const ce = e.currentTarget as HTMLElement;
451
+ const mod = e.metaKey || e.ctrlKey;
452
+ const key = e.key;
453
+ const i = idx(b.id);
454
+
455
+ if (slash && handleSlashKey(e)) return;
456
+
457
+ if (mod) {
458
+ const k = key.toLowerCase();
459
+ let handled = true;
460
+ if (k === 'z') {
461
+ if (e.shiftKey) redo();
462
+ else undo();
463
+ } else if (k === 'y') redo();
464
+ else if (k === 'b') fmt('bold');
465
+ else if (k === 'i') fmt('italic');
466
+ else if (k === 'u') fmt('underline');
467
+ else if (k === 'e') fmt('code');
468
+ else if (k === 'k') fmt('link');
469
+ else if (k === '\\') fmt('plain');
470
+ else if (k === 'x' && e.shiftKey) fmt('strikeThrough');
471
+ else if (k === 'a' && (!ce.textContent || getSelection()?.toString() === ce.textContent)) {
472
+ selA = value[0].id;
473
+ selH = value[value.length - 1].id;
474
+ blurActive();
475
+ root.focus();
476
+ } else if (e.shiftKey && (key === 'ArrowUp' || key === 'ArrowDown')) nudge([b.id], key === 'ArrowUp' ? -1 : 1);
477
+ else if (key === 'Enter' && b.type === 'code') {
478
+ const nb = newBlock();
479
+ mutate(() => (value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)]));
480
+ focusBlock(nb.id, 'start');
481
+ } else handled = false;
482
+ if (handled) return e.preventDefault();
483
+ }
484
+
485
+ if (key === 'Escape') {
486
+ e.preventDefault();
487
+ return selectOnly(b.id);
488
+ }
489
+
490
+ if (key === 'Enter') {
491
+ e.preventDefault();
492
+ if (b.type === 'code') {
493
+ if (e.shiftKey) {
494
+ const nb = newBlock();
495
+ mutate(() => (value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)]));
496
+ focusBlock(nb.id, 'start');
497
+ } else document.execCommand('insertLineBreak');
498
+ } else if (e.shiftKey) document.execCommand('insertLineBreak');
499
+ else enter(b, ce);
500
+ return;
501
+ }
502
+
503
+ if (key === 'Tab') {
504
+ e.preventDefault();
505
+ const d = e.shiftKey ? -1 : 1;
506
+ const n = Math.max(0, Math.min(MAX_INDENT, b.indent + d));
507
+ if (n !== b.indent) {
508
+ const off = caretOffset(ce);
509
+ mutate(() => (b.indent = n));
510
+ focusBlock(b.id, off);
511
+ }
512
+ return;
513
+ }
514
+
515
+ if (key === 'Backspace' && atStart(ce)) {
516
+ e.preventDefault();
517
+ if (b.indent > 0) return mutate(() => b.indent--);
518
+ if (b.type !== 'p') return mutate(() => convert(b, 'p'));
519
+ if (i === 0) return;
520
+ const prev = value[i - 1];
521
+ if (prev.type === 'hr') return mutate(() => (value = value.filter((x) => x.id !== prev.id)));
522
+ if (prev.type === 'plugin') return selectOnly(prev.id);
523
+ const off = textLen(prev.html);
524
+ const merged = prev.html + clean(ce.innerHTML);
525
+ mutate(() => {
526
+ prev.html = merged;
527
+ value = value.filter((x) => x.id !== b.id);
528
+ });
529
+ return void focusBlock(prev.id, off);
530
+ }
531
+
532
+ if (key === 'Delete' && atEnd(ce)) {
533
+ const next = value[i + 1];
534
+ if (!next) return;
535
+ e.preventDefault();
536
+ if (next.type === 'hr') return mutate(() => (value = value.filter((x) => x.id !== next.id)));
537
+ if (next.type === 'plugin') return selectOnly(next.id);
538
+ const off = textLen(ce.innerHTML);
539
+ const merged = clean(ce.innerHTML) + next.html;
540
+ mutate(() => {
541
+ b.html = merged;
542
+ value = value.filter((x) => x.id !== next.id);
543
+ });
544
+ return void focusBlock(b.id, off);
545
+ }
546
+
547
+ // Shift+arrow past the start or end of the block's text continues as a selection of whole blocks.
548
+ if ((key === 'ArrowUp' || key === 'ArrowDown') && e.shiftKey && !e.altKey && !mod) {
549
+ const up = key === 'ArrowUp';
550
+ const next = value[i + (up ? -1 : 1)];
551
+ if (next && focusAtEdge(ce, up)) {
552
+ e.preventDefault();
553
+ selA = b.id;
554
+ selH = next.id;
555
+ slash = null;
556
+ blurActive();
557
+ root.focus({ preventScroll: true });
558
+ }
559
+ return;
560
+ }
561
+
562
+ if ((key === 'ArrowUp' || key === 'ArrowDown') && !e.shiftKey && !e.altKey && !mod) {
563
+ const up = key === 'ArrowUp';
564
+ if (onEdgeLine(ce, up) && value[i + (up ? -1 : 1)]) {
565
+ e.preventDefault();
566
+ const cr = caretRect();
567
+ goTo(i + (up ? -1 : 1), up, cr ? cr.left : ce.getBoundingClientRect().left);
568
+ }
569
+ return;
570
+ }
571
+ if (key === 'ArrowLeft' && !e.shiftKey && !mod && atStart(ce) && i > 0) {
572
+ e.preventDefault();
573
+ const p = value[i - 1];
574
+ return atomic(p) ? selectOnly(p.id) : void focusBlock(p.id, 'end');
575
+ }
576
+ if (key === 'ArrowRight' && !e.shiftKey && !mod && atEnd(ce) && i < value.length - 1) {
577
+ e.preventDefault();
578
+ const n = value[i + 1];
579
+ return atomic(n) ? selectOnly(n.id) : void focusBlock(n.id, 'start');
580
+ }
581
+ }
582
+
583
+ function onBeforeInput(e: InputEvent) {
584
+ if (e.inputType === 'historyUndo') {
585
+ e.preventDefault();
586
+ undo();
587
+ } else if (e.inputType === 'historyRedo') {
588
+ e.preventDefault();
589
+ redo();
590
+ }
591
+ }
592
+
593
+ function onInput(e: Event, b: Block) {
594
+ const ce = e.currentTarget as HTMLElement;
595
+ if (ce.innerHTML === '<br>') ce.innerHTML = '';
596
+ const text = ce.textContent ?? '';
597
+
598
+ if (b.type === 'p' && (e as InputEvent).inputType === 'insertText') {
599
+ const m = /^(#{1,6}|[-*+]|\d{1,3}\.|\[\s?\]|>)[\s ]$/.exec(text);
600
+ const t = m?.[1];
601
+ const type: BlockType | null = !t
602
+ ? null
603
+ : t[0] === '#' ? (`h${t.length}` as BlockType) : t === '>' ? 'quote' : t[0] === '[' ? 'todo' : /\d/.test(t[0]) ? 'ol' : 'ul';
604
+ if (type && allowed.has(type)) {
605
+ mutate(() => {
606
+ ce.innerHTML = '';
607
+ b.html = '';
608
+ convert(b, type);
609
+ });
610
+ slash = null;
611
+ return void focusBlock(b.id, 'start');
612
+ }
613
+ }
614
+ typed();
615
+ b.html = ce.innerHTML;
616
+ detectSlash(ce, b);
617
+ }
618
+
619
+ function onPaste(e: ClipboardEvent, b: Block) {
620
+ const text = e.clipboardData?.getData('text/plain');
621
+ if (text == null) return;
622
+ e.preventDefault();
623
+ const ce = e.currentTarget as HTMLElement;
624
+ const norm = text.replace(/\r/g, '');
625
+
626
+ if (b.type === 'code' || !norm.includes('\n')) {
627
+ norm.split('\n').forEach((line, n) => {
628
+ if (n) document.execCommand('insertLineBreak');
629
+ if (line) document.execCommand('insertText', false, line);
630
+ });
631
+ return;
632
+ }
633
+ const parsed = fit(fromMarkdown(norm));
634
+ if (!parsed.length) return;
635
+ const parts = splitAtCaret(ce);
636
+ if (!parts) return;
637
+ const pre = clean(parts[0]);
638
+ const post = clean(parts[1]);
639
+ const i = idx(b.id);
640
+
641
+ let rest = parsed;
642
+ const first = parsed[0];
643
+ let curHtml = pre;
644
+ if (!pre && first.type !== 'hr') {
645
+ b.type = first.type;
646
+ b.indent = first.indent;
647
+ if (first.type === 'todo') b.checked = first.checked;
648
+ curHtml = first.html;
649
+ rest = parsed.slice(1);
650
+ } else if (first.type === 'p') {
651
+ curHtml = pre + first.html;
652
+ rest = parsed.slice(1);
653
+ }
654
+ const target = rest.length ? rest[rest.length - 1] : b;
655
+ const off = textLen(target === b ? curHtml : target.html);
656
+ mutate(() => {
657
+ if (target === b) curHtml += post;
658
+ else target.html += post;
659
+ b.html = curHtml;
660
+ value = [...value.slice(0, i + 1), ...rest, ...value.slice(i + 1)];
661
+ });
662
+ focusBlock(target.id, off);
663
+ }
664
+
665
+ /* ---------------------------------------------------------- inline format */
666
+
667
+ function fmt(cmd: Cmd) {
668
+ const ce = focusedId ? el(focusedId) : null;
669
+ const b = focusedId ? value[idx(focusedId)] : null;
670
+ if (!ce || !b) return;
671
+ typed();
672
+ if (cmd === 'code') toggleCode(ce);
673
+ else if (cmd === 'plain') plainText(ce);
674
+ else if (cmd === 'link') {
675
+ const sel = getSelection()!;
676
+ if (!sel.rangeCount) return;
677
+ return void askLink(ce, b, sel.getRangeAt(0).cloneRange());
678
+ } else document.execCommand(cmd);
679
+ b.html = ce.innerHTML;
680
+ }
681
+
682
+ /** The link field below the selection; the selection is put back before the link is set (or removed with ''). */
683
+ async function askLink(ce: HTMLElement, b: Block, saved: Range) {
684
+ const r = saved.getBoundingClientRect();
685
+ const node = saved.commonAncestorContainer;
686
+ const href = (node instanceof Element ? node : node.parentElement)?.closest('a')?.getAttribute('href') ?? '';
687
+ const url: string | undefined = await popups.open.component(LinkPrompt, { href, tr }, { pos: { clientX: r.left, clientY: r.bottom } });
688
+ if (url === undefined) return;
689
+ ce.focus({ preventScroll: true });
690
+ const sel = getSelection()!;
691
+ sel.removeAllRanges();
692
+ sel.addRange(saved);
693
+ typed();
694
+ if (url === '') document.execCommand('unlink');
695
+ else document.execCommand('createLink', false, /^(https?:|mailto:|tel:|\/(?!\/))/.test(url) ? url : 'https://' + url);
696
+ b.html = ce.innerHTML;
697
+ }
698
+
699
+ function onSelChange() {
700
+ if (slash) {
701
+ const e = el(slash.id);
702
+ const b = value[idx(slash.id)];
703
+ if (e && b) detectSlash(e, b);
704
+ else slash = null;
705
+ }
706
+ const sel = getSelection();
707
+ if (readonly || !sel || sel.isCollapsed || !sel.rangeCount) return void (toolbar = null);
708
+ const r = sel.getRangeAt(0);
709
+ const a = r.commonAncestorContainer;
710
+ const host = (a instanceof Element ? a : a.parentElement)?.closest('.ce');
711
+ if (!host || host.closest('.editor') !== root) return void (toolbar = null);
712
+ const rect = r.getBoundingClientRect();
713
+ toolbar = { x: rect.left + rect.width / 2, y: rect.top };
714
+ }
715
+
716
+ /* --------------------------------------------------------- block selection */
717
+
718
+ function blurActive() {
719
+ (document.activeElement as HTMLElement | null)?.blur?.();
720
+ }
721
+ function selectOnly(id: string) {
722
+ selA = selH = id;
723
+ slash = null;
724
+ blurActive();
725
+ root.focus({ preventScroll: true });
726
+ root.querySelector(`[data-block="${id}"]`)?.scrollIntoView({ block: 'nearest' });
727
+ }
728
+
729
+ function deleteSelected() {
730
+ const ids = new Set(selSet);
731
+ if (!ids.size) return;
732
+ const first = value.findIndex((b) => ids.has(b.id));
733
+ mutate(() => {
734
+ let rest = value.filter((b) => !ids.has(b.id));
735
+ if (!rest.length) rest = [newBlock()];
736
+ value = rest;
737
+ });
738
+ selA = selH = null;
739
+ const t = value[first - 1] ?? value[0];
740
+ if (atomic(t)) selectOnly(t.id);
741
+ else focusBlock(t.id, first > 0 ? 'end' : 'start');
742
+ }
743
+
744
+ /** The bubble's delete: the whole block selection if this block is in it, otherwise just this block. */
745
+ function deleteBlock(b: Block) {
746
+ if (!selSet.has(b.id)) selectOnly(b.id);
747
+ deleteSelected();
748
+ }
749
+
750
+ function nudge(ids: string[], dir: -1 | 1) {
751
+ const pos = value.map((b, i) => (ids.includes(b.id) ? i : -1)).filter((i) => i >= 0);
752
+ if (!pos.length) return;
753
+ const lo = pos[0];
754
+ const hi = pos[pos.length - 1];
755
+ if ((dir < 0 && lo === 0) || (dir > 0 && hi === value.length - 1)) return;
756
+ const fid = focusedId;
757
+ const fe = fid ? el(fid) : null;
758
+ const off = fe ? caretOffset(fe) : 0;
759
+ mutate(() => {
760
+ const arr = [...value];
761
+ const seg = arr.splice(lo, hi - lo + 1);
762
+ arr.splice(lo + dir, 0, ...seg);
763
+ value = arr;
764
+ });
765
+ if (fid) focusBlock(fid, off);
766
+ }
767
+
768
+ function onRootKey(e: KeyboardEvent) {
769
+ if (e.target !== root || !selSet.size || !selH) return;
770
+ if (onTurnKey(e)) return;
771
+ const mod = e.metaKey || e.ctrlKey;
772
+ const key = e.key;
773
+ const h = idx(selH);
774
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
775
+ e.preventDefault();
776
+ const d = key === 'ArrowUp' ? -1 : 1;
777
+ if (mod && e.shiftKey) return nudge([...selSet], d);
778
+ const n = Math.max(0, Math.min(value.length - 1, h + d));
779
+ selH = value[n].id;
780
+ if (!e.shiftKey) selA = selH;
781
+ root.querySelector(`[data-block="${selH}"]`)?.scrollIntoView({ block: 'nearest' });
782
+ } else if (key === 'Backspace' || key === 'Delete') {
783
+ e.preventDefault();
784
+ deleteSelected();
785
+ } else if (key === 'Enter') {
786
+ e.preventDefault();
787
+ const b = value[h];
788
+ selA = selH = null;
789
+ if (el(b.id)) focusBlock(b.id, 'end');
790
+ else {
791
+ const nb = newBlock();
792
+ mutate(() => (value = [...value.slice(0, h + 1), nb, ...value.slice(h + 1)]));
793
+ focusBlock(nb.id, 'start');
794
+ }
795
+ } else if (key === 'Escape') {
796
+ const b = value[h];
797
+ selA = selH = null;
798
+ if (el(b.id)) focusBlock(b.id, 'end');
799
+ } else if (mod && key.toLowerCase() === 'a') {
800
+ e.preventDefault();
801
+ selA = value[0].id;
802
+ selH = value[value.length - 1].id;
803
+ } else if (mod && key.toLowerCase() === 'z') {
804
+ e.preventDefault();
805
+ if (e.shiftKey) redo();
806
+ else undo();
807
+ } else if (mod && key.toLowerCase() === 'y') {
808
+ e.preventDefault();
809
+ redo();
810
+ }
811
+ }
812
+
813
+ function onCopy(e: ClipboardEvent, cut = false) {
814
+ if (!selSet.size) return;
815
+ e.preventDefault();
816
+ e.clipboardData?.setData('text/plain', toMarkdown(value.filter((b) => selSet.has(b.id)), { plugins }));
817
+ if (cut) deleteSelected();
818
+ }
819
+
820
+ /** A click on the handle selects the block (shift: extends the selection); the menu comes from the bubble (openTurn). */
821
+ function onHandleClick(e: MouseEvent, b: Block, menu = false) {
822
+ if (e.shiftKey && selA) {
823
+ selH = b.id;
824
+ blurActive();
825
+ root.focus({ preventScroll: true });
826
+ return;
827
+ }
828
+ // Clicking inside a block selection keeps it, so the menu acts on all of those blocks.
829
+ if (selSet.has(b.id)) {
830
+ blurActive();
831
+ root.focus({ preventScroll: true });
832
+ } else selectOnly(b.id);
833
+ if (!menu) return;
834
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
835
+ const up = r.bottom + 340 > innerHeight && r.top > 340;
836
+ turn = { x: Math.min(r.right + 6, innerWidth - 250), y: up ? r.bottom : r.top, up, index: 0 };
837
+ }
838
+
839
+ /* ------------------------------------------------------ properties panel */
840
+
841
+ // Plugin blocks and code blocks show their tag and fields on their top (PropsBar); read-only, a code block shows its
842
+ // language in its corner.
843
+ // Free text, or a dropdown when the host gives a list (provideCodeLanguages). A stored language missing from the
844
+ // list stays an option, so opening an older document loses nothing.
845
+ const codeLanguages = useCodeLanguages();
846
+ const codeFields = (b: Block): PropField[] => {
847
+ const list = codeLanguages();
848
+ if (!list.length)
849
+ // Only characters an opening fence can carry (```c++, ```objective-c).
850
+ return [{ key: 'lang', label: 'Language', kind: 'text', placeholder: 'e.g. ts, py, css', parse: (s) => s.replace(/[^\w+#.-]/g, '') }];
851
+ const lang = typeof b.props?.lang === 'string' ? b.props.lang : '';
852
+ const options = [{ value: '', label: 'None' }, ...list.map((l) => ({ value: l.value, label: l.label ?? l.value }))];
853
+ if (lang && !list.some((l) => l.value === lang)) options.push({ value: lang, label: lang });
854
+ return [{ key: 'lang', label: 'Language', kind: 'select', options }];
855
+ };
856
+ const codeTag = (b: Block) => [tr('code'), b.props?.lang].filter(Boolean).join(' · ');
857
+ function tagOf(b: Block) {
858
+ const p = pluginOf(b.plugin);
859
+ return p ? p.tag(b.props ?? {}).split(' · ').map((s) => tr(s)).join(' · ') : (b.plugin ?? '');
860
+ }
861
+
862
+ const fieldText = (b: Block, f: PropField) => {
863
+ const v = b.props?.[f.key];
864
+ return f.format ? f.format(v) : v == null ? '' : String(v);
865
+ };
866
+
867
+ /** Sets one property to a value as it is ('' or undefined removes it). */
868
+ function setValue(b: Block, key: string, v: unknown) {
869
+ typed();
870
+ const next = { ...(b.props ?? {}) };
871
+ if (v === '' || v == null) delete next[key];
872
+ else next[key] = v;
873
+ b.props = next;
874
+ }
875
+ /** Sets one property from its field's text ('' removes it) and returns the text as it is now kept. */
876
+ function setProp(b: Block, f: PropField, raw: string) {
877
+ const v = f.parse ? f.parse(raw) : raw;
878
+ typed();
879
+ const next = { ...(b.props ?? {}) };
880
+ if (v === '' || v == null) delete next[f.key];
881
+ else next[f.key] = v;
882
+ b.props = next;
883
+ return fieldText(b, f);
884
+ }
885
+
886
+ /* --------------------------------------------------------- turn-into menu */
887
+
888
+ // Opened from the handle. What a block can turn into depends on what it holds (see Content):
889
+ // - text <-> text keeps the formatting; text -> plain (code) keeps only the letters; plain -> text as is;
890
+ // - a type without content (divider, map, image) only replaces empty blocks or other contentless ones;
891
+ // - a type with nested blocks (box) wraps the selected blocks; "Unwrap" moves a box's blocks out;
892
+ // - a box can become another box-like type with its blocks; same-named props carry over.
893
+ type TurnOption =
894
+ | { kind: 'type'; item: PaletteItem; type: BlockType; current: boolean; lossy: boolean }
895
+ | { kind: 'wrap'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
896
+ | { kind: 'retype'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
897
+ | { kind: 'unwrap' }
898
+ | { kind: 'delete' };
899
+
900
+ let turn = $state<{ x: number; y: number; up: boolean; index: number } | null>(null);
901
+
902
+ /** Null for an unknown plugin type: it can only be deleted. */
903
+ const contentOf = (b: Block): Content | null =>
904
+ b.type === 'plugin' ? (pluginOf(b.plugin)?.content ?? null) : BUILT_IN_CONTENT[b.type];
905
+ const emptyOrNone = (b: Block) => contentOf(b) === 'none' || ((contentOf(b) === 'text' || contentOf(b) === 'plain') && !b.html);
906
+ const formatted = (html: string) => /<(?!br\b)[a-z]/i.test(html);
907
+
908
+ const selected = $derived(value.filter((b) => selSet.has(b.id)));
909
+ /** A block's palette id (as stored: 'md:p', or the plugin's type). */
910
+ const paletteId = (b: Block) => (b.type === 'plugin' ? (b.plugin ?? '') : MD + b.type);
911
+
912
+ const turnOptions = $derived.by((): TurnOption[] => {
913
+ const sel = selected;
914
+ if (!sel.length) return [];
915
+ const kinds = sel.map(contentOf);
916
+ const known = kinds.every((k) => k !== null);
917
+ // No nested blocks among them: these can change type or be wrapped.
918
+ const flat = known && kinds.every((k) => k !== 'blocks');
919
+ const single = sel.length === 1 ? sel[0] : null;
920
+ const out: TurnOption[] = [];
921
+ for (const item of items) {
922
+ const plugin = pluginOf(item.id);
923
+ if (plugin) {
924
+ const current = single?.plugin === plugin.type;
925
+ if (plugin.content === 'blocks') {
926
+ // only into a container that takes every block going in
927
+ if (flat && sel.every((b) => acceptsId(plugin.accepts, paletteId(b)))) out.push({ kind: 'wrap', item, plugin, current: false });
928
+ else if (single && kinds[0] === 'blocks' && (single.blocks ?? []).every((s) => acceptsId(plugin.accepts, s.type)))
929
+ out.push({ kind: 'retype', item, plugin, current });
930
+ } else if (plugin.content === 'none' && known && sel.every(emptyOrNone)) {
931
+ out.push({ kind: 'retype', item, plugin, current });
932
+ }
933
+ continue;
934
+ }
935
+ const type = item.id.slice(MD.length) as BlockType;
936
+ const to = BUILT_IN_CONTENT[type as Exclude<BlockType, 'plugin'>];
937
+ if (!flat || (to === 'none' && !sel.every(emptyOrNone))) continue;
938
+ const lossy = to === 'plain' && sel.some((b) => contentOf(b) === 'text' && formatted(b.html));
939
+ out.push({ kind: 'type', item, type, current: single?.type === type, lossy });
940
+ }
941
+ if (known && kinds.includes('blocks')) out.push({ kind: 'unwrap' });
942
+ out.push({ kind: 'delete' });
943
+ return out;
944
+ });
945
+
946
+ $effect(() => {
947
+ if (!selA) turn = null;
948
+ });
949
+
950
+ function applyTurn(n: number) {
951
+ const opt = turnOptions[n];
952
+ turn = null;
953
+ if (!opt) return;
954
+ const sel = selected;
955
+ if (opt.kind === 'delete') return deleteSelected();
956
+ if (opt.kind === 'unwrap') return unwrap(sel);
957
+ if (opt.kind === 'wrap') return wrap(sel, opt.plugin);
958
+ if (opt.kind === 'retype') return mutate(() => sel.forEach((b) => toPlugin(b, opt.plugin)));
959
+ const type = opt.type;
960
+ mutate(() => sel.forEach((b) => toBuiltIn(b, type)));
961
+ if (sel.length === 1 && BUILT_IN_CONTENT[type as Exclude<BlockType, 'plugin'>] !== 'none') {
962
+ selA = selH = null;
963
+ focusBlock(sel[0].id, 'end');
964
+ }
965
+ }
966
+
967
+ function toBuiltIn(b: Block, type: BlockType) {
968
+ const from = contentOf(b);
969
+ const to = BUILT_IN_CONTENT[type as Exclude<BlockType, 'plugin'>];
970
+ if (from === 'text' && to === 'plain') b.html = plainHtml(b.html);
971
+ if (from === 'none' || to === 'none') b.html = '';
972
+ convert(b, type);
973
+ }
974
+
975
+ function toPlugin(b: Block, plugin: BlockPlugin) {
976
+ const defaults = plugin.props();
977
+ const old = b.type === 'plugin' ? (b.props ?? {}) : {};
978
+ b.props = Object.fromEntries(Object.entries(defaults).map(([k, v]) => [k, k in old ? old[k] : v]));
979
+ b.type = 'plugin';
980
+ b.plugin = plugin.type;
981
+ fresh.add(b.id);
982
+ b.html = '';
983
+ delete b.checked;
984
+ if (plugin.content === 'blocks') b.blocks ??= [{ type: 'md:p', text: '', indent: 0 }];
985
+ else delete b.blocks;
986
+ }
987
+
988
+ /** Puts the selected blocks, in order and with their relative indent, into one new block of `plugin`. */
989
+ async function wrap(sel: Block[], plugin: BlockPlugin) {
990
+ const base = Math.min(...sel.map((b) => b.indent));
991
+ const blocks = sel.map((b) => ({ ...($state.snapshot(toStored(b)) as StoredBlock), indent: b.indent - base }));
992
+ const pb = newBlock('plugin', '', { plugin: plugin.type, indent: base, props: plugin.props(), blocks });
993
+ const at = idx(sel[0].id);
994
+ const ids = new Set(sel.map((b) => b.id));
995
+ mutate(() => {
996
+ const rest = value.filter((b) => !ids.has(b.id));
997
+ value = [...rest.slice(0, at), pb, ...rest.slice(at)];
998
+ });
999
+ await tick();
1000
+ selectOnly(pb.id);
1001
+ }
1002
+
1003
+ /** Replaces each selected nested-block block with its inner blocks; the others stay. */
1004
+ function unwrap(sel: Block[]) {
1005
+ const ids = new Set(sel.filter((b) => contentOf(b) === 'blocks').map((b) => b.id));
1006
+ mutate(() => {
1007
+ const next = value.flatMap((b) => {
1008
+ if (!ids.has(b.id)) return [b];
1009
+ return fit((b.blocks ?? []).map(fromStored)).map((k) => {
1010
+ k.indent = Math.min(MAX_INDENT, k.indent + b.indent);
1011
+ return k;
1012
+ });
1013
+ });
1014
+ value = next.length ? next : [newBlock()];
1015
+ });
1016
+ selA = selH = null;
1017
+ }
1018
+
1019
+ const optionKey = (o: TurnOption) => ('item' in o ? o.kind + ':' + o.item.id : o.kind);
1020
+ function optionView(o: TurnOption): { icon: Icon; label: string; hint: string } {
1021
+ const n = selected.length;
1022
+ if (o.kind === 'delete') return { icon: Trash2, label: tr('Delete'), hint: n > 1 ? tr('{n} blocks', { n }) : tr('Remove this block') };
1023
+ if (o.kind === 'unwrap') return { icon: Ungroup, label: tr('Unwrap'), hint: tr('Move the inner blocks out') };
1024
+ 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') };
1025
+ if (o.kind === 'type' && o.lossy) return { icon: o.item.icon, label: o.item.label, hint: tr('Loses formatting') };
1026
+ return { icon: o.item.icon, label: o.item.label, hint: o.item.hint };
1027
+ }
1028
+
1029
+ function onTurnKey(e: KeyboardEvent): boolean {
1030
+ if (!turn) return false;
1031
+ const turnCount = turnOptions.length;
1032
+ if (e.key === 'ArrowDown') turn.index = (turn.index + 1) % turnCount;
1033
+ else if (e.key === 'ArrowUp') turn.index = (turn.index - 1 + turnCount) % turnCount;
1034
+ else if (e.key === 'Enter') applyTurn(turn.index);
1035
+ else if (e.key === 'Escape') turn = null;
1036
+ else return false;
1037
+ e.preventDefault();
1038
+ return true;
1039
+ }
1040
+
1041
+ /* ------------------------------------------------- selection across blocks */
1042
+
1043
+ // A text selection dragged out of its block turns into a selection of whole blocks (each block is its own
1044
+ // editable field, so the browser cannot select text across them). Nested editors run the same logic, so a
1045
+ // sweep that leaves a box is taken over by the editor around it, with the box as one block.
1046
+ let sweeping = $state(false);
1047
+ let sweepFrom: Element | null = null;
1048
+
1049
+ function startSweep(e: MouseEvent) {
1050
+ if (readonly || e.button !== 0 || e.shiftKey) return;
1051
+ const t = e.target as Element;
1052
+ if (!t.closest('.ce') || t.closest('.grab')) return;
1053
+ sweepFrom = t;
1054
+ addEventListener('mousemove', sweep);
1055
+ addEventListener('mouseup', endSweep, { once: true });
1056
+ }
1057
+
1058
+ function sweep(e: MouseEvent) {
1059
+ const from = ownRow(sweepFrom);
1060
+ const here = document.elementFromPoint(e.clientX, e.clientY);
1061
+ if (!from || !here) return;
1062
+ const row = root.contains(here) ? ownRow(here) : null;
1063
+ // Left this editor, or came back to the starting block: back to plain text selection.
1064
+ if (!root.contains(here) || row === from) {
1065
+ if (sweeping) {
1066
+ sweeping = false;
1067
+ selA = selH = null;
1068
+ }
1069
+ return;
1070
+ }
1071
+ if (!row) return;
1072
+ if (!sweeping) {
1073
+ sweeping = true;
1074
+ blurActive();
1075
+ root.focus({ preventScroll: true });
1076
+ }
1077
+ getSelection()?.removeAllRanges();
1078
+ selA = from.dataset.block!;
1079
+ selH = row.dataset.block!;
1080
+ }
1081
+
1082
+ function endSweep() {
1083
+ removeEventListener('mousemove', sweep);
1084
+ sweepFrom = null;
1085
+ sweeping = false;
1086
+ }
1087
+
1088
+ /* ---------------------------------------------------------------- dragging */
1089
+
1090
+ function onDragStart(e: DragEvent, b: Block) {
1091
+ const ids = selSet.has(b.id) ? value.filter((x) => selSet.has(x.id)).map((x) => x.id) : [b.id];
1092
+ const moving = value.filter((x) => ids.includes(x.id));
1093
+ hub.src = {
1094
+ editor: token,
1095
+ ids,
1096
+ payload: moving.map((x) => $state.snapshot(toStored(x)) as StoredBlock),
1097
+ rows: ids.map((id) => root.querySelector<HTMLElement>(`[data-block="${id}"]`)!).filter(Boolean),
1098
+ remove: () => mutate(() => (value = value.filter((x) => !ids.includes(x.id)).concat(value.length === ids.length ? [newBlock()] : [])))
1099
+ };
1100
+ hub.target = null;
1101
+ if (!e.dataTransfer) return;
1102
+ e.dataTransfer.effectAllowed = 'move';
1103
+ e.dataTransfer.setData('text/plain', ' ');
1104
+ const row = (e.currentTarget as HTMLElement).closest('.row');
1105
+ if (row) e.dataTransfer.setDragImage(row, 0, 0);
1106
+ }
1107
+
1108
+ /** Can the dragged blocks land in this editor? Not inside themselves, and only plugins this editor offers. */
1109
+ function accepts(src: DragSource) {
1110
+ if (src.editor === token) return true;
1111
+ if (src.rows.some((r) => r.contains(root))) return false;
1112
+ return src.payload.every((s) => isMd(s) || (allowed.has(s.type) && !!pluginOf(s.type)));
1113
+ }
1114
+
1115
+ function onDragOver(e: DragEvent) {
1116
+ // A nested editor deeper in the tree already took this event.
1117
+ if (!hub.src || e.defaultPrevented) return;
1118
+ const row = ownRow(e.target as Element);
1119
+ if (!row || !accepts(hub.src)) return;
1120
+ e.preventDefault();
1121
+ const r = row.getBoundingClientRect();
1122
+ hub.target = { editor: token, over: row.dataset.block!, after: e.clientY > r.top + r.height / 2 };
1123
+ }
1124
+
1125
+ function onDrop(e: DragEvent) {
1126
+ const src = hub.src;
1127
+ const at = hub.target;
1128
+ if (!src || at?.editor !== token) return;
1129
+ e.preventDefault();
1130
+ hub.src = hub.target = null;
1131
+ const { over, after } = at;
1132
+ if (src.ids.includes(over)) return;
1133
+ if (src.editor === token) {
1134
+ mutate(() => {
1135
+ const moving = value.filter((b) => src.ids.includes(b.id));
1136
+ const rest = value.filter((b) => !src.ids.includes(b.id));
1137
+ const t = rest.findIndex((b) => b.id === over) + (after ? 1 : 0);
1138
+ value = [...rest.slice(0, t), ...moving, ...rest.slice(t)];
1139
+ });
1140
+ return;
1141
+ }
1142
+ // From another editor: copy in through the stored form, then remove at the source.
1143
+ const incoming = fit(src.payload.map(fromStored));
1144
+ mutate(() => {
1145
+ const t = idx(over) + (after ? 1 : 0);
1146
+ value = [...value.slice(0, t), ...incoming, ...value.slice(t)];
1147
+ });
1148
+ src.remove();
1149
+ }
1150
+
1151
+ const dropMark = (id: string, after: boolean) =>
1152
+ hub.target?.editor === token && hub.target.over === id && hub.target.after === after && !hub.src?.ids.includes(id);
1153
+
1154
+ /* ------------------------------------------------------------------- misc */
1155
+
1156
+ /** The + button: a new paragraph below (or this one, if it is an empty paragraph) with the block menu open. */
1157
+ async function addBelow(b: Block) {
1158
+ let target = b;
1159
+ if (!(b.type === 'p' && !b.html)) {
1160
+ target = newBlock('p', '', { indent: b.indent });
1161
+ const i = idx(b.id);
1162
+ mutate(() => (value = [...value.slice(0, i + 1), target, ...value.slice(i + 1)]));
1163
+ }
1164
+ await tick();
1165
+ const ce = el(target.id);
1166
+ if (!ce) return;
1167
+ setCaret(ce, 'end');
1168
+ document.execCommand('insertText', false, '/');
1169
+ }
1170
+
1171
+ function tailClick() {
1172
+ const l = value[value.length - 1];
1173
+ if (l.type === 'p' && !l.html) return void focusBlock(l.id);
1174
+ const nb = newBlock();
1175
+ mutate(() => (value = [...value, nb]));
1176
+ focusBlock(nb.id);
1177
+ }
1178
+
1179
+ function toggleTodo(b: Block) {
1180
+ mutate(() => (b.checked = !b.checked));
1181
+ }
1182
+ </script>
1183
+
1184
+ <svelte:document
1185
+ onselectionchange={onSelChange}
1186
+ onmousedown={(e) => {
1187
+ const t = e.target as Element;
1188
+ if (turn && !t.closest('.menu.turn') && !t.closest('.grab')) turn = null;
1189
+ }}
1190
+ />
1191
+
1192
+ <div
1193
+ class="editor"
1194
+ class:nested
1195
+ class:sweeping
1196
+ role="presentation"
1197
+ bind:this={root}
1198
+ tabindex="-1"
1199
+ onkeydown={onRootKey}
1200
+ oncopy={(e) => onCopy(e)}
1201
+ oncut={(e) => onCopy(e, true)}
1202
+ onmousedown={(e) => {
1203
+ if (selA && !(e.target as HTMLElement).closest('.grab')) selA = selH = null;
1204
+ startSweep(e);
1205
+ }}
1206
+ ondragover={onDragOver}
1207
+ ondrop={onDrop}
1208
+ ondragend={() => (hub.src = hub.target = null)}
1209
+ >
1210
+ {#each value as b, i (b.id)}
1211
+ <div
1212
+ class="row"
1213
+ class:selected={selSet.has(b.id)}
1214
+ class:dragging={hub.src?.editor === token && hub.src.ids.includes(b.id)}
1215
+ class:drop-before={dropMark(b.id, false)}
1216
+ class:drop-after={dropMark(b.id, true)}
1217
+ data-block={b.id}
1218
+ data-type={b.type}
1219
+ style:--indent={b.indent}
1220
+ >
1221
+ {#if !readonly}
1222
+ <div class="gutter">
1223
+ <!-- The handle: a bar the full height of the block (so it shows what a drag moves), the grip icon in its
1224
+ middle. Drag to move, click for the turn-into menu. Hovering it shows a small dark bubble on its right
1225
+ with the block actions (add a block below, turn into, delete). -->
1226
+ <div class="grab">
1227
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
1228
+ <span
1229
+ class="handle"
1230
+ draggable="true"
1231
+ title={tr('Drag to move, click to select')}
1232
+ ondragstart={(e) => onDragStart(e, b)}
1233
+ onclick={(e) => onHandleClick(e, b)}><span class="grip"><GripVertical size={16} /></span></span
1234
+ >
1235
+ <div class="bubble" role="toolbar" aria-label={tr('Block actions')}>
1236
+ <button
1237
+ type="button"
1238
+ title={tr('Add a block below')}
1239
+ aria-label={tr('Add a block below')}
1240
+ onmousedown={(e) => e.preventDefault()}
1241
+ onclick={() => addBelow(b)}><Plus size={15} /></button
1242
+ >
1243
+ <button
1244
+ type="button"
1245
+ title={tr('Turn into')}
1246
+ aria-label={tr('Turn into')}
1247
+ onmousedown={(e) => e.preventDefault()}
1248
+ onclick={(e) => onHandleClick(e, b, true)}><Replace size={15} /></button
1249
+ >
1250
+ <button
1251
+ type="button"
1252
+ class="del"
1253
+ title={tr('Delete')}
1254
+ aria-label={tr('Delete')}
1255
+ onmousedown={(e) => e.preventDefault()}
1256
+ onclick={() => deleteBlock(b)}><Trash2 size={15} /></button
1257
+ >
1258
+ </div>
1259
+ </div>
1260
+ </div>
1261
+ {/if}
1262
+ <div class="body t-{b.type}">
1263
+ {#if b.type === 'hr'}
1264
+ <hr />
1265
+ {:else if b.type === 'plugin'}
1266
+ {@const plugin = pluginOf(b.plugin)}
1267
+ {#if plugin}
1268
+ <div class="plugin">
1269
+ {#snippet propsBar()}
1270
+ <PropsBar block={b} tag={tagOf(b)} fields={plugin.fields ?? []} {tr} {setProp} {setValue} open={fresh.has(b.id)} />
1271
+ {/snippet}
1272
+ <PluginHost
1273
+ {plugin}
1274
+ bind:props={() => b.props ?? {}, (p) => (b.props = p)}
1275
+ bind:blocks={() => b.blocks ?? [], (v) => (b.blocks = v)}
1276
+ {readonly}
1277
+ {palette}
1278
+ {plugins}
1279
+ fields={!readonly && plugin.fields?.length ? propsBar : undefined}
1280
+ />
1281
+ </div>
1282
+ {:else}
1283
+ <div class="unknown">Unknown block: {b.plugin}</div>
1284
+ {/if}
1285
+ {:else}
1286
+ {#if b.type === 'ul'}
1287
+ <span class="marker" aria-hidden="true">{['•', '◦', '▪'][b.indent % 3]}</span>
1288
+ {:else if b.type === 'ol'}
1289
+ <span class="marker num" aria-hidden="true">{nums[b.id]}.</span>
1290
+ {:else if b.type === 'todo'}
1291
+ <button
1292
+ type="button"
1293
+ class="check"
1294
+ class:done={b.checked}
1295
+ role="checkbox"
1296
+ aria-checked={!!b.checked}
1297
+ aria-label={tr('Done')}
1298
+ disabled={readonly}
1299
+ onmousedown={(e) => e.preventDefault()}
1300
+ onclick={() => toggleTodo(b)}
1301
+ >{#if b.checked}<Check size={12} strokeWidth={3} />{/if}</button
1302
+ >
1303
+ {:else if b.type === 'code' && !readonly}
1304
+ <div class="code-fields">
1305
+ <PropsBar block={b} tag={codeTag(b)} fields={codeFields(b)} {tr} {setProp} {setValue} />
1306
+ </div>
1307
+ {/if}
1308
+ <div
1309
+ class="ce"
1310
+ class:empty={!b.html}
1311
+ class:done={b.type === 'todo' && b.checked}
1312
+ role="textbox"
1313
+ aria-multiline="true"
1314
+ tabindex="0"
1315
+ contenteditable={readonly ? 'false' : 'true'}
1316
+ spellcheck={b.type !== 'code'}
1317
+ data-placeholder={hintFor(b.type)}
1318
+ use:rich={b.html}
1319
+ onkeydown={(e) => onKeydown(e, b)}
1320
+ onbeforeinput={onBeforeInput}
1321
+ oninput={(e) => onInput(e, b)}
1322
+ onpaste={(e) => onPaste(e, b)}
1323
+ onfocus={() => {
1324
+ focusedId = b.id;
1325
+ hub.active = token;
1326
+ selA = selH = null;
1327
+ }}
1328
+ onblur={() => {
1329
+ if (focusedId === b.id) focusedId = null;
1330
+ slash = null;
1331
+ }}
1332
+ ></div>
1333
+ {/if}
1334
+ {#if b.type === 'code' && readonly}
1335
+ <!-- read-only: the language of a code block, in its corner -->
1336
+ <div class="badge">
1337
+ <span class="tag">{tr('code')}</span>
1338
+ {#if b.props?.lang}<span class="tag">{b.props.lang}</span>{/if}
1339
+ </div>
1340
+ {:else if b.type === 'plugin' && (readonly || !pluginOf(b.plugin)?.fields?.length)}
1341
+ <div class="badge"><span class="tag">{tagOf(b)}</span></div>
1342
+ {/if}
1343
+ </div>
1344
+ </div>
1345
+ {/each}
1346
+
1347
+ {#if !readonly}
1348
+ <button type="button" class="tail" aria-label={tr('New block at the end')} onclick={tailClick}></button>
1349
+ {/if}
1350
+
1351
+ {#if toolbar}
1352
+ <div class="toolbar" style:left="{toolbar.x}px" style:top="{toolbar.y}px" role="toolbar" aria-label={tr('Formatting')}>
1353
+ {#each tools as t (t.cmd)}
1354
+ <button
1355
+ type="button"
1356
+ title={tr(t.title)}
1357
+ aria-label={tr(t.title)}
1358
+ onmousedown={(e) => e.preventDefault()}
1359
+ onclick={() => fmt(t.cmd)}><t.icon size={16} /></button
1360
+ >
1361
+ {/each}
1362
+ </div>
1363
+ {/if}
1364
+
1365
+ {#if turn}
1366
+ <div
1367
+ class="menu turn"
1368
+ class:up={turn.up}
1369
+ style:left="{turn.x}px"
1370
+ style:top="{turn.y}px"
1371
+ role="listbox"
1372
+ aria-label={tr('Turn into')}
1373
+ >
1374
+ {#if turnOptions[0]?.kind !== 'unwrap' && turnOptions[0]?.kind !== 'delete'}
1375
+ <div class="caption">{tr('Turn into')}</div>
1376
+ {/if}
1377
+ {#each turnOptions as o, n (optionKey(o))}
1378
+ {@const v = optionView(o)}
1379
+ {#if (o.kind === 'unwrap' || o.kind === 'delete') && n > 0 && turnOptions[n - 1].kind !== 'unwrap'}
1380
+ <div class="sep"></div>
1381
+ {/if}
1382
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1383
+ <div
1384
+ class="item"
1385
+ class:danger={o.kind === 'delete'}
1386
+ class:active={n === turn.index}
1387
+ role="option"
1388
+ tabindex="-1"
1389
+ aria-selected={n === turn.index}
1390
+ onmousedown={(e) => {
1391
+ e.preventDefault();
1392
+ applyTurn(n);
1393
+ }}
1394
+ onmouseenter={() => turn && (turn.index = n)}
1395
+ >
1396
+ <span class="icon"><v.icon size={14} /></span>
1397
+ <span class="txt"><b>{v.label}</b><small class:warn={'lossy' in o && o.lossy}>{v.hint}</small></span>
1398
+ {#if 'current' in o && o.current}<span class="current"><Check size={16} /></span>{/if}
1399
+ </div>
1400
+ {/each}
1401
+ </div>
1402
+ {/if}
1403
+
1404
+ {#if slash && filtered.length}
1405
+ <div
1406
+ class="menu"
1407
+ class:up={slash.up}
1408
+ style:left="{slash.x}px"
1409
+ style:top="{slash.y}px"
1410
+ role="listbox"
1411
+ aria-label={tr('Block type')}
1412
+ >
1413
+ {#each filtered as it, n (it.id)}
1414
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1415
+ <div
1416
+ class="item"
1417
+ class:active={n === slash.index}
1418
+ role="option"
1419
+ tabindex="-1"
1420
+ aria-selected={n === slash.index}
1421
+ onmousedown={(e) => {
1422
+ e.preventDefault();
1423
+ applySlash(it);
1424
+ }}
1425
+ onmouseenter={() => slash && (slash.index = n)}
1426
+ >
1427
+ <span class="icon"><it.icon size={14} /></span>
1428
+ <span class="txt"><b>{it.label}</b><small>{it.hint}</small></span>
1429
+ </div>
1430
+ {/each}
1431
+ </div>
1432
+ {/if}
1433
+ </div>
1434
+
1435
+ <!-- the popups of this editor and the ones nested in it -->
1436
+ {#if !nested}<PopupContainer />{/if}
1437
+
1438
+ <style>
1439
+ .editor {
1440
+ --fg: var(--color-surface-contrast, #1d1d1f);
1441
+ --muted: var(--color-muted-contrast, #a0a0a8);
1442
+ --accent: #3b82f6;
1443
+ --sel: rgba(59, 130, 246, 0.14);
1444
+ --surface: var(--color-surface, #fff);
1445
+ --border: var(--color-frame, #e5e5ea);
1446
+ --code-bg: rgba(120, 120, 140, 0.14);
1447
+ position: relative;
1448
+ outline: none;
1449
+ color: var(--fg);
1450
+ font: 17px/1.65 system-ui, -apple-system, 'Segoe UI', sans-serif;
1451
+ padding-bottom: 6rem;
1452
+ }
1453
+ /* While sweeping over whole blocks, the browser must not keep extending its own text selection. */
1454
+ .editor.sweeping,
1455
+ .editor.sweeping :global(*) {
1456
+ user-select: none;
1457
+ }
1458
+ .editor.nested {
1459
+ font-size: 1em;
1460
+ padding-bottom: 0;
1461
+ }
1462
+ .editor.nested > .tail {
1463
+ min-height: 0.75rem;
1464
+ }
1465
+ .plugin {
1466
+ flex: 1;
1467
+ min-width: 0;
1468
+ margin: 0.35em 0;
1469
+ }
1470
+ .unknown {
1471
+ flex: 1;
1472
+ padding: 0.6em 0.9em;
1473
+ border: 1px dashed var(--border);
1474
+ border-radius: 8px;
1475
+ color: var(--muted);
1476
+ font-size: 0.85em;
1477
+ }
1478
+
1479
+ .row {
1480
+ position: relative;
1481
+ display: flex;
1482
+ border-radius: 6px;
1483
+ margin: 0 -8px;
1484
+ padding: 0 8px;
1485
+ }
1486
+ .row.selected {
1487
+ background: var(--sel);
1488
+ }
1489
+ .row.dragging {
1490
+ opacity: 0.35;
1491
+ }
1492
+ .row.drop-before::before,
1493
+ .row.drop-after::after {
1494
+ content: '';
1495
+ position: absolute;
1496
+ left: 8px;
1497
+ right: 8px;
1498
+ height: 3px;
1499
+ border-radius: 2px;
1500
+ background: var(--accent);
1501
+ z-index: 2;
1502
+ }
1503
+ .row.drop-before::before {
1504
+ top: -2px;
1505
+ }
1506
+ .row.drop-after::after {
1507
+ bottom: -2px;
1508
+ }
1509
+
1510
+ /* 1px short at both ends, so the bars of neighbouring blocks do not touch. */
1511
+ .gutter {
1512
+ position: absolute;
1513
+ left: -22px;
1514
+ top: 1px;
1515
+ bottom: 1px;
1516
+ width: 20px;
1517
+ }
1518
+ .handle {
1519
+ height: 100%;
1520
+ display: flex;
1521
+ align-items: center;
1522
+ justify-content: center;
1523
+ user-select: none;
1524
+ color: var(--muted);
1525
+ border-radius: 4px;
1526
+ cursor: grab;
1527
+ transition:
1528
+ background 0.12s,
1529
+ box-shadow 0.12s;
1530
+ }
1531
+ /* The grip icon shows with the block's hover, caret or selection; the bubble comes only from the icon. */
1532
+ .grip {
1533
+ width: 20px;
1534
+ height: 20px;
1535
+ display: flex;
1536
+ align-items: center;
1537
+ justify-content: center;
1538
+ opacity: 0;
1539
+ transition: opacity 0.12s;
1540
+ }
1541
+ /* The mouse: a faint fill with the block's hover, stronger on the bar (below; and while dragging). The block with
1542
+ the typing caret (or the selected one): a thin frame instead (inset, so nothing moves). */
1543
+ .row:hover > .gutter .handle {
1544
+ background: rgba(120, 120, 140, 0.06);
1545
+ }
1546
+ .row:focus-within > .gutter .handle,
1547
+ .row.selected > .gutter .handle {
1548
+ box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.22);
1549
+ }
1550
+ /* with the caret it breathes gently, like the caret blinks; only the innermost block (not the box around it) */
1551
+ .row:focus-within:not(:has(.row:focus-within)) > .gutter .handle {
1552
+ animation: caret-pulse 1.1s ease-in-out infinite alternate;
1553
+ }
1554
+ @keyframes caret-pulse {
1555
+ from {
1556
+ box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.06);
1557
+ }
1558
+ to {
1559
+ box-shadow: inset 0 0 0 1px rgba(120, 120, 140, 0.3);
1560
+ }
1561
+ }
1562
+ @media (prefers-reduced-motion: reduce) {
1563
+ .row:focus-within:not(:has(.row:focus-within)) > .gutter .handle {
1564
+ animation: none;
1565
+ }
1566
+ }
1567
+ .row:hover > .gutter .grip,
1568
+ .row:focus-within > .gutter .grip,
1569
+ .row.selected > .gutter .grip {
1570
+ opacity: 1;
1571
+ }
1572
+ .row > .gutter .handle:hover,
1573
+ .row.dragging > .gutter .handle {
1574
+ background: var(--code-bg);
1575
+ }
1576
+ .grab {
1577
+ position: relative;
1578
+ height: 100%;
1579
+ }
1580
+ /* The block actions: a dark bubble right of the handle, over the start of the block. Its left padding bridges
1581
+ the gap, so the pointer can move from the handle into it without it closing. */
1582
+ .bubble {
1583
+ position: absolute;
1584
+ left: 100%;
1585
+ top: 50%;
1586
+ z-index: 30;
1587
+ display: flex;
1588
+ padding-left: 6px;
1589
+ translate: 0 -50%;
1590
+ opacity: 0;
1591
+ visibility: hidden;
1592
+ transition:
1593
+ opacity 0.12s,
1594
+ visibility 0s 0.12s;
1595
+ }
1596
+ .bubble::before {
1597
+ content: '';
1598
+ position: absolute;
1599
+ left: 2px;
1600
+ top: 50%;
1601
+ translate: 0 -50%;
1602
+ border: 4px solid transparent;
1603
+ border-right-color: var(--color-surface-contrast, #1d1d1f);
1604
+ border-left: 0;
1605
+ }
1606
+ .grab:has(.grip:hover) .bubble,
1607
+ .bubble:hover {
1608
+ opacity: 1;
1609
+ visibility: visible;
1610
+ transition-delay: 0.15s, 0s;
1611
+ }
1612
+ /* Hidden from the press on (so it is not in the drag image) and while sweeping. Only visibility changes here:
1613
+ a layout change inside the row while the drag starts makes Chrome cancel the drag. */
1614
+ .handle:active ~ .bubble,
1615
+ .editor.sweeping .bubble {
1616
+ opacity: 0;
1617
+ visibility: hidden;
1618
+ transition: none;
1619
+ }
1620
+ .bubble button {
1621
+ display: flex;
1622
+ align-items: center;
1623
+ justify-content: center;
1624
+ width: 28px;
1625
+ height: 26px;
1626
+ padding: 0;
1627
+ border: 0;
1628
+ /* inverted: the theme's text colour as the background */
1629
+ color: var(--color-surface, #fff);
1630
+ background: var(--color-surface-contrast, #1d1d1f);
1631
+ cursor: pointer;
1632
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
1633
+ }
1634
+ .bubble button:first-child {
1635
+ border-radius: 6px 0 0 6px;
1636
+ padding-left: 2px;
1637
+ }
1638
+ .bubble button:last-child {
1639
+ border-radius: 0 6px 6px 0;
1640
+ padding-right: 2px;
1641
+ }
1642
+ .bubble button:hover {
1643
+ background: color-mix(in srgb, var(--color-surface-contrast, #1d1d1f) 82%, var(--color-surface, #fff));
1644
+ }
1645
+ .bubble button.del:hover {
1646
+ background: #c62828;
1647
+ }
1648
+
1649
+ .body {
1650
+ flex: 1;
1651
+ min-width: 0;
1652
+ display: flex;
1653
+ align-items: flex-start;
1654
+ gap: 0.5em;
1655
+ padding: 0.15em 0;
1656
+ margin-left: calc(var(--indent, 0) * 1.7em);
1657
+ }
1658
+
1659
+ .ce {
1660
+ flex: 1;
1661
+ min-width: 0;
1662
+ min-height: 1.65em;
1663
+ outline: none;
1664
+ white-space: pre-wrap;
1665
+ word-break: break-word;
1666
+ caret-color: var(--accent);
1667
+ }
1668
+ .ce.empty::before {
1669
+ content: attr(data-placeholder);
1670
+ color: var(--muted);
1671
+ pointer-events: none;
1672
+ }
1673
+ .t-p .ce.empty:not(:focus)::before,
1674
+ .t-ul .ce.empty:not(:focus)::before,
1675
+ .t-ol .ce.empty:not(:focus)::before {
1676
+ content: '';
1677
+ }
1678
+ .ce.done {
1679
+ color: var(--muted);
1680
+ text-decoration: line-through;
1681
+ }
1682
+ .ce :global(code) {
1683
+ background: var(--code-bg);
1684
+ padding: 0.1em 0.35em;
1685
+ border-radius: 4px;
1686
+ font: 0.88em ui-monospace, SFMono-Regular, Menlo, monospace;
1687
+ }
1688
+ .ce :global(a) {
1689
+ color: var(--accent);
1690
+ text-decoration: underline;
1691
+ }
1692
+
1693
+ .t-h1 .ce {
1694
+ font-size: 2em;
1695
+ font-weight: 700;
1696
+ line-height: 1.25;
1697
+ }
1698
+ .t-h1 {
1699
+ padding-top: 0.6em;
1700
+ }
1701
+ .t-h2 .ce {
1702
+ font-size: 1.5em;
1703
+ font-weight: 650;
1704
+ line-height: 1.3;
1705
+ }
1706
+ .t-h2 {
1707
+ padding-top: 0.45em;
1708
+ }
1709
+ .t-h3 .ce {
1710
+ font-size: 1.2em;
1711
+ font-weight: 600;
1712
+ line-height: 1.4;
1713
+ }
1714
+ .t-h3 {
1715
+ padding-top: 0.3em;
1716
+ }
1717
+ .t-h4 .ce {
1718
+ font-size: 1.05em;
1719
+ font-weight: 600;
1720
+ line-height: 1.45;
1721
+ }
1722
+ .t-h4 {
1723
+ padding-top: 0.25em;
1724
+ }
1725
+ .t-h5 .ce {
1726
+ font-size: 0.95em;
1727
+ font-weight: 600;
1728
+ line-height: 1.5;
1729
+ }
1730
+ .t-h6 .ce {
1731
+ font-size: 0.85em;
1732
+ font-weight: 600;
1733
+ line-height: 1.5;
1734
+ letter-spacing: 0.04em;
1735
+ text-transform: uppercase;
1736
+ }
1737
+ .t-h5,
1738
+ .t-h6 {
1739
+ padding-top: 0.2em;
1740
+ }
1741
+ .t-quote .ce {
1742
+ border-left: 3px solid var(--fg);
1743
+ padding-left: 0.9em;
1744
+ font-style: italic;
1745
+ }
1746
+ .t-code,
1747
+ .t-plugin {
1748
+ position: relative;
1749
+ }
1750
+ .badge {
1751
+ position: absolute;
1752
+ top: calc(0.15em + 5px);
1753
+ right: 6px;
1754
+ display: flex;
1755
+ align-items: center;
1756
+ gap: 2px;
1757
+ z-index: 2;
1758
+ }
1759
+ .t-plugin > .badge {
1760
+ top: calc(0.35em + 5px);
1761
+ /* Readable over a map or an image. */
1762
+ background: color-mix(in srgb, var(--color-surface, #fff) 92%, transparent);
1763
+ border-radius: 6px;
1764
+ }
1765
+ .tag {
1766
+ max-width: 18em;
1767
+ overflow: hidden;
1768
+ text-overflow: ellipsis;
1769
+ white-space: nowrap;
1770
+ padding: 1px 6px;
1771
+ border-radius: 5px;
1772
+ color: var(--muted);
1773
+ font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
1774
+ user-select: none;
1775
+ }
1776
+ /* the fields bar on its own row above the code */
1777
+ .t-code {
1778
+ flex-wrap: wrap;
1779
+ row-gap: 0;
1780
+ }
1781
+ .code-fields,
1782
+ .t-code .ce {
1783
+ flex-basis: 100%;
1784
+ }
1785
+ .t-code:has(> .badge) .ce {
1786
+ padding-right: 7.5em;
1787
+ }
1788
+ .t-code .ce {
1789
+ background: var(--code-bg);
1790
+ border-radius: 8px;
1791
+ padding: 0.7em 0.9em;
1792
+ font: 0.88em/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
1793
+ white-space: pre-wrap;
1794
+ }
1795
+ .t-hr {
1796
+ padding: 0.6em 0;
1797
+ }
1798
+ hr {
1799
+ flex: 1;
1800
+ border: 0;
1801
+ border-top: 1px solid var(--border);
1802
+ margin: 0;
1803
+ }
1804
+
1805
+ .marker {
1806
+ flex: none;
1807
+ width: 1.3em;
1808
+ text-align: center;
1809
+ color: var(--fg);
1810
+ user-select: none;
1811
+ }
1812
+ .marker.num {
1813
+ width: 1.6em;
1814
+ text-align: right;
1815
+ font-variant-numeric: tabular-nums;
1816
+ }
1817
+ .check {
1818
+ flex: none;
1819
+ width: 1.1em;
1820
+ height: 1.1em;
1821
+ margin: 0.3em 0.2em 0 0.1em;
1822
+ border: 1.6px solid var(--muted);
1823
+ border-radius: 4px;
1824
+ background: transparent;
1825
+ color: #fff;
1826
+ display: grid;
1827
+ place-items: center;
1828
+ cursor: pointer;
1829
+ padding: 0;
1830
+ }
1831
+ .check.done {
1832
+ background: var(--accent);
1833
+ border-color: var(--accent);
1834
+ }
1835
+
1836
+ .tail {
1837
+ display: block;
1838
+ width: 100%;
1839
+ min-height: 5rem;
1840
+ border: 0;
1841
+ background: none;
1842
+ cursor: text;
1843
+ }
1844
+
1845
+ .toolbar {
1846
+ position: fixed;
1847
+ transform: translate(-50%, calc(-100% - 8px));
1848
+ display: flex;
1849
+ gap: 2px;
1850
+ padding: 4px;
1851
+ background: var(--surface);
1852
+ border: 1px solid var(--border);
1853
+ border-radius: 10px;
1854
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
1855
+ z-index: 20;
1856
+ }
1857
+ .toolbar button {
1858
+ min-width: 30px;
1859
+ height: 30px;
1860
+ border: 0;
1861
+ border-radius: 6px;
1862
+ background: none;
1863
+ color: var(--fg);
1864
+ display: grid;
1865
+ place-items: center;
1866
+ cursor: pointer;
1867
+ }
1868
+ .toolbar button:hover {
1869
+ background: var(--code-bg);
1870
+ }
1871
+
1872
+ .menu {
1873
+ position: fixed;
1874
+ width: 260px;
1875
+ max-height: 300px;
1876
+ overflow: auto;
1877
+ padding: 6px;
1878
+ background: var(--surface);
1879
+ border: 1px solid var(--border);
1880
+ border-radius: 12px;
1881
+ box-shadow: 0 10px 36px rgba(0, 0, 0, 0.2);
1882
+ z-index: 30;
1883
+ }
1884
+ .menu.up {
1885
+ transform: translateY(-100%);
1886
+ }
1887
+ .menu.turn {
1888
+ width: 240px;
1889
+ max-height: 340px;
1890
+ }
1891
+ .caption {
1892
+ padding: 4px 8px 6px;
1893
+ font: 600 11px system-ui;
1894
+ letter-spacing: 0.06em;
1895
+ text-transform: uppercase;
1896
+ color: var(--muted);
1897
+ }
1898
+ .sep {
1899
+ height: 1px;
1900
+ margin: 6px 4px;
1901
+ background: var(--border);
1902
+ }
1903
+ .current {
1904
+ margin-left: auto;
1905
+ display: flex;
1906
+ color: var(--accent);
1907
+ }
1908
+ .txt small.warn {
1909
+ color: #b45309;
1910
+ }
1911
+ .item.danger {
1912
+ color: #dc2626;
1913
+ }
1914
+ .item.danger .icon {
1915
+ color: #dc2626;
1916
+ }
1917
+ /* Compact one-line rows: icon, label, and the hint after it in grey. */
1918
+ .item {
1919
+ display: flex;
1920
+ align-items: center;
1921
+ gap: 8px;
1922
+ padding: 3px 6px;
1923
+ border-radius: 6px;
1924
+ cursor: pointer;
1925
+ }
1926
+ .item.active {
1927
+ background: var(--sel);
1928
+ }
1929
+ .icon {
1930
+ flex: none;
1931
+ width: 24px;
1932
+ height: 24px;
1933
+ display: grid;
1934
+ place-items: center;
1935
+ border: 1px solid var(--border);
1936
+ border-radius: 6px;
1937
+ color: var(--fg);
1938
+ }
1939
+ .txt {
1940
+ min-width: 0;
1941
+ display: flex;
1942
+ align-items: baseline;
1943
+ gap: 6px;
1944
+ white-space: nowrap;
1945
+ overflow: hidden;
1946
+ }
1947
+ .txt b {
1948
+ font: 500 13px system-ui;
1949
+ }
1950
+ .txt small {
1951
+ overflow: hidden;
1952
+ text-overflow: ellipsis;
1953
+ color: var(--muted);
1954
+ font-size: 11px;
1955
+ }
1956
+ </style>