@voithos-labs/aragonite 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/a11y-strings.d.ts +1 -0
  2. package/dist/a11y-strings.js +1 -0
  3. package/dist/components/BlockDragHandle.svelte +2 -0
  4. package/dist/components/BlockHost.svelte +2 -2
  5. package/dist/components/Editor.svelte +56 -3
  6. package/dist/components/SelectionOverlay.svelte +17 -23
  7. package/dist/components/SelectionOverlay.svelte.d.ts +1 -1
  8. package/dist/components/blocks/code/CodeBlockRail.svelte +42 -24
  9. package/dist/components/blocks/code/code-bootstrap.js +12 -0
  10. package/dist/components/blocks/code/code-languages.d.ts +5 -5
  11. package/dist/components/blocks/code/code-languages.js +20 -12
  12. package/dist/components/blocks/editable-leaf.d.ts +3 -3
  13. package/dist/components/blocks/editable-leaf.js +6 -2
  14. package/dist/components/blocks/list/ListBlock.svelte +1 -0
  15. package/dist/components/blocks/list/ListItemBlock.svelte +12 -3
  16. package/dist/components/blocks/list/ListItemBlock.svelte.d.ts +2 -0
  17. package/dist/components/blocks/text/click-snap-guard.d.ts +3 -0
  18. package/dist/components/blocks/text/click-snap-guard.js +8 -0
  19. package/dist/components/blocks/text/edge-policy-dispatch.js +33 -18
  20. package/dist/components/blocks/text/live-selection-edit.d.ts +5 -5
  21. package/dist/components/blocks/text/live-selection-edit.js +10 -7
  22. package/dist/components/blocks/text/text-clipboard.js +2 -2
  23. package/dist/components/blocks/text/widget-interaction.js +45 -23
  24. package/dist/components/drag-handle.d.ts +6 -0
  25. package/dist/components/drag-handle.js +8 -0
  26. package/dist/components/editor-root-geometry.d.ts +1 -1
  27. package/dist/components/editor-root-geometry.js +1 -1
  28. package/dist/components/editor-root-listeners.d.ts +0 -7
  29. package/dist/components/editor-root-listeners.js +0 -22
  30. package/dist/components/image/ImageResizeHandles.svelte +18 -10
  31. package/dist/components/menu/SelectionToolbar.svelte +385 -0
  32. package/dist/components/menu/SelectionToolbar.svelte.d.ts +16 -0
  33. package/dist/core/inline/inline-widgets.d.ts +12 -4
  34. package/dist/core/inline/inline-widgets.js +5 -0
  35. package/dist/core/inline/transparency.js +3 -3
  36. package/dist/cursor/height-oracle.d.ts +2 -3
  37. package/dist/cursor/height-oracle.js +0 -1
  38. package/dist/cursor/scroll-hold.d.ts +10 -0
  39. package/dist/cursor/scroll-hold.js +22 -0
  40. package/dist/cursor/scrollport.d.ts +9 -0
  41. package/dist/cursor/scrollport.js +30 -1
  42. package/dist/cursor/visual-lines.d.ts +5 -4
  43. package/dist/cursor/visual-lines.js +56 -11
  44. package/dist/cursor/widget-edge-snap.d.ts +28 -0
  45. package/dist/cursor/widget-edge-snap.js +38 -0
  46. package/dist/cursor/widget-offset.d.ts +3 -0
  47. package/dist/cursor/widget-offset.js +10 -0
  48. package/dist/decorations/reserved-attrs.js +1 -0
  49. package/dist/editor-actions/focus/focus-dispatch.js +7 -4
  50. package/dist/editor-actions/focus/focus-landing.d.ts +10 -1
  51. package/dist/editor-actions/focus/focus-landing.js +20 -6
  52. package/dist/editor-actions/plugin/container.d.ts +7 -0
  53. package/dist/editor-actions/plugin/container.js +3 -1
  54. package/dist/editor-props.d.ts +3 -0
  55. package/dist/plugin.d.ts +2 -1
  56. package/dist/plugin.js +6 -1
  57. package/dist/plugins/latex/latex-kind.js +48 -31
  58. package/dist/plugins/latex/math-source.d.ts +3 -3
  59. package/dist/plugins/latex/math-source.js +32 -26
  60. package/dist/plugins/mermaid/MermaidBlock.svelte +19 -9
  61. package/dist/plugins/parrot/ParrotBlock.svelte +3 -1
  62. package/dist/reactivity/list-windowing.svelte.js +26 -6
  63. package/dist/schema/commands.d.ts +9 -5
  64. package/dist/schema/commands.js +12 -7
  65. package/dist/schema/operations.d.ts +1 -1
  66. package/dist/schema/reserved-chords.js +12 -0
  67. package/dist/selection/cross-block/keydown.js +2 -28
  68. package/dist/selection/cross-block/paste.js +7 -13
  69. package/dist/selection/cross-block/type-replace.js +5 -15
  70. package/dist/selection/dead-space-caret.d.ts +3 -0
  71. package/dist/selection/dead-space-caret.js +12 -2
  72. package/dist/selection/drag-pointer.d.ts +19 -0
  73. package/dist/selection/drag-pointer.js +42 -0
  74. package/dist/selection/keyboard-extend.d.ts +3 -2
  75. package/dist/selection/keyboard-extend.js +3 -2
  76. package/dist/selection/multi-click.d.ts +42 -0
  77. package/dist/selection/multi-click.js +203 -0
  78. package/dist/selection/native-bridge.d.ts +3 -0
  79. package/dist/selection/native-bridge.js +31 -2
  80. package/dist/selection/path-lookup.d.ts +10 -2
  81. package/dist/selection/path-lookup.js +10 -3
  82. package/dist/selection/pointer-gesture.d.ts +9 -0
  83. package/dist/selection/pointer-gesture.js +11 -0
  84. package/dist/selection/primitives.d.ts +7 -0
  85. package/dist/selection/primitives.js +19 -1
  86. package/dist/selection/range-delete.d.ts +4 -0
  87. package/dist/selection/range-delete.js +1 -1
  88. package/dist/selection/selection-drop.d.ts +34 -0
  89. package/dist/selection/selection-drop.js +253 -0
  90. package/dist/styles/editor-theme.css +18 -3
  91. package/dist/styles/editor.css +64 -13
  92. package/dist/tree-operations/paste/replace-block-at-parent.d.ts +3 -1
  93. package/dist/tree-operations/paste/replace-block-at-parent.js +4 -1
  94. package/dist/tree-operations/paste/replacement-parse.d.ts +17 -0
  95. package/dist/tree-operations/paste/replacement-parse.js +22 -0
  96. package/docs/guide/consumer-guide.md +10 -8
  97. package/docs/guide/plugin-api.md +54 -38
  98. package/docs/guide/plugin-guide.md +31 -22
  99. package/package.json +7 -7
  100. package/dist/selection/double-click-trim.d.ts +0 -17
  101. package/dist/selection/double-click-trim.js +0 -57
