@atom-forge/brickee 0.0.2 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README-AI.md +10 -7
  3. package/dist/Editor.svelte +691 -833
  4. package/dist/LinkPrompt.svelte +1 -14
  5. package/dist/Preview.svelte +27 -54
  6. package/dist/Preview.svelte.d.ts +2 -4
  7. package/dist/PropsBar.svelte +35 -219
  8. package/dist/code.js +1 -1
  9. package/dist/dom.d.ts +8 -0
  10. package/dist/dom.js +9 -0
  11. package/dist/editor/BlockMenu.svelte +102 -0
  12. package/dist/editor/BlockMenu.svelte.d.ts +32 -0
  13. package/dist/editor/FormatBar.svelte +41 -0
  14. package/dist/editor/FormatBar.svelte.d.ts +16 -0
  15. package/dist/editor/drag.svelte.d.ts +63 -0
  16. package/dist/editor/drag.svelte.js +95 -0
  17. package/dist/editor/history.d.ts +24 -0
  18. package/dist/editor/history.js +86 -0
  19. package/dist/editor/sweep.svelte.d.ts +21 -0
  20. package/dist/editor/sweep.svelte.js +56 -0
  21. package/dist/html.d.ts +12 -0
  22. package/dist/html.js +206 -0
  23. package/dist/i18n.js +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +3 -2
  26. package/dist/kit/Empty.svelte +4 -15
  27. package/dist/kit/Frame.svelte +7 -37
  28. package/dist/locales/hu.js +4 -1
  29. package/dist/markdown.d.ts +20 -4
  30. package/dist/markdown.js +380 -85
  31. package/dist/model.js +2 -3
  32. package/dist/plugins/Gap.svelte +5 -8
  33. package/dist/plugins/GapPreview.svelte +4 -6
  34. package/dist/plugins/Map.svelte +2 -17
  35. package/dist/plugins/MapPreview.svelte +8 -10
  36. package/dist/plugins/Table.svelte +233 -256
  37. package/dist/plugins/TablePreview.svelte +28 -28
  38. package/dist/plugins/YouTube.svelte +3 -31
  39. package/dist/plugins/YouTubePreview.svelte +45 -43
  40. package/dist/plugins/index.js +3 -1
  41. package/dist/types.d.ts +21 -3
  42. package/dist/types.js +8 -1
  43. package/dist/view.js +1 -1
  44. package/docs/en/bundled-plugins.md +1 -1
  45. package/docs/en/documents-and-markdown.md +20 -14
  46. package/docs/en/editor-and-preview.md +6 -7
  47. package/docs/en/getting-started.md +8 -0
  48. package/docs/en/index.md +1 -1
  49. package/docs/en/recipes-and-pitfalls.md +2 -2
  50. package/docs/en/server-side.md +0 -17
  51. package/docs/en/upgrading.md +14 -0
  52. package/docs/en/writing-plugins.md +36 -10
  53. package/docs/hu/bundled-plugins.md +1 -1
  54. package/docs/hu/documents-and-markdown.md +20 -14
  55. package/docs/hu/editor-and-preview.md +6 -7
  56. package/docs/hu/getting-started.md +8 -0
  57. package/docs/hu/index.md +1 -1
  58. package/docs/hu/recipes-and-pitfalls.md +2 -2
  59. package/docs/hu/server-side.md +0 -17
  60. package/docs/hu/upgrading.md +14 -0
  61. package/docs/hu/writing-plugins.md +36 -10
  62. package/package.json +9 -1
@@ -1,34 +1,23 @@
1
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';
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';
6
+ import { fromHtml } from './html';
22
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. */
@@ -72,21 +61,50 @@
72
61
  const pluginOf = (type: string | undefined) => plugins.find((p) => p.type === type);
73
62
  /** Blocks the caret cannot enter from outside: dividers and plugin blocks. */
74
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';
75
66
 
76
67
  /* ------------------------------------------------------------ document io */
77
68
 
78
- // 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).
79
71
  const load = (d: StoredBlock[]) => (d.length ? d.map(fromStored) : [newBlock()]);
80
72
  let value = $state<Block[]>(load(doc));
81
- let written = '';
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);
82
91
  $effect(() => {
83
- const out = value.map(toStored);
84
- written = JSON.stringify(out);
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;
85
96
  doc = out;
97
+ readBack = untrack(() => doc);
86
98
  });
