@liminis/editor 0.1.0-rc.1

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 (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,135 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { COMMAND_PRIORITY_CRITICAL } from 'lexical';
4
+ import { readAnchorFields, removeMarksForAnnotation, removeMarksForAnnotations, wrapNativeRangeInMark, } from './annotation-marks.js';
5
+ import { OPEN_ANNOTATION_COMPOSER_COMMAND } from './annotationCommands.js';
6
+ /**
7
+ * Turns a user selection into a captured anchor for a configured kind.
8
+ *
9
+ * Deliberately headless: it renders no composer of its own. Whether a comment
10
+ * gets a textarea or a correction gets a panel is host UX, so this plugin's
11
+ * whole job is to listen for the create command, run the shared capture
12
+ * primitive, and hand the anchor out. Both kinds take the same path here —
13
+ * only `retainMarkOnCreate` differs, which is what SC-001 rests on.
14
+ *
15
+ * Not gated on `editable`: annotating is decoupled from editing, and a
16
+ * read-only document is a legitimate place to comment.
17
+ */
18
+ export function AnnotationPlugin({ kinds, onCreateAnnotation, logger, wikiLinkPromotion }) {
19
+ const [editor] = useLexicalComposerContext();
20
+ const mintId = useCallback(() => {
21
+ // crypto.randomUUID is unavailable in some non-secure contexts (an
22
+ // unpackaged Electron or file:// window); the id only has to be unique
23
+ // within this document's live marks, so a fallback is fine.
24
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
25
+ return crypto.randomUUID();
26
+ }
27
+ // A timestamp alone is not enough: `performance.now()` is coarsened for
28
+ // fingerprinting protection, so two creates in quick succession can land on
29
+ // the same value. Ids gate mark placement and removal via `hasID`/`deleteID`,
30
+ // so a collision would merge two annotations onto one live mark and let
31
+ // removing either delete the other's anchor. The random suffix makes that
32
+ // effectively impossible without needing a real UUID source.
33
+ const stamp = Math.floor(performance.now() * 1000).toString(36);
34
+ const random = Math.random().toString(36).slice(2, 10);
35
+ return `anno-${stamp}-${random}-${globalThis.isSecureContext ? 's' : 'u'}`;
36
+ }, []);
37
+ // Marks placed by a create whose anchor read hasn't run yet. The wrap is
38
+ // synchronous but the read-back is deferred, so between the two the mark
39
+ // exists in the document while its id exists nowhere else — the host has not
40
+ // been told, and `AnnotationMarkerPlugin` walks only host-supplied
41
+ // annotations. Dropping one there would strand a bare `<mark>` the user
42
+ // cannot dismiss until the document is reparsed (review finding,
43
+ // @handarbeit-pruefer). The teardown effect below retracts whatever is left.
44
+ //
45
+ // Held in a ref, not an effect-local, so it survives a re-run of the
46
+ // command-registration effect: a create in flight belongs to the plugin, not
47
+ // to the effect run that happened to register the handler.
48
+ const pendingIdsRef = useRef(new Set());
49
+ // Cancellation is a property of the plugin's *lifetime*, not of one effect
50
+ // run. Scoping it per-run would make correctness depend on React flushing the
51
+ // cleanup after the pending microtask: that holds on the default async render
52
+ // path (verified by test), but a synchronous re-render — `flushSync`, or a
53
+ // discrete input forcing one — runs the cleanup first, and a create in flight
54
+ // would be silently dropped with the host never told (review finding,
55
+ // CodeRabbit). Tying the flag to unmount makes the ordering irrelevant.
56
+ const unmountedRef = useRef(false);
57
+ useEffect(() => {
58
+ unmountedRef.current = false;
59
+ const pendingIds = pendingIdsRef.current;
60
+ return () => {
61
+ // Runs only on unmount (`editor` is stable for the composer's lifetime),
62
+ // which is exactly the case where a deferred read must not proceed: it
63
+ // would touch a torn-down editor and call the host back with a stale rect
64
+ // and a stale closure.
65
+ unmountedRef.current = true;
66
+ // Retract regardless of the kind's `retainMarkOnCreate`: a retained mark
67
+ // is only safe to leave behind because the host owns it from the moment
68
+ // `onCreateAnnotation` fires, and that never happened for these.
69
+ //
70
+ // Safe to touch the editor here: this cleanup runs as ordinary React
71
+ // work, not inside the Lexical update the command handler ran in, so the
72
+ // queued wrap has already been applied by the time we get here.
73
+ if (pendingIds.size > 0) {
74
+ removeMarksForAnnotations(editor, [...pendingIds]);
75
+ pendingIds.clear();
76
+ }
77
+ };
78
+ }, [editor]);
79
+ useEffect(() => {
80
+ const pendingIds = pendingIdsRef.current;
81
+ const unregister = editor.registerCommand(OPEN_ANNOTATION_COMPOSER_COMMAND, ({ kind }) => {
82
+ const config = kinds[kind];
83
+ if (!config) {
84
+ // An unconfigured kind is a host wiring mistake, not a crash (edge
85
+ // case in the spec): warn through the injected logger and decline.
86
+ logger?.warn(`[annotations] ignoring create for unconfigured kind "${kind}"`);
87
+ return false;
88
+ }
89
+ if (!config.createAffordance) {
90
+ logger?.warn(`[annotations] kind "${kind}" is host-injected only; no create affordance`);
91
+ return false;
92
+ }
93
+ // Read the on-screen rect while the selection is still live and before
94
+ // wrapping mutates the DOM around it.
95
+ const nativeSelection = window.getSelection();
96
+ if (!nativeSelection || nativeSelection.rangeCount === 0 || nativeSelection.isCollapsed)
97
+ return false;
98
+ const nativeRange = nativeSelection.getRangeAt(0);
99
+ const rect = nativeRange.getBoundingClientRect();
100
+ const id = mintId();
101
+ const retainMark = config.retainMarkOnCreate ?? false;
102
+ // Wrap now, while the native range still points at live DOM — but read
103
+ // the anchor back on a microtask rather than inline.
104
+ //
105
+ // A command handler runs *inside* an active Lexical update, so the
106
+ // update `wrapNativeRangeInMark` performs is nested: Lexical queues it
107
+ // and applies it when the outer update completes, which means its
108
+ // result is not observable synchronously here. Reading the anchor
109
+ // inline would read the pre-mark state and always come back null. The
110
+ // microtask runs after the outer update has flushed, so the mark is
111
+ // really in the tree by then.
112
+ wrapNativeRangeInMark(editor, nativeRange, id);
113
+ // The mark is now in the tree but its id has not been reported to the
114
+ // host yet, so nothing outside this plugin can clean it up. Tracked
115
+ // until the microtask hands it over (or the cleanup retracts it).
116
+ pendingIds.add(id);
117
+ queueMicrotask(() => {
118
+ if (unmountedRef.current)
119
+ return;
120
+ pendingIds.delete(id);
121
+ const anchor = readAnchorFields(editor, id, { wikiLinkPromotion });
122
+ // Comments keep the mark (it is their live anchor and the composer's
123
+ // highlight); corrections discard it so nothing ever paints.
124
+ if (!retainMark)
125
+ removeMarksForAnnotation(editor, id);
126
+ onCreateAnnotation({ kind, id, anchor, rect });
127
+ });
128
+ return true;
129
+ }, COMMAND_PRIORITY_CRITICAL);
130
+ // Only the registration is scoped to this effect run; cancellation and the
131
+ // pending-mark retraction live in the teardown effect above.
132
+ return unregister;
133
+ }, [editor, kinds, onCreateAnnotation, logger, mintId, wikiLinkPromotion]);
134
+ return null;
135
+ }
@@ -0,0 +1,34 @@
1
+ import type { MutableRefObject } from 'react';
2
+ import { type Annotation, type AnnotationEditorHandle, type AnnotationKindConfigs } from '../../annotations/types.js';
3
+ import type { OffsetSpan } from '../mapper/mdastToLexical.js';
4
+ import type { WikiLinkPromotionMode } from '../mapper/lexicalToMdast.js';
5
+ import { type AnnotationCreateEvent } from './AnnotationPlugin.js';
6
+ export interface AnnotationSurfaceProps {
7
+ kinds: AnnotationKindConfigs;
8
+ annotations: Annotation[];
9
+ activeAnnotationId: string | null;
10
+ scrollToAnnotation?: {
11
+ id: string;
12
+ nonce: number;
13
+ } | null;
14
+ onCreateAnnotation?: (event: AnnotationCreateEvent) => void;
15
+ onActivateAnnotation?: (id: string) => void;
16
+ editorHandleRef?: MutableRefObject<AnnotationEditorHandle | null>;
17
+ offsetSpansRef: MutableRefObject<OffsetSpan[]>;
18
+ markdownTextRef: MutableRefObject<string>;
19
+ offsetsVersion: number;
20
+ logger?: {
21
+ warn: (message: string, ...args: unknown[]) => void;
22
+ };
23
+ /** Forwarded to the anchor-capture export pass — see `Editor`'s own prop of the same name. */
24
+ wikiLinkPromotion?: WikiLinkPromotionMode;
25
+ }
26
+ /**
27
+ * The entire annotation feature surface, behind one lazily-imported boundary.
28
+ *
29
+ * Nothing here is reachable from `Editor.tsx` by static import: the editor
30
+ * loads this module only when a host actually configures annotation kinds
31
+ * (FR-004/SC-004). That is why the create plugin, marker plugin, placement and
32
+ * editor handle are assembled here rather than mounted individually.
33
+ */
34
+ export default function AnnotationSurface({ kinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, editorHandleRef, offsetSpansRef, markdownTextRef, offsetsVersion, logger, wikiLinkPromotion, }: AnnotationSurfaceProps): import("react").JSX.Element;
@@ -0,0 +1,115 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from 'react';
3
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
4
+ import { deriveMarkerTargets, } from '../../annotations/types.js';
5
+ import { collectLiveAnchorSnapshots, placeMarksForAnchors, removeMarksForAnnotation, removeMarksForAnnotations, } from './annotation-marks.js';
6
+ import { AnnotationPlugin } from './AnnotationPlugin.js';
7
+ import { AnnotationMarkerPlugin } from './AnnotationMarkerPlugin.js';
8
+ import { registerMarkOverlapResolver } from './mark-overlap-resolver.js';
9
+ /**
10
+ * Places a live `MarkNode` for every marker target — the set `deriveMarkerTargets`
11
+ * already narrowed by each kind's live-mark policy.
12
+ *
13
+ * This is also where the spec's "document edited mid-resolution" edge case is
14
+ * handled, and it needs no dedicated guard: `placeMarkForAnchor` re-verifies
15
+ * the anchor's target text against the *current* offset spans and returns
16
+ * false rather than placing on unrelated text. A stale anchor therefore
17
+ * no-ops, and the next parse bumps `offsetsVersion`, which re-runs this pass
18
+ * against fresh spans. Placement is never applied on the strength of an
19
+ * out-of-date resolution.
20
+ */
21
+ function AnnotationMarkPlacementPlugin({ offsetSpansRef, markdownTextRef, targets, offsetsVersion, }) {
22
+ const [editor] = useLexicalComposerContext();
23
+ // Ids this plugin has placed a mark for. Retracting a mark is keyed off this
24
+ // rather than off "every mark in the document" on purpose: a comment created
25
+ // through `AnnotationPlugin` with `retainMarkOnCreate` keeps its mark while
26
+ // the host is still deciding whether to persist it, and that mark is not
27
+ // ours to remove.
28
+ const placedRef = useRef(new Set());
29
+ useEffect(() => {
30
+ // No outcome filter here: `deriveMarkerTargets` has already applied each
31
+ // kind's `livemarkPolicy` (defaulting to `shouldPlaceLiveMark` over the
32
+ // outcome). Re-testing the outcome would let the policy only ever subtract
33
+ // — a host that deliberately opts a `flagged` annotation into a live mark
34
+ // would get a marker target the marker plugin then decorates but nothing
35
+ // ever places, which is the one inconsistency the single policy exists to
36
+ // prevent.
37
+ const eligible = new Set(targets.map((target) => target.annotationId));
38
+ // Retract first: an annotation the host removed, or whose outcome or
39
+ // live-mark policy now declines it, must lose the mark it was given.
40
+ // Without this the MarkNode survives every later pass — still in the
41
+ // document, still rendering as a bare <mark>, and no longer decorated by
42
+ // the marker plugin (which only walks current targets), so it becomes an
43
+ // undismissable highlight with nothing behind it.
44
+ // Batched for the same reason placement is: a host dropping a set of live
45
+ // annotations at once would otherwise pay one synchronous Lexical
46
+ // reconciliation per id (review finding, @handarbeit-pruefer).
47
+ const retract = [...placedRef.current].filter((id) => !eligible.has(id));
48
+ if (retract.length > 0) {
49
+ removeMarksForAnnotations(editor, retract);
50
+ for (const id of retract)
51
+ placedRef.current.delete(id);
52
+ }
53
+ // One batched update for the whole set, not one per annotation: an
54
+ // `offsetsVersion` bump re-runs this pass over every target, and a
55
+ // per-target `editor.update()` would force a synchronous Lexical
56
+ // reconciliation each time (review finding, @handarbeit-pruefer).
57
+ const placed = placeMarksForAnchors(editor, offsetSpansRef.current, markdownTextRef.current, targets.map((target) => ({ anchor: target.anchor, id: target.annotationId })));
58
+ for (const id of placed)
59
+ placedRef.current.add(id);
60
+ // The two refs are read fresh on every run; offsetsVersion (bumped whenever
61
+ // a (re)parse produces new spans) is what should actually re-trigger this.
62
+ }, [editor, targets, offsetsVersion, offsetSpansRef, markdownTextRef]);
63
+ return null;
64
+ }
65
+ /**
66
+ * Keeps overlapping annotations on *shared* MarkNodes rather than nested ones
67
+ * — see `mark-overlap-resolver.ts`. Mounted here because it is only meaningful
68
+ * once annotation kinds are configured, and this whole module is lazily loaded
69
+ * on exactly that condition.
70
+ */
71
+ function MarkOverlapResolverPlugin() {
72
+ const [editor] = useLexicalComposerContext();
73
+ useEffect(() => registerMarkOverlapResolver(editor), [editor]);
74
+ return null;
75
+ }
76
+ /**
77
+ * Publishes the live-mark mechanism to the host via an imperative ref — the
78
+ * host lives outside this LexicalComposer tree and has no other way to reach
79
+ * the editor instance.
80
+ */
81
+ function AnnotationEditorHandlePlugin({ handleRef, wikiLinkPromotion, }) {
82
+ const [editor] = useLexicalComposerContext();
83
+ useEffect(() => {
84
+ handleRef.current = {
85
+ removeMarksForAnnotation: (id) => removeMarksForAnnotation(editor, id),
86
+ collectLiveAnchorSnapshots: (markdownText) => collectLiveAnchorSnapshots(editor, markdownText, { wikiLinkPromotion }),
87
+ };
88
+ return () => {
89
+ handleRef.current = null;
90
+ };
91
+ }, [editor, handleRef, wikiLinkPromotion]);
92
+ return null;
93
+ }
94
+ /**
95
+ * The entire annotation feature surface, behind one lazily-imported boundary.
96
+ *
97
+ * Nothing here is reachable from `Editor.tsx` by static import: the editor
98
+ * loads this module only when a host actually configures annotation kinds
99
+ * (FR-004/SC-004). That is why the create plugin, marker plugin, placement and
100
+ * editor handle are assembled here rather than mounted individually.
101
+ */
102
+ export default function AnnotationSurface({ kinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, editorHandleRef, offsetSpansRef, markdownTextRef, offsetsVersion, logger, wikiLinkPromotion, }) {
103
+ const targets = useMemo(() => deriveMarkerTargets(annotations, kinds), [annotations, kinds]);
104
+ // An annotation whose kind has no configuration is dropped by
105
+ // deriveMarkerTargets; surface that as a warning rather than silently
106
+ // ignoring the host's data.
107
+ useEffect(() => {
108
+ for (const annotation of annotations) {
109
+ if (!kinds[annotation.kind]) {
110
+ logger?.warn(`[annotations] annotation ${annotation.id} has unconfigured kind "${annotation.kind}"; ignoring`);
111
+ }
112
+ }
113
+ }, [annotations, kinds, logger]);
114
+ return (_jsxs(_Fragment, { children: [_jsx(MarkOverlapResolverPlugin, {}), onCreateAnnotation && (_jsx(AnnotationPlugin, { kinds: kinds, onCreateAnnotation: onCreateAnnotation, logger: logger, wikiLinkPromotion: wikiLinkPromotion })), _jsx(AnnotationMarkPlacementPlugin, { offsetSpansRef: offsetSpansRef, markdownTextRef: markdownTextRef, targets: targets, offsetsVersion: offsetsVersion }), _jsx(AnnotationMarkerPlugin, { targets: targets, kinds: kinds, activeAnnotationId: activeAnnotationId, onActivateAnnotation: onActivateAnnotation ?? (() => undefined), scrollToAnnotation: scrollToAnnotation }), editorHandleRef && (_jsx(AnnotationEditorHandlePlugin, { handleRef: editorHandleRef, wikiLinkPromotion: wikiLinkPromotion }))] }));
115
+ }
@@ -0,0 +1,20 @@
1
+ import type { ImagePathResolution } from '../../types.js';
2
+ interface AssetContextValue {
3
+ assetBaseUri: string | undefined;
4
+ documentDirUri: string | undefined;
5
+ imagePathResolution: ImagePathResolution;
6
+ resolveAssetPath: (path: string) => string;
7
+ /** Resolve a workspace-relative file path to a data URL for display */
8
+ resolveLocalAsset?: (relativePath: string) => Promise<string | null>;
9
+ }
10
+ export declare const AssetContext: import("react").Context<AssetContextValue>;
11
+ export declare function useAssetContext(): AssetContextValue;
12
+ export interface AssetContextOptions {
13
+ assetBaseUri?: string;
14
+ documentDirUri?: string;
15
+ imagePathResolution?: ImagePathResolution;
16
+ /** Resolve a workspace-relative file path to a data URL for display */
17
+ resolveLocalAsset?: (relativePath: string) => Promise<string | null>;
18
+ }
19
+ export declare function createAssetContextValue(options: AssetContextOptions): AssetContextValue;
20
+ export {};
@@ -0,0 +1,81 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const AssetContext = createContext({
3
+ assetBaseUri: undefined,
4
+ documentDirUri: undefined,
5
+ imagePathResolution: 'document',
6
+ resolveAssetPath: (path) => path,
7
+ });
8
+ export function useAssetContext() {
9
+ return useContext(AssetContext);
10
+ }
11
+ /**
12
+ * Normalize a relative path by resolving . and .. segments
13
+ * Examples:
14
+ * "./images/photo.png" -> "images/photo.png"
15
+ * "../assets/image.png" -> "../assets/image.png" (kept as-is, resolved by browser)
16
+ * "assets/../images/photo.png" -> "images/photo.png"
17
+ */
18
+ function normalizePath(path) {
19
+ // Remove leading ./ (current directory reference)
20
+ const normalized = path.replace(/^\.\//, '');
21
+ // Split into segments and resolve . and ..
22
+ const segments = normalized.split('/');
23
+ const result = [];
24
+ for (const segment of segments) {
25
+ if (segment === '.' || segment === '') {
26
+ // Skip current directory references and empty segments
27
+ continue;
28
+ }
29
+ else if (segment === '..') {
30
+ // Go up one directory if possible
31
+ if (result.length > 0 && result[result.length - 1] !== '..') {
32
+ result.pop();
33
+ }
34
+ else {
35
+ // Keep leading .. segments (they go above the base)
36
+ result.push(segment);
37
+ }
38
+ }
39
+ else {
40
+ result.push(segment);
41
+ }
42
+ }
43
+ return result.join('/');
44
+ }
45
+ export function createAssetContextValue(options) {
46
+ const { assetBaseUri, documentDirUri, imagePathResolution = 'document', resolveLocalAsset, } = options;
47
+ return {
48
+ assetBaseUri,
49
+ documentDirUri,
50
+ imagePathResolution,
51
+ resolveLocalAsset,
52
+ resolveAssetPath: (path) => {
53
+ // If it's already an absolute URL (http, https, vscode-webview-resource, data), return as-is
54
+ if (path.startsWith('http://') ||
55
+ path.startsWith('https://') ||
56
+ path.startsWith('vscode-webview-resource:') ||
57
+ path.startsWith('data:')) {
58
+ return path;
59
+ }
60
+ // Paths starting with / are absolute from workspace root, regardless of
61
+ // imagePathResolution setting. Strip the leading / so the path is
62
+ // workspace-relative (readBinaryFile joins with workspace root).
63
+ // Check BEFORE normalizePath which strips the leading /.
64
+ if (path.startsWith('/')) {
65
+ return normalizePath(path.slice(1));
66
+ }
67
+ // Normalize the path to handle ./ and ../ correctly
68
+ const normalizedPath = normalizePath(path);
69
+ // Choose the base URI based on settings
70
+ const baseUri = imagePathResolution === 'document' ? documentDirUri : assetBaseUri;
71
+ // If we have a base URI, resolve relative paths and normalize the
72
+ // combined result so that "../" segments are resolved (e.g.
73
+ // "notes/sub/" + "../assets/img.svg" → "notes/assets/img.svg")
74
+ if (baseUri) {
75
+ return normalizePath(baseUri + normalizedPath);
76
+ }
77
+ // Fallback to original path
78
+ return path;
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * BlockClickPlugin - Enables clicking to the right of blocks to place cursor
3
+ *
4
+ * When clicking in empty space to the right of a block's content,
5
+ * this selects that block. For non-editable blocks (images, HRs),
6
+ * pressing Enter will then create a new paragraph below.
7
+ */
8
+ export declare function BlockClickPlugin(): null;
@@ -0,0 +1,126 @@
1
+ import { useEffect } from 'react';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { $getNodeByKey, $isDecoratorNode, $createParagraphNode, $getRoot, $getNearestNodeFromDOMNode, COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND, } from 'lexical';
4
+ /**
5
+ * BlockClickPlugin - Enables clicking to the right of blocks to place cursor
6
+ *
7
+ * When clicking in empty space to the right of a block's content,
8
+ * this selects that block. For non-editable blocks (images, HRs),
9
+ * pressing Enter will then create a new paragraph below.
10
+ */
11
+ export function BlockClickPlugin() {
12
+ const [editor] = useLexicalComposerContext();
13
+ useEffect(() => {
14
+ const rootElement = editor.getRootElement();
15
+ if (!rootElement)
16
+ return;
17
+ const handleClick = (event) => {
18
+ // Only handle left clicks
19
+ if (event.button !== 0)
20
+ return;
21
+ const clickX = event.clientX;
22
+ const clickY = event.clientY;
23
+ // Get the content editable bounds
24
+ const rootRect = rootElement.getBoundingClientRect();
25
+ // Must be within editor bounds
26
+ if (clickY < rootRect.top || clickY > rootRect.bottom)
27
+ return;
28
+ if (clickX < rootRect.left || clickX > rootRect.right)
29
+ return;
30
+ // Find which block element the click Y coordinate corresponds to
31
+ const targetBlock = findBlockAtY(rootElement, clickY);
32
+ if (!targetBlock) {
33
+ return;
34
+ }
35
+ const blockRect = targetBlock.getBoundingClientRect();
36
+ // Check if click is to the RIGHT of the block's actual content
37
+ // (not inside the block itself)
38
+ if (clickX <= blockRect.right) {
39
+ return;
40
+ }
41
+ // We clicked in the empty space to the right of the block
42
+ event.preventDefault();
43
+ event.stopPropagation();
44
+ // Use Lexical's method to find the node from DOM element
45
+ editor.update(() => {
46
+ const node = $getNearestNodeFromDOMNode(targetBlock);
47
+ if (!node) {
48
+ return;
49
+ }
50
+ // Get the top-level block (direct child of root)
51
+ let topLevelNode = node;
52
+ const root = $getRoot();
53
+ while (topLevelNode && topLevelNode.getParent() !== root) {
54
+ topLevelNode = topLevelNode.getParent();
55
+ }
56
+ if (topLevelNode) {
57
+ selectAfterNode(topLevelNode);
58
+ }
59
+ });
60
+ // Focus the editor
61
+ editor.focus();
62
+ };
63
+ // Use mousedown instead of click to capture before other handlers
64
+ rootElement.addEventListener('mousedown', handleClick);
65
+ return () => {
66
+ rootElement.removeEventListener('mousedown', handleClick);
67
+ };
68
+ }, [editor]);
69
+ // Handle Enter key when a decorator node is selected to insert paragraph after
70
+ useEffect(() => {
71
+ return editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
72
+ // Check if we have an element selection on a decorator node
73
+ return editor.getEditorState().read(() => {
74
+ const sel = editor.getEditorState()._selection;
75
+ if (sel?.getNodes().length !== 1)
76
+ return false;
77
+ const node = sel.getNodes()[0];
78
+ if (!$isDecoratorNode(node))
79
+ return false;
80
+ // Insert a new paragraph after the decorator node
81
+ editor.update(() => {
82
+ const decoratorNode = $getNodeByKey(node.getKey());
83
+ if (!decoratorNode)
84
+ return;
85
+ const paragraph = $createParagraphNode();
86
+ decoratorNode.insertAfter(paragraph);
87
+ paragraph.select();
88
+ });
89
+ event?.preventDefault();
90
+ return true;
91
+ });
92
+ }, COMMAND_PRIORITY_LOW);
93
+ }, [editor]);
94
+ return null;
95
+ }
96
+ /**
97
+ * Find the top-level block element at a given Y coordinate
98
+ */
99
+ function findBlockAtY(rootElement, y) {
100
+ const children = Array.from(rootElement.children);
101
+ for (const child of children) {
102
+ const rect = child.getBoundingClientRect();
103
+ if (y >= rect.top && y <= rect.bottom) {
104
+ return child;
105
+ }
106
+ }
107
+ // If click is below all blocks, return the last block
108
+ if (children.length > 0) {
109
+ const lastChild = children[children.length - 1];
110
+ const lastRect = lastChild.getBoundingClientRect();
111
+ if (y > lastRect.bottom) {
112
+ return lastChild;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ /**
118
+ * Select after a node - inserts a new paragraph after the block and selects it
119
+ */
120
+ function selectAfterNode(node) {
121
+ // For all block-level nodes, insert a new paragraph after and select it
122
+ // This gives the user a clear place to type
123
+ const paragraph = $createParagraphNode();
124
+ node.insertAfter(paragraph);
125
+ paragraph.select();
126
+ }
@@ -0,0 +1 @@
1
+ export declare function CodeBlockPlugin(): import("react").JSX.Element;