@@ -26,6 +26,7 @@ export declare const CODE_COPIED_LABEL = "Code copied";
26
26
  export declare const CODE_MENU_LABEL = "Code block actions";
27
27
  export declare const CODE_RAIL_LABEL = "Code block controls";
28
28
  export declare const CODE_LANGUAGE_LIST = "Code block languages";
29
+ export declare const SELECTION_TOOLBAR_LABEL = "Selection formatting";
29
30
  /** Chrome, not an announcement: the language chip's accessible name. */
30
31
  export declare function codeLanguageLabel(language: string): string;
31
32
  export declare const SEARCH_TOGGLE_REPLACE = "Toggle replace";
@@ -26,6 +26,7 @@ export const CODE_COPIED_LABEL = 'Code copied';
26
26
  export const CODE_MENU_LABEL = 'Code block actions';
27
27
  export const CODE_RAIL_LABEL = 'Code block controls';
28
28
  export const CODE_LANGUAGE_LIST = 'Code block languages';
29
+ export const SELECTION_TOOLBAR_LABEL = 'Selection formatting';
29
30
  /** Chrome, not an announcement: the language chip's accessible name. */
30
31
  export function codeLanguageLabel(language) {
31
32
  return `Code language: ${language}`;
@@ -48,6 +48,8 @@
48
48
  left: 0;
49
49
  width: 100%;
50
50
  height: 1.25rem;
51
+ /* the em line is the same first-line centre for engines without lh (Safari before 16.4) */
52
+ top: 0.75em;
51
53
  top: 0.5lh;
52
54
  transform: translateY(-50%);
53
55
  display: flex;
@@ -80,8 +80,8 @@
80
80
 
81
81
  let descriptor = $derived(registryView.descriptor(node.kind));
82
82
  let isContainer = $derived(descriptor.isContainer);
83
- // Who paints this block's rects, decided ONCE here and handed to both overlays —
84
- // duplicated, the two drift and a container paints over its own children. Delegation
83
+ // Who measures this block's rects, decided ONCE here and handed to both overlays —
84
+ // duplicated, the two drift and a container measures over its own children. Delegation
85
85
  // needs children with hosts: a childless container and a grid have none.
86
86
  let delegatesPainting = $derived(
87
87
  isContainer && (node.children?.length ?? 0) > 0 && descriptor.containerContract !== 'grid'
@@ -38,6 +38,9 @@
38
38
  import { createDeadSpaceCaret } from '../selection/dead-space-caret';
39
39
  import { resetForPointerDown } from '../selection/cross-block/pointer';
40
40
  import { installDragListener } from '../selection/drag-pointer';
41
+ import { installMultiClickSelect } from '../selection/multi-click';
42
+ import { claimsPointerGesture } from '../selection/pointer-gesture';
43
+ import { installSelectionDrop } from '../selection/selection-drop';
41
44
  import { createContentVersion } from '../reactivity/content-version.svelte';
42
45
  import { useContainerWindowing } from '../reactivity/use-container-windowing.svelte';
43
46
  import { refSlotsOver, replaceRefs, revealChildOrWait } from '../reactivity/publish-ref.svelte';
@@ -112,7 +115,6 @@
112
115
  installWidthWatcher
113
116
  } from './editor-root-geometry';
114
117
  import {
115
- installDoubleClickWordSelect,
116
118
  installEditorBlurAnnouncer,
117
119
  installModActiveTracker,
118
120
  installSelectionChangeBridge,
@@ -146,6 +148,7 @@
146
148
  import { bothEnable, createRegistryView, type KindEnablement } from '../schema/registry-view';
147
149
  import BlockList from './BlockList.svelte';
148
150
  import SearchBar from './SearchBar.svelte';
151
+ import SelectionToolbar from './menu/SelectionToolbar.svelte';
149
152
  import ImageOverlayHost from './image/ImageOverlayHost.svelte';
150
153
  import LinkCardHost from './link-card/LinkCardHost.svelte';
151
154
  import { createLinkCardState } from './link-card/link-card-state.svelte';
@@ -176,6 +179,7 @@
176
179
  blockDragHandles = true,
177
180
  searchBar = true,
178
181
  searchBarAnchor,
182
+ selectionToolbar = true,
179
183
  keybindings,
180
184
  theme = 'dark',
181
185
  presentationMode = 'source',
@@ -717,6 +721,11 @@
717
721
  // press and release targets meet at the root); with a range just painted, it is no
718
722
  // click to answer.
719
723
  if (!anchor) {
724
+ // A multi-click places no caret: the ladder painted its range over this press.
725
+ if (e.detail >= 2) {
726
+ marginDrag = false;
727
+ return;
728
+ }
720
729
  const pressed = marginDrag;
721
730
  const dragged =
722
731
  pressed &&
@@ -761,6 +770,7 @@
761
770
  'a, summary, [role="checkbox"], ' +
762
771
  '.code-rail, .table-add-zone, .editor-tail, .md-menu, .block-drag-handle';
763
772
  const dragStartsHere = (rootEl: HTMLElement, target: EventTarget | null): boolean => {
773
+ if (claimsPointerGesture(target)) return false;
764
774
  if (deadSpaceCaret.isDeadSpaceTarget(rootEl, target)) return true;
765
775
  if (!(target instanceof Element) || !rootEl.contains(target)) return false;
766
776
  return target.closest(NOT_A_DRAG_START) === null;
@@ -771,6 +781,7 @@
771
781
  // starts no selection of its own to fight it; `click` still fires for the caret placement.
772
782
  let marginDrag = false;
773
783
  let marginDown = { x: 0, y: 0 };
784
+ let marginSession: { dispose(): void } | null = null;
774
785
  const startMarginDrag = (e: PointerEvent) => {
775
786
  marginDrag = false;
776
787
  marginDown = { x: e.clientX, y: e.clientY };
@@ -786,7 +797,7 @@
786
797
  const component = getBlockComponent(anchor.path);
787
798
  if (component?.startDragAtPoint?.(e.clientX, e.clientY, e)) return;
788
799
  }
789
- installDragListener(
800
+ marginSession = installDragListener(
790
801
  {
791
802
  editorRoot: root,
792
803
  scrollContainer: getScrollHost() ?? root,
@@ -801,10 +812,38 @@
801
812
  };
802
813
  return removeAll(
803
814
  onRoot(root, 'click', handleClick),
804
- installDoubleClickWordSelect(root),
815
+ installMultiClickSelect({
816
+ editorRoot: root,
817
+ selection: selectionState,
818
+ getBlockElByPath,
819
+ getScrollContainer: () => getScrollHost() ?? root,
820
+ lifetimeSignal: lifetimeController.signal,
821
+ marginBlockAt: (target, x, y) =>
822
+ effectiveMode !== 'reading' && dragStartsHere(root, target)
823
+ ? deadSpaceCaret.blockPathNearPoint(root, x, y)
824
+ : null
825
+ }),
826
+ installSelectionDrop({
827
+ editorRoot: root,
828
+ getDoc: () => doc,
829
+ controller,
830
+ coordinator: pasteCoordinator,
831
+ getPresentationMode: () => effectiveMode,
832
+ linkRef: linkRefView,
833
+ grammar: registryView.grammar,
834
+ activePlugins,
835
+ events,
836
+ isReadOnly: () => effectiveMode === 'reading'
837
+ }),
805
838
  onRoot(root, 'pointerdown', startMarginDrag),
806
839
  onRoot(root, 'mousedown', (e: MouseEvent) => {
807
840
  deadSpaceCaret.notePress(root, e);
841
+ // The second press of a click run is the ladder's, which runs its own drag.
842
+ if (marginDrag && e.detail >= 2) {
843
+ marginSession?.dispose();
844
+ marginSession = null;
845
+ marginDrag = false;
846
+ }
808
847
  if (marginDrag) e.preventDefault();
809
848
  })
810
849
  );
@@ -1845,6 +1884,20 @@
1845
1884
  onClose={() => (blockMenu = null)}
1846
1885
  />
1847
1886
  {/if}
1887
+ {#if selectionToolbar && effectiveMode !== 'reading'}
1888
+ <SelectionToolbar
1889
+ editor={{
1890
+ getSelection,
1891
+ getRects,
1892
+ getBlockKindAt,
1893
+ runCommand,
1894
+ canRunCommand,
1895
+ isCommandActive,
1896
+ getEvents
1897
+ }}
1898
+ root={editorEl}
1899
+ />
1900
+ {/if}
1848
1901
  <ImageOverlayHost
1849
1902
  {widgetSelection}
1850
1903
  {controller}
@@ -9,13 +9,13 @@
9
9
  } from '../editor-keys';
10
10
  import {
11
11
  normalize,
12
+ blockPaintsWholeBox,
12
13
  classifyBlockForSelection,
13
14
  charOffsetOf,
14
15
  cellIndexOf,
15
- type BlockSelectionClass
16
+ type EditorSelection
16
17
  } from '../selection/primitives';
17
18
  import { snapCrossBlockTableEndpoints } from '../selection/table-endpoint-snap';
18
- import { pathsEqual } from '../selection/path-math';
19
19
  import { wireOverlayRemeasure } from '../cursor/overlay-remeasure';
20
20
 
21
21
  let {
@@ -28,7 +28,7 @@
28
28
  path: number[];
29
29
  blockRef: BlockComponent | undefined;
30
30
  blockEl: HTMLElement | null | undefined;
31
- /** This container's children paint their own overlays, so it paints none. */
31
+ /** This container's children paint the range's endpoint rects, so it measures none. */
32
32
  delegatesPainting?: boolean;
33
33
  /** This container measures its own rects instead of delegating. Both are decided
34
34
  * at BlockHost, which hands the same pair to DecorationOverlay. */
@@ -42,32 +42,26 @@
42
42
  const getEditorRoot = editorDoc?.editorRoot;
43
43
  const getDoc = editorDoc?.doc;
44
44
 
45
- const classification = $derived.by<BlockSelectionClass>(() => {
46
- if (delegatesPainting) return 'outside';
47
- if (!selection?.isCustomRendered || !selection.anchor || !selection.focus) {
48
- return 'outside';
49
- }
50
- return classifyBlockForSelection(path, {
51
- anchor: selection.anchor,
52
- focus: selection.focus
53
- });
45
+ const range = $derived.by<EditorSelection | null>(() => {
46
+ if (!selection?.isCustomRendered || !selection.anchor || !selection.focus) return null;
47
+ return { anchor: selection.anchor, focus: selection.focus };
54
48
  });
55
49
 
56
- // A whole unit (one surface-less block taken as the entire range) paints as a middle block
57
- // does: its full box, no measuring.
58
- const paintsWholeBox = $derived.by(() => {
59
- if (classification === 'middle') return true;
60
- if (classification !== 'single-block') return false;
61
- const unit = selection?.wholeUnitPath ?? null;
62
- return unit !== null && pathsEqual(unit, path);
63
- });
50
+ const classification = $derived(range ? classifyBlockForSelection(path, range) : 'outside');
51
+
52
+ // Delegation-blind on purpose: a block the range holds whole paints one box over everything it
53
+ // renders, chrome included, and the seam already keeps its children from painting under it.
54
+ const paintsWholeBox = $derived(
55
+ range !== null && blockPaintsWholeBox(path, range, selection?.wholeUnitPath ?? null)
56
+ );
64
57
 
65
58
  // The measuring effect and the template read this one predicate, so a rendered
66
59
  // rect is always one the effect measured; two predicates render a stale box.
67
60
  const paintsEndpoints = $derived(
68
- classification === 'start' ||
69
- classification === 'end' ||
70
- (classification === 'single-block' && containerPaintsRects)
61
+ !delegatesPainting &&
62
+ (classification === 'start' ||
63
+ classification === 'end' ||
64
+ (classification === 'single-block' && containerPaintsRects))
71
65
  );
72
66
 
73
67
  interface LocalRect {
@@ -3,7 +3,7 @@ type $$ComponentProps = {
3
3
  path: number[];
4
4
  blockRef: BlockComponent | undefined;
5
5
  blockEl: HTMLElement | null | undefined;
6
- /** This container's children paint their own overlays, so it paints none. */
6
+ /** This container's children paint the range's endpoint rects, so it measures none. */
7
7
  delegatesPainting?: boolean;
8
8
  /** This container measures its own rects instead of delegating. Both are decided
9
9
  * at BlockHost, which hands the same pair to DecorationOverlay. */
@@ -10,7 +10,7 @@
10
10
  codeLanguageLabel
11
11
  } from '../../../a11y-strings';
12
12
  import type { CodeMenuItem } from '../../../editor-keys';
13
- import { listLanguages } from './code-languages';
13
+ import { getLanguageAliases, getLanguageGrammar, listLanguages } from './code-languages';
14
14
 
15
15
  // The chrome for the modes that paint no fence: the language door plus whatever action
16
16
  // affordances the host has earned by installing a hook. Drafts live here and only a
@@ -49,15 +49,12 @@
49
49
 
50
50
  let editing = $state(false);
51
51
  let draft = $state('');
52
- // The draft SEEDS with the current language, so filtering on it from the start would open
53
- // the picker onto a list of one. Filtering waits for a keystroke; until then the full list
54
- // shows with the current language highlighted, which is also what Enter would re-commit.
52
+ // Filtering waits for a keystroke: the field opens empty, and until then the whole list
53
+ // shows with the block's own language leading it, which is what a bare Enter re-commits.
55
54
  let filtering = $state(false);
56
- // Whether the LIST is what Enter should take. Until the user types or arrows, it is not:
57
- // the highlight is only a where-you-stand marker, and a language the registry does not
58
- // carry (an unregistered grammar, or a bare mount that registered none) would otherwise
59
- // have no entry to seat on, so a bare Enter would rewrite it to the list's first row.
60
55
  let activeIndex = $state(0);
56
+ /** The user moved the highlight, so the row it sits on outranks whatever they typed. */
57
+ let highlightMoved = false;
61
58
  let copied = $state(false);
62
59
  let menuOpen = $state(false);
63
60
  let openMenuItems = $state<readonly CodeMenuItem[]>([]);
@@ -77,18 +74,31 @@
77
74
  const all = ['text', ...listLanguages().filter((name) => name !== 'text')];
78
75
  const needle = filtering ? draft.trim().toLowerCase() : '';
79
76
  if (needle.length > 0) {
80
- const starts = all.filter((name) => name.startsWith(needle));
81
- const contains = all.filter((name) => !name.startsWith(needle) && name.includes(needle));
77
+ // An alias is a search key, never a row of its own: `rs` finds `rust`, and the list
78
+ // still carries one entry per language.
79
+ const spellings = (name: string) => [name, ...getLanguageAliases(name)];
80
+ const starts = all.filter((name) => spellings(name).some((s) => s.startsWith(needle)));
81
+ const seated = new Set(starts);
82
+ const contains = all.filter(
83
+ (name) => !seated.has(name) && spellings(name).some((s) => s.includes(needle))
84
+ );
82
85
  return [...starts, ...contains];
83
86
  }
84
- // Unfiltered, the block's own language leads. This menu is ours to order, so the row
85
- // that matters goes where the eye already is rather than somewhere the list has to be
86
- // scrolled to — which is why nothing here scrolls itself on open.
87
+ // Unfiltered, the block's own language leads, in the spelling the fence uses — so the
88
+ // row that matters is where the eye already is, and its canonical twin is not below it.
87
89
  const current = language;
88
- const rest = all.filter((name) => name !== current);
89
- return all.includes(current) ? [current, ...rest] : [current, ...rest];
90
+ const rest = all.filter((name) => name !== current && !isCurrentLanguage(name));
91
+ return [current, ...rest];
90
92
  });
91
93
 
94
+ /** One language in two spellings is still the block's own, so the mark comparing them asks
95
+ * the registry rather than the text. */
96
+ function isCurrentLanguage(name: string): boolean {
97
+ const canonical = (spelling: string) =>
98
+ getLanguageGrammar(spelling)?.name ?? spelling.toLowerCase();
99
+ return canonical(name) === canonical(language);
100
+ }
101
+
92
102
  // The highlight cannot outrun a filter that shortened the list under it.
93
103
  $effect(() => {
94
104
  if (activeIndex >= suggestions.length) activeIndex = 0;
@@ -107,6 +117,7 @@
107
117
  if (!editable) return;
108
118
  draft = '';
109
119
  filtering = false;
120
+ highlightMoved = false;
110
121
  menuOpen = false;
111
122
  editing = true;
112
123
  // Row 0 is the block's own language (see `suggestions`), so the highlight starts where
@@ -148,22 +159,25 @@
148
159
  if (e.isComposing) return;
149
160
  if (e.key === 'Enter') {
150
161
  e.preventDefault();
151
- // The highlighted row is what Enter takes once the user has engaged the list; the
152
- // raw draft stands otherwise, and whenever the filter matched nothing, so an
153
- // unregistered language survives both opening the field and typing it in.
154
- // The highlighted row wins; a typed string the list never matched still commits, so
155
- // an unregistered language stays authorable.
156
- commit(suggestions[activeIndex] ?? (draft.trim() === '' ? language : draft));
162
+ // A spelling the registry resolves is a name, not a query: it commits as typed, so
163
+ // `js` survives a picker whose rows are canonical. A moved highlight takes the row.
164
+ const typed = draft.trim();
165
+ const namesLanguage = !highlightMoved && typed !== '' && getLanguageGrammar(typed) !== null;
166
+ commit(
167
+ namesLanguage ? typed : (suggestions[activeIndex] ?? (typed === '' ? language : typed))
168
+ );
157
169
  } else if (e.key === 'Escape') {
158
170
  e.preventDefault();
159
171
  close();
160
172
  onCancel(true);
161
173
  } else if (e.key === 'ArrowDown') {
162
174
  e.preventDefault();
175
+ highlightMoved = true;
163
176
  activeIndex = suggestions.length === 0 ? 0 : (activeIndex + 1) % suggestions.length;
164
177
  scrollActiveIntoView();
165
178
  } else if (e.key === 'ArrowUp') {
166
179
  e.preventDefault();
180
+ highlightMoved = true;
167
181
  activeIndex =
168
182
  suggestions.length === 0 ? 0 : (activeIndex - 1 + suggestions.length) % suggestions.length;
169
183
  scrollActiveIntoView();
@@ -431,6 +445,7 @@
431
445
  placeholder="Search for a language…"
432
446
  oninput={() => {
433
447
  filtering = true;
448
+ highlightMoved = false;
434
449
  activeIndex = 0;
435
450
  }}
436
451
  onkeydown={onFieldKeyDown}
@@ -444,10 +459,13 @@
444
459
  <button
445
460
  type="button"
446
461
  role="option"
447
- aria-selected={name === language}
462
+ aria-selected={isCurrentLanguage(name)}
448
463
  data-active={i === activeIndex}
449
464
  tabindex="-1"
450
- onmouseenter={() => (activeIndex = i)}
465
+ onmouseenter={() => {
466
+ highlightMoved = true;
467
+ activeIndex = i;
468
+ }}
451
469
  onmousedown={(e) => {
452
470
  // mousedown, not click: the field's blur would otherwise cancel the edit
453
471
  // before a click ever landed.
@@ -456,7 +474,7 @@
456
474
  }}
457
475
  >
458
476
  <span class="code-lang-name">{name}</span>
459
- {#if name === language}
477
+ {#if isCurrentLanguage(name)}
460
478
  <span class="code-lang-check">
461
479
  {@render icon('<path d="M20 6 9 17l-5-5"/>')}
462
480
  </span>
@@ -24,6 +24,12 @@ import ruby from 'highlight.js/lib/languages/ruby';
24
24
  import markdown from 'highlight.js/lib/languages/markdown';
25
25
  import diff from 'highlight.js/lib/languages/diff';
26
26
  import latex from 'highlight.js/lib/languages/latex';
27
+ import csharp from 'highlight.js/lib/languages/csharp';
28
+ import kotlin from 'highlight.js/lib/languages/kotlin';
29
+ import swift from 'highlight.js/lib/languages/swift';
30
+ import php from 'highlight.js/lib/languages/php';
31
+ import powershell from 'highlight.js/lib/languages/powershell';
32
+ import dockerfile from 'highlight.js/lib/languages/dockerfile';
27
33
  let booted = false;
28
34
  export function bootstrapCodeLanguages() {
29
35
  if (booted)
@@ -47,6 +53,12 @@ export function bootstrapCodeLanguages() {
47
53
  registerLanguage('markdown', markdown, ['md']);
48
54
  registerLanguage('diff', diff);
49
55
  registerLanguage('latex', latex, ['tex']);
56
+ registerLanguage('csharp', csharp, ['cs', 'c#']);
57
+ registerLanguage('kotlin', kotlin, ['kt']);
58
+ registerLanguage('swift', swift);
59
+ registerLanguage('php', php);
60
+ registerLanguage('powershell', powershell, ['ps1']);
61
+ registerLanguage('dockerfile', dockerfile, ['docker']);
50
62
  registerPasteSurface(codePasteSurface);
51
63
  registerCodeContextActions();
52
64
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Language registry for code-block tokenization. Nothing outside this directory
3
3
  * imports highlight.js directly; static vs. dynamic loading is a policy on top.
4
+ * Aliases fold to their canonical name here, so no caller carries its own table.
4
5
  */
5
6
  import type { LanguageFn } from 'highlight.js';
6
7
  export interface LanguageGrammar {
@@ -11,11 +12,10 @@ export interface LanguageGrammar {
11
12
  export declare function registerLanguage(name: string, definition: LanguageFn, aliasList?: readonly string[]): void;
12
13
  /** Info strings with trailing attributes (`js {1-3}`) resolve on the first token. */
13
14
  export declare function getLanguageGrammar(infoString: string): LanguageGrammar | null;
14
- /**
15
- * Every registered grammar name, plus every alias, sorted — what the language picker
16
- * offers. Aliases are listed as their own entries: a user typing `js` should find it
17
- * rather than having to know it resolves to `javascript`.
18
- */
15
+ /** Every registered language once, under its canonical name, sorted — the picker's rows. */
19
16
  export declare function listLanguages(): string[];
17
+ /** The alternate spellings a language answers to, from any spelling of it — a picker's filter
18
+ * matches `rs` to `rust` without a table of its own. */
19
+ export declare function getLanguageAliases(name: string): readonly string[];
20
20
  /** Test-only: clear all registered languages. */
21
21
  export declare function __resetRegistryForTests(): void;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Language registry for code-block tokenization. Nothing outside this directory
3
3
  * imports highlight.js directly; static vs. dynamic loading is a policy on top.
4
+ * Aliases fold to their canonical name here, so no caller carries its own table.
4
5
  */
5
6
  const grammars = new Map();
6
7
  const aliases = new Map();
@@ -14,25 +15,32 @@ export function registerLanguage(name, definition, aliasList = []) {
14
15
  aliases.set(alias.toLowerCase(), key);
15
16
  }
16
17
  }
18
+ /** The fold: any spelling of a language to the one name it is registered under. A name of its
19
+ * own beats another language's alias, so no grammar is shadowed by somebody's nickname. */
20
+ function canonicalName(spelling) {
21
+ const key = spelling.toLowerCase();
22
+ if (grammars.has(key))
23
+ return key;
24
+ return aliases.get(key) ?? key;
25
+ }
17
26
  /** Info strings with trailing attributes (`js {1-3}`) resolve on the first token. */
18
27
  export function getLanguageGrammar(infoString) {
19
28
  const trimmed = infoString.trim();
20
29
  if (trimmed.length === 0)
21
30
  return null;
22
- const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
23
- const resolvedName = aliases.get(firstToken) ?? firstToken;
24
- return grammars.get(resolvedName) ?? null;
31
+ return grammars.get(canonicalName(trimmed.split(/\s+/)[0])) ?? null;
25
32
  }
26
- /**
27
- * Every registered grammar name, plus every alias, sorted — what the language picker
28
- * offers. Aliases are listed as their own entries: a user typing `js` should find it
29
- * rather than having to know it resolves to `javascript`.
30
- */
33
+ /** Every registered language once, under its canonical name, sorted — the picker's rows. */
31
34
  export function listLanguages() {
32
- const names = new Set(grammars.keys());
33
- for (const alias of aliases.keys())
34
- names.add(alias);
35
- return [...names].sort();
35
+ return [...grammars.keys()].sort();
36
+ }
37
+ /** The alternate spellings a language answers to, from any spelling of it — a picker's filter
38
+ * matches `rs` to `rust` without a table of its own. */
39
+ export function getLanguageAliases(name) {
40
+ const key = canonicalName(name);
41
+ if (!grammars.has(key))
42
+ return [];
43
+ return [...aliases].filter(([, target]) => target === key).map(([alias]) => alias);
36
44
  }
37
45
  /** Test-only: clear all registered languages. */
38
46
  export function __resetRegistryForTests() {
@@ -52,9 +52,9 @@ export interface EditableLeafDeps {
52
52
  onSourceEdit?(text: string): void;
53
53
  /**
54
54
  * A source that is only its own chrome (a `$$$$` with no body line), completed to the shape
55
- * a caret can sit in, with where the caret goes. Applied as the source is revealed, so the
56
- * block the user just entered is one they can type into and delete; null leaves the bytes
57
- * alone. The edit is the reveal's own, committed on blur like any other.
55
+ * a caret can sit in, with where the caret goes. Applied as the source is revealed AND after
56
+ * any edit that empties it, so the block is one the user can type into and delete however it
57
+ * got there; null leaves the bytes alone. The edit is the reveal's own, committed on blur.
58
58
  */
59
59
  completeBareSource?(text: string): {
60
60
  text: string;
@@ -325,11 +325,15 @@ export function createEditableLeaf(deps) {
325
325
  const keystroke = end - start <= 1 && insert.length <= 1 && insert !== '\n';
326
326
  if (deps.renderSource)
327
327
  recordSourceEdit(text, getCursorOffset(el) ?? start, keystroke);
328
- const next = text.slice(0, start) + insert + text.slice(end);
328
+ const spliced = text.slice(0, start) + insert + text.slice(end);
329
+ // An edit that empties the body leaves the same chrome-only source a bare block arrives
330
+ // as, so the edit door owes the completion the reveal door applies.
331
+ const completed = deps.completeBareSource?.(spliced) ?? null;
332
+ const next = completed?.text ?? spliced;
329
333
  paintSource(el, next);
330
334
  deps.onSourceEdit?.(next);
331
335
  preEditOffset = start;
332
- setCursorOffset(el, asDomTextOffset(start + insert.length));
336
+ setCursorOffset(el, asDomTextOffset(completed?.caret ?? start + insert.length));
333
337
  // Armed once the edit has settled, so the window measures the gap the user leaves.
334
338
  if (deps.renderSource && keystroke)
335
339
  sourceBatch.armPause();
@@ -147,6 +147,7 @@
147
147
  node={item}
148
148
  index={absoluteIndex}
149
149
  myPath={[...myPath, absoluteIndex]}
150
+ itemCount={(node.children ?? []).length}
150
151
  slots={listState.refSlots}
151
152
  />
152
153
  {/each}
@@ -39,16 +39,20 @@
39
39
  import { dispatchKindCommand } from '../../../schema/block-commands';
40
40
  import type { AnyCommandId } from '../../../schema/command-id';
41
41
  import BlockDragHandle from '../../BlockDragHandle.svelte';
42
+ import { showsListItemDragHandle } from '../../drag-handle';
42
43
 
43
44
  let {
44
45
  node,
45
46
  index,
46
47
  myPath = [],
48
+ itemCount,
47
49
  slots
48
50
  }: {
49
51
  node: NodeView;
50
52
  index: number;
51
53
  myPath?: number[];
54
+ /** How many items the enclosing list holds; a lone item has no sibling to reorder past. */
55
+ itemCount: number;
52
56
  slots?: RefSlots<BlockComponent>;
53
57
  } = $props();
54
58
 
@@ -67,6 +71,10 @@
67
71
  // $derived, not a mount-time snapshot: a runtime prop toggle must reach blocks
68
72
  // that window in and out after the change, not just those mounted at mount.
69
73
  const dragHandles = $derived(getDragHandles?.() ?? false);
74
+ // The grip is the only pointer road into a reorder, and a reorder needs a sibling: a lone
75
+ // item stays a reorder host (a dragged sibling never arrives, but the class costs nothing)
76
+ // and simply shows nothing to grab.
77
+ const showsHandle = $derived(showsListItemDragHandle(itemCount, dragHandles));
70
78
  const presentationMode = $derived(getPresentationMode?.() ?? 'source');
71
79
  const readOnly = $derived(presentationMode === 'reading');
72
80
 
@@ -260,7 +268,7 @@
260
268
  <div
261
269
  class="list-item-block"
262
270
  class:reorder-host={dragHandles}
263
- class:handle-host={dragHandles}
271
+ class:handle-host={showsHandle}
264
272
  data-task-checked={taskCheckedAttr}
265
273
  data-list-marker={presentationMarkerKind}
266
274
  bind:this={boxEl}
@@ -277,8 +285,9 @@
277
285
  />
278
286
  </div>
279
287
  <!-- A list item IS a reorder unit; its inner content BlockList passes the
280
- default reorderable={false}, so the paragraph inside gets no handle. -->
281
- {#if dragHandles}
288
+ default reorderable={false}, so the paragraph inside gets no handle. A lone item
289
+ renders none either: its drag is list-scoped and there is no sibling to pass. -->
290
+ {#if showsHandle}
282
291
  <BlockDragHandle />
283
292
  {/if}
284
293
  </div>
@@ -5,6 +5,8 @@ type $$ComponentProps = {
5
5
  node: NodeView;
6
6
  index: number;
7
7
  myPath?: number[];
8
+ /** How many items the enclosing list holds; a lone item has no sibling to reorder past. */
9
+ itemCount: number;
8
10
  slots?: RefSlots<BlockComponent>;
9
11
  };
10
12
  declare const ListItemBlock: import("svelte").Component<$$ComponentProps, {
@@ -2,6 +2,9 @@
2
2
  /** Caret in a real text node the reader can see: the native caret renders there, so the
3
3
  * synthetic indicator stands down. Hidden marker text renders nothing, so it does not count. */
4
4
  export declare function caretIsInTextContent(el: HTMLElement, sel: Selection | null): boolean;
5
+ /** A ranged selection this surface holds — the click ladder's rungs, a shift-click, a
6
+ * drag-select. It is the gesture's own paint, so a click-time caret seat stands down for it. */
7
+ export declare function surfaceHoldsRange(el: HTMLElement, sel: Selection | null): boolean;
5
8
  /** Ctrl/meta/alt held — a platform command, never an edit of the byte beside the caret. */
6
9
  export declare function hasModifier(e: KeyboardEvent): boolean;
7
10
  /** A typed character: no modifier chord, one code point. A chord is a command instead. */
@@ -9,6 +9,14 @@ export function caretIsInTextContent(el, sel) {
9
9
  const node = sel.getRangeAt(0).startContainer;
10
10
  return node.nodeType === Node.TEXT_NODE && el.contains(node) && !isHiddenMarkerText(node, el);
11
11
  }
12
+ /** A ranged selection this surface holds — the click ladder's rungs, a shift-click, a
13
+ * drag-select. It is the gesture's own paint, so a click-time caret seat stands down for it. */
14
+ export function surfaceHoldsRange(el, sel) {
15
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0)
16
+ return false;
17
+ const range = sel.getRangeAt(0);
18
+ return el.contains(range.startContainer) || el.contains(range.endContainer);
19
+ }
12
20
  /** Ctrl/meta/alt held — a platform command, never an edit of the byte beside the caret. */
13
21
  export function hasModifier(e) {
14
22
  return e.ctrlKey || e.metaKey || e.altKey;