87
- // 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.
88
100
  $effect(() => {
89
- if (JSON.stringify(doc) !== written) value = load(doc);
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());
90
108
  });
91
109
 
92
110
  /* ------------------------------------------------------------------ state */
@@ -100,17 +118,9 @@
100
118
 
101
119
  // State shared by an editor and every editor nested in its plugin blocks: blocks can be dragged between them,
102
120
  // 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
121
  // A string, not an object: $state would proxy an object and break identity checks.
110
122
  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);
123
+ const hub = useHub();
114
124
 
115
125
  $effect(() => {
116
126
  if (selA) hub.active = token;
@@ -120,6 +130,8 @@
120
130
  });
121
131
 
122
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)]);
123
135
  // Direct children only: a plugin row may hold a nested editor with its own blocks.
124
136
  const el = (id: string) => root?.querySelector<HTMLElement>(`[data-block="${id}"] > .body > .ce`) ?? null;
125
137
  const ownRow = (n: Element | null) => {
@@ -147,66 +159,39 @@
147
159
 
148
160
  /* ---------------------------------------------------------------- history */
149
161
 
150
- type Snap = { blocks: string; id: string | null; off: number };
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
+ }
151
174
  const take = (): Snap => {
152
175
  const e = focusedId ? el(focusedId) : null;
153
- return { blocks: JSON.stringify($state.snapshot(value)), id: focusedId, off: e ? caretOffset(e) : 0 };
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 };
154
180
  };
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
- }
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();
186
187
 
187
188
  function restore(s: Snap) {
188
- value = JSON.parse(s.blocks);
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);
189
191
  selA = selH = null;
190
192
  slash = null;
191
- last = s;
192
193
  if (s.id && idx(s.id) >= 0) focusBlock(s.id, s.off);
193
194
  }
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
195
 
211
196
  /* ---------------------------------------------------------- block catalog */
212
197
 
@@ -234,7 +219,7 @@
234
219
  blocks
235
220
  .filter((b) => b.type !== 'hr' || allowed.has('hr'))
236
221
  .map((b) => {
237
- if (!allowed.has(b.type)) convert(b, 'p');
222
+ if (b.type !== 'plugin' && !allowed.has(b.type)) convert(b, 'p');
238
223
  return b;
239
224
  });
240
225
 
@@ -312,12 +297,6 @@
312
297
  };
313
298
  }
314
299
 
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
300
  function applySlash(item: PaletteItem) {
322
301
  if (!slash) return;
323
302
  const b = value[idx(slash.id)];
@@ -336,7 +315,7 @@
336
315
  if (type === 'hr') {
337
316
  const i = idx(b.id);
338
317
  const nb = newBlock();
339
- value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
318
+ insertAt(i + 1, nb);
340
319
  focusBlock(nb.id, 'start');
341
320
  } else focusBlock(b.id, off);
342
321
  });
@@ -390,7 +369,7 @@
390
369
  b.type = 'hr';
391
370
  b.html = '';
392
371
  const nb = newBlock();
393
- value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
372
+ insertAt(i + 1, nb);
394
373
  focusBlock(nb.id, 'start');
395
374
  });
396
375
  return;
@@ -431,7 +410,7 @@
431
410
  const nb = newBlock(type, post, { indent: b.indent, ...(type === 'todo' ? { checked: false } : {}) });
432
411
  mutate(() => {
433
412
  b.html = pre;
434
- value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)];
413
+ insertAt(i + 1, nb);
435
414
  });
436
415
  focusBlock(nb.id, 'start');
437
416
  }
@@ -468,6 +447,11 @@
468
447
  else if (k === 'k') fmt('link');
469
448
  else if (k === '\\') fmt('plain');
470
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
+ }
471
455
  else if (k === 'a' && (!ce.textContent || getSelection()?.toString() === ce.textContent)) {
472
456
  selA = value[0].id;
473
457
  selH = value[value.length - 1].id;
@@ -476,7 +460,7 @@
476
460
  } else if (e.shiftKey && (key === 'ArrowUp' || key === 'ArrowDown')) nudge([b.id], key === 'ArrowUp' ? -1 : 1);
477
461
  else if (key === 'Enter' && b.type === 'code') {
478
462
  const nb = newBlock();
479
- mutate(() => (value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)]));
463
+ mutate(() => insertAt(i + 1, nb));
480
464
  focusBlock(nb.id, 'start');
