@atom-forge/brickee 0.0.1 → 0.0.3

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