481
465
  } else handled = false;
482
466
  if (handled) return e.preventDefault();
@@ -492,7 +476,7 @@
492
476
  if (b.type === 'code') {
493
477
  if (e.shiftKey) {
494
478
  const nb = newBlock();
495
- mutate(() => (value = [...value.slice(0, i + 1), nb, ...value.slice(i + 1)]));
479
+ mutate(() => insertAt(i + 1, nb));
496
480
  focusBlock(nb.id, 'start');
497
481
  } else document.execCommand('insertLineBreak');
498
482
  } else if (e.shiftKey) document.execCommand('insertLineBreak');
@@ -616,21 +600,94 @@
616
600
  detectSlash(ce, b);
617
601
  }
618
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
+
619
665
  function onPaste(e: ClipboardEvent, b: Block) {
620
- const text = e.clipboardData?.getData('text/plain');
621
- if (text == null) return;
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;
622
678
  e.preventDefault();
623
679
  const ce = e.currentTarget as HTMLElement;
624
- const norm = text.replace(/\r/g, '');
680
+ const norm = (text ?? '').replace(/\r/g, '');
625
681
 
626
- if (b.type === 'code' || !norm.includes('\n')) {
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'))) {
627
684
  norm.split('\n').forEach((line, n) => {
628
685
  if (n) document.execCommand('insertLineBreak');
629
686
  if (line) document.execCommand('insertText', false, line);
630
687
  });
631
688
  return;
632
689
  }
633
- const parsed = fit(fromMarkdown(norm));
690
+ const parsed = pasted(data) ?? [];
634
691
  if (!parsed.length) return;
635
692
  const parts = splitAtCaret(ce);
636
693
  if (!parts) return;
@@ -638,28 +695,59 @@
638
695
  const post = clean(parts[1]);
639
696
  const i = idx(b.id);
640
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
+ }
641
706
  let rest = parsed;
642
707
  const first = parsed[0];
643
708
  let curHtml = pre;
644
- if (!pre && first.type !== 'hr') {
709
+ if (!pre && first.type !== 'hr' && first.type !== 'plugin') {
645
710
  b.type = first.type;
646
711
  b.indent = first.indent;
647
712
  if (first.type === 'todo') b.checked = first.checked;
713
+ if (first.props) b.props = first.props;
714
+ else delete b.props;
648
715
  curHtml = first.html;
649
716
  rest = parsed.slice(1);
650
717
  } else if (first.type === 'p') {
651
718
  curHtml = pre + first.html;
652
719
  rest = parsed.slice(1);
653
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', '')];
654
723
  const target = rest.length ? rest[rest.length - 1] : b;
655
724
  const off = textLen(target === b ? curHtml : target.html);
656
725
  mutate(() => {
657
726
  if (target === b) curHtml += post;
658
727
  else target.html += post;
659
728
  b.html = curHtml;
660
- value = [...value.slice(0, i + 1), ...rest, ...value.slice(i + 1)];
729
+ insertAt(i + 1, ...rest);
661
730
  });
662
- focusBlock(target.id, off);
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;
663
751
  }
664
752
 
665
753
  /* ---------------------------------------------------------- inline format */
@@ -766,8 +854,23 @@
766
854
  }
767
855
 
768
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
+ }
769
867
  if (e.target !== root || !selSet.size || !selH) return;
770
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
+ }
771
874
  const mod = e.metaKey || e.ctrlKey;
772
875
  const key = e.key;
773
876
  const h = idx(selH);
@@ -789,7 +892,7 @@
789
892
  if (el(b.id)) focusBlock(b.id, 'end');
790
893
  else {
791
894
  const nb = newBlock();
792
- mutate(() => (value = [...value.slice(0, h + 1), nb, ...value.slice(h + 1)]));
895
+ mutate(() => insertAt(h + 1, nb));
793
896
  focusBlock(nb.id, 'start');
794
897
  }
795
898
  } else if (key === 'Escape') {
@@ -813,7 +916,13 @@
813
916
  function onCopy(e: ClipboardEvent, cut = false) {
814
917
  if (!selSet.size) return;
815
918
  e.preventDefault();
816
- e.clipboardData?.setData('text/plain', toMarkdown(value.filter((b) => selSet.has(b.id)), { plugins }));
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
+ );
817
926
  if (cut) deleteSelected();
818
927
  }
819
928
 
@@ -830,12 +939,23 @@
830
939
  blurActive();
831
940
  root.focus({ preventScroll: true });
832
941
  } else selectOnly(b.id);
833
- if (!menu) return;
834
- const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
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();
835
948
  const up = r.bottom + 340 > innerHeight && r.top > 340;
836
949
  turn = { x: Math.min(r.right + 6, innerWidth - 250), y: up ? r.bottom : r.top, up, index: 0 };
837
950
  }
838
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
+
839
959
  /* ------------------------------------------------------ properties panel */
840
960
 
841
961
  // Plugin blocks and code blocks show their tag and fields on their top (PropsBar); read-only, a code block shows its
@@ -864,6 +984,17 @@
864
984
  return f.format ? f.format(v) : v == null ? '' : String(v);
865
985
  };
866
986
 
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;
993
+ typed();
994
+ if (key === 'props') b.props = v as Block['props'];
995
+ else b.blocks = v as StoredBlock[];
996
+ }
997
+
867
998
  /** Sets one property to a value as it is ('' or undefined removes it). */
868
999
  function setValue(b: Block, key: string, v: unknown) {
869
1000
  typed();
@@ -894,6 +1025,8 @@
894
1025
  | { kind: 'type'; item: PaletteItem; type: BlockType; current: boolean; lossy: boolean }
895
1026
  | { kind: 'wrap'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
896
1027
  | { kind: 'retype'; item: PaletteItem; plugin: BlockPlugin; current: boolean }
1028
+ | { kind: 'up' }
1029
+ | { kind: 'down' }
897
1030
  | { kind: 'unwrap' }
898
1031
  | { kind: 'delete' };
899
1032
 
@@ -938,6 +1071,11 @@
938
1071
  const lossy = to === 'plain' && sel.some((b) => contentOf(b) === 'text' && formatted(b.html));
939
1072
  out.push({ kind: 'type', item, type, current: single?.type === type, lossy });
940
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' });
941
1079
  if (known && kinds.includes('blocks')) out.push({ kind: 'unwrap' });
942
1080
  out.push({ kind: 'delete' });
943
1081
  return out;
@@ -953,6 +1091,7 @@
953
1091
  if (!opt) return;
954
1092
  const sel = selected;
955
1093
  if (opt.kind === 'delete') return deleteSelected();
1094
+ if (opt.kind === 'up' || opt.kind === 'down') return nudge([...selSet], opt.kind === 'up' ? -1 : 1);
956
1095
  if (opt.kind === 'unwrap') return unwrap(sel);
957
1096
  if (opt.kind === 'wrap') return wrap(sel, opt.plugin);
958
1097
  if (opt.kind === 'retype') return mutate(() => sel.forEach((b) => toPlugin(b, opt.plugin)));
@@ -1016,10 +1155,35 @@
1016
1155
  selA = selH = null;
1017
1156
  }
1018
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
+
1019
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
+ );
1020
1182
  function optionView(o: TurnOption): { icon: Icon; label: string; hint: string } {
1021
1183
  const n = selected.length;
1022
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+↓' };
1023
1187
  if (o.kind === 'unwrap') return { icon: Ungroup, label: tr('Unwrap'), hint: tr('Move the inner blocks out') };
1024
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') };
1025
1189
  if (o.kind === 'type' && o.lossy) return { icon: o.item.icon, label: o.item.label, hint: tr('Loses formatting') };
@@ -1040,117 +1204,76 @@
1040
1204
 
1041
1205
  /* ------------------------------------------------- selection across blocks */
1042
1206
 
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;
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: () => {
1074
1217
  blurActive();
1075
1218
  root.focus({ preventScroll: true });
1076
1219
  }
1077
- getSelection()?.removeAllRanges();
1078
- selA = from.dataset.block!;
1079
- selH = row.dataset.block!;
1080
- }
1220
+ });
1081
1221
 
1082
- function endSweep() {
1083
- removeEventListener('mousemove', sweep);
1084
- sweepFrom = null;
1085
- sweeping = false;
1086
- }
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
+ });
1087
1228
 
1088
1229
  /* ---------------------------------------------------------------- dragging */
1089
1230
 
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) {
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) =>
1134
1241
  mutate(() => {
1135
- const moving = value.filter((b) => src.ids.includes(b.id));
1136
- const rest = value.filter((b) => !src.ids.includes(b.id));
1242
+ const moving = value.filter((b) => ids.includes(b.id));
1243
+ const rest = value.filter((b) => !ids.includes(b.id));
1137
1244
  const t = rest.findIndex((b) => b.id === over) + (after ? 1 : 0);
1138
1245
  value = [...rest.slice(0, t), ...moving, ...rest.slice(t)];
1139
- });
1140
- return;
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;
1141
1273
  }
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();
1274
+ return false;
1149
1275
  }
1150
1276
 
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
1277
  /* ------------------------------------------------------------------- misc */
1155
1278
 
1156
1279
  /** The + button: a new paragraph below (or this one, if it is an empty paragraph) with the block menu open. */
@@ -1159,7 +1282,7 @@
1159
1282
  if (!(b.type === 'p' && !b.html)) {
1160
1283
  target = newBlock('p', '', { indent: b.indent });
1161
1284
  const i = idx(b.id);
1162
- mutate(() => (value = [...value.slice(0, i + 1), target, ...value.slice(i + 1)]));
1285
+ mutate(() => insertAt(i + 1, target));
1163
1286
  }
1164
1287
  await tick();
1165
1288
  const ce = el(target.id);
@@ -1192,28 +1315,30 @@
1192
1315
  <div
1193
1316
  class="editor"
1194
1317
  class:nested
1195
- class:sweeping
1318
+ class:sweeping={sweep.sweeping}
1196
1319
  role="presentation"
1197
1320
  bind:this={root}
1198
1321
  tabindex="-1"
1199
1322
  onkeydown={onRootKey}
1200
1323
  oncopy={(e) => onCopy(e)}
1201
1324
  oncut={(e) => onCopy(e, true)}
1325
+ onpaste={onRootPaste}
1202
1326
  onmousedown={(e) => {
1203
1327
  if (selA && !(e.target as HTMLElement).closest('.grab')) selA = selH = null;
1204
- startSweep(e);
1328
+ sweep.start(e);
1205
1329
  }}
1206
- ondragover={onDragOver}
1207
- ondrop={onDrop}
1208
- ondragend={() => (hub.src = hub.target = null)}
1330
+ ondragover={drag.over}
1331
+ ondrop={drag.drop}
1332
+ ondragend={drag.end}
1333
+ ondragleave={drag.leave}
1209
1334
  >
1210
1335
  {#each value as b, i (b.id)}
1211
1336
  <div
1212
1337
  class="row"
1213
1338
  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)}
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)}
1217
1342
  data-block={b.id}
1218
1343
  data-type={b.type}
1219
1344
  style:--indent={b.indent}
@@ -1224,17 +1349,20 @@
1224
1349
  middle. Drag to move, click for the turn-into menu. Hovering it shows a small dark bubble on its right
1225
1350
  with the block actions (add a block below, turn into, delete). -->
1226
1351
  <div class="grab">
1227
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
1228
- <span
1352
+ <button
1353
+ type="button"
1229
1354
  class="handle"
1230
1355
  draggable="true"
1356
+ tabindex="-1"
1231
1357
  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
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
1234
1361
  >
1235
1362
  <div class="bubble" role="toolbar" aria-label={tr('Block actions')}>
1236
1363
  <button
1237
1364
  type="button"
1365
+ class={BUBBLE}
1238
1366
  title={tr('Add a block below')}
1239
1367
  aria-label={tr('Add a block below')}
1240
1368
  onmousedown={(e) => e.preventDefault()}
@@ -1242,6 +1370,7 @@
1242
1370
  >
1243
1371
  <button
1244
1372
  type="button"
1373
+ class={BUBBLE}
1245
1374
  title={tr('Turn into')}
1246
1375
  aria-label={tr('Turn into')}
1247
1376
  onmousedown={(e) => e.preventDefault()}
@@ -1249,7 +1378,7 @@
1249
1378
  >
1250
1379
  <button
1251
1380
  type="button"
1252
- class="del"
1381
+ class={BUBBLE_DELETE}
1253
1382
  title={tr('Delete')}
1254
1383
  aria-label={tr('Delete')}
1255
1384
  onmousedown={(e) => e.preventDefault()}
@@ -1261,18 +1390,18 @@
1261
1390
  {/if}
1262
1391
  <div class="body t-{b.type}">
1263
1392
  {#if b.type === 'hr'}
1264
- <hr />
1393
+ <hr class="m-0 flex-1 border-0 border-t border-frame" />
1265
1394
  {:else if b.type === 'plugin'}
1266
1395
  {@const plugin = pluginOf(b.plugin)}
1267
1396
  {#if plugin}
1268
- <div class="plugin">
1397
+ <div class="my-[0.35em] min-w-0 flex-1">
1269
1398
  {#snippet propsBar()}
1270
1399
  <PropsBar block={b} tag={tagOf(b)} fields={plugin.fields ?? []} {tr} {setProp} {setValue} open={fresh.has(b.id)} />
1271
1400
  {/snippet}
1272
1401
  <PluginHost
1273
1402
  {plugin}
1274
- bind:props={() => b.props ?? {}, (p) => (b.props = p)}
1275
- bind:blocks={() => b.blocks ?? [], (v) => (b.blocks = v)}
1403
+ bind:props={() => b.props ?? {}, (p) => pluginChange(b, 'props', p)}
1404
+ bind:blocks={() => b.blocks ?? [], (v) => pluginChange(b, 'blocks', v)}
1276
1405
  {readonly}
1277
1406
  {palette}
1278
1407
  {plugins}
@@ -1280,18 +1409,19 @@
1280
1409
  />
1281
1410
  </div>
1282
1411
  {:else}
1283
- <div class="unknown">Unknown block: {b.plugin}</div>
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>
1284
1413
  {/if}
1285
1414
  {:else}
1286
1415
  {#if b.type === 'ul'}
1287
- <span class="marker" aria-hidden="true">{['•', '◦', '▪'][b.indent % 3]}</span>
1416
+ <span class="w-[1.3em] flex-none text-center text-surface-contrast select-none" aria-hidden="true">{['•', '◦', '▪'][b.indent % 3]}</span>
1288
1417
  {:else if b.type === 'ol'}
1289
- <span class="marker num" aria-hidden="true">{nums[b.id]}.</span>
1418
+ <span class="w-[1.6em] flex-none text-right text-surface-contrast tabular-nums select-none" aria-hidden="true">{nums[b.id]}.</span>
1290
1419
  {:else if b.type === 'todo'}
1291
1420
  <button
1292
1421
  type="button"
1293
- class="check"
1294
- class:done={b.checked}
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'}"
1295
1425
  role="checkbox"
1296
1426
  aria-checked={!!b.checked}
1297
1427
  aria-label={tr('Done')}
@@ -1301,7 +1431,7 @@
1301
1431
  >{#if b.checked}<Check size={12} strokeWidth={3} />{/if}</button
1302
1432
  >
1303
1433
  {:else if b.type === 'code' && !readonly}
1304
- <div class="code-fields">
1434
+ <div class="basis-full">
1305
1435
  <PropsBar block={b} tag={codeTag(b)} fields={codeFields(b)} {tr} {setProp} {setValue} />
1306
1436
  </div>
1307
1437
  {/if}
@@ -1329,628 +1459,356 @@
1329
1459
  if (focusedId === b.id) focusedId = null;
1330
1460
  slash = null;
1331
1461
  }}
1332
- ></div>
1462
+ >{#if ssr}{@html b.html}{/if}</div>
1333
1463
  {/if}
1334
1464
  {#if b.type === 'code' && readonly}
1335
1465
  <!-- 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}
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}
1339
1470
  </div>
1340
1471
  {:else if b.type === 'plugin' && (readonly || !pluginOf(b.plugin)?.fields?.length)}
1341
- <div class="badge"><span class="tag">{tagOf(b)}</span></div>
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>
1342
1476
  {/if}
1343
1477
  </div>
1344
1478
  </div>
1345
1479
  {/each}
1346
1480
 
1347
1481
  {#if !readonly}
1348
- <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>
1349
1483
  {/if}
1350
1484
 
1351
1485
  {#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>
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)} />
1363
1487
  {/if}
1364
1488
 
1365
1489
  {#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>
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
+ />
1402
1502
  {/if}
1403
1503
 
1404
1504
  {#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>
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
+ />
1432
1515
  {/if}
1433
1516
  </div>
1434
1517
 
1435
1518
  <!-- 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>
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>