@sigx/lynx-markdown 0.4.7 → 0.4.9

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 (41) hide show
  1. package/README.md +47 -17
  2. package/dist/ast.d.ts +18 -1
  3. package/dist/editor/MarkdownEditor.d.ts +73 -0
  4. package/dist/editor/MarkdownEditor.js +243 -0
  5. package/dist/editor/convert/docToMd.d.ts +24 -0
  6. package/dist/editor/convert/docToMd.js +224 -0
  7. package/dist/editor/convert/mdToDoc.d.ts +32 -0
  8. package/dist/editor/convert/mdToDoc.js +221 -0
  9. package/dist/editor/convert/overlap.d.ts +31 -0
  10. package/dist/editor/convert/overlap.js +70 -0
  11. package/dist/editor/plugin.d.ts +118 -0
  12. package/dist/editor/plugin.js +16 -0
  13. package/dist/editor/toolbar/Toolbar.d.ts +25 -0
  14. package/dist/editor/toolbar/Toolbar.js +51 -0
  15. package/dist/editor/toolbar/items.d.ts +35 -0
  16. package/dist/editor/toolbar/items.js +29 -0
  17. package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
  18. package/dist/editor/trigger/SuggestionPopup.js +77 -0
  19. package/dist/editor/trigger/position.d.ts +47 -0
  20. package/dist/editor/trigger/position.js +62 -0
  21. package/dist/editor/trigger/session.d.ts +49 -0
  22. package/dist/editor/trigger/session.js +162 -0
  23. package/dist/index.d.ts +19 -6
  24. package/dist/index.js +9 -3
  25. package/dist/parser/blocks.d.ts +2 -1
  26. package/dist/parser/blocks.js +13 -13
  27. package/dist/parser/extensions.d.ts +51 -0
  28. package/dist/parser/extensions.js +18 -0
  29. package/dist/parser/incremental.d.ts +10 -1
  30. package/dist/parser/incremental.js +5 -2
  31. package/dist/parser/inline.d.ts +15 -5
  32. package/dist/parser/inline.js +55 -9
  33. package/dist/render/MarkdownView.d.ts +10 -4
  34. package/dist/render/MarkdownView.js +13 -5
  35. package/dist/render/components.d.ts +13 -1
  36. package/dist/render/engine.js +11 -0
  37. package/package.json +18 -7
  38. package/dist/XMarkdown.d.ts +0 -36
  39. package/dist/XMarkdown.js +0 -36
  40. package/dist/jsx-augment.d.ts +0 -83
  41. package/dist/jsx-augment.js +0 -1
package/README.md CHANGED
@@ -9,10 +9,6 @@ AI output: as the source string grows token-by-token, finalized blocks keep a
9
9
  stable identity and are never re-parsed or remounted, so completed content
10
10
  doesn't flicker or reflow while new tokens stream in.
11
11
 
12
- The package also ships [`XMarkdown`](#xmarkdown--native-element-wrapper), the
13
- thin wrapper around Lynx's native `<x-markdown>` element, for cases where you
14
- want the platform's built-in renderer.
15
-
16
12
  ## Install
17
13
 
18
14
  ```
@@ -120,22 +116,56 @@ import { markdownComponents } from '@sigx/lynx-daisyui';
120
116
  <MarkdownView value={src} components={markdownComponents} />;
121
117
  ```
122
118
 
123
- ## `XMarkdown` — native element wrapper
124
-
125
- `XMarkdown` wraps Lynx's native `<x-markdown>` XElement. It's fast where
126
- available but platform-gated and opaque (the engine owns parsing and styling):
127
119
 
128
- | Platform | First version | Notes |
129
- | -------- | --------------- | --------------------------------------------------- |
130
- | Harmony | Lynx 3.7.0 | Stable. |
131
- | Android | Lynx 3.8.0-rc.0 | Ships as `org.lynxsdk.lynx:lynx_xelement_markdown`. |
132
- | iOS | (post-3.8.0) | Currently only on the upstream `main` branch. |
120
+ ## `MarkdownEditor` true-WYSIWYG editing
133
121
 
134
- On platforms where `<x-markdown>` is not registered, it renders nothing. Prefer
135
- `MarkdownView` for cross-platform, streaming, and customizable rendering.
122
+ `MarkdownEditor` provides WYSIWYG markdown editing on the native
123
+ [`@sigx/lynx-richtext`](../lynx-richtext) element (optional peer dependency
124
+ bold is bold *inside* the input, not `**` markers). The external contract stays
125
+ markdown: `value` in, `onChange(markdown)` out.
136
126
 
137
127
  ```tsx
138
- import { XMarkdown } from '@sigx/lynx-markdown';
128
+ import { MarkdownEditor, type MarkdownEditorController } from '@sigx/lynx-markdown';
129
+
130
+ let ctrl: MarkdownEditorController | null = null;
131
+
132
+ <MarkdownEditor
133
+ value={draft}
134
+ placeholder="Message…"
135
+ minLines={1}
136
+ maxLines={4} // chat-style: grow 1→4 lines, then scroll
137
+ mode="auto" // 'auto' | 'fixed' | 'fullscreen'
138
+ confirmType="send"
139
+ onChange={(md) => { draft = md; }}
140
+ onSelectionChange={(sel) => toolbarState(sel.activeFormats)}
141
+ controllerRef={(c) => { ctrl = c; }}
142
+ />;
143
+
144
+ // Imperative commands (what a toolbar drives):
145
+ ctrl?.toggleBold();
146
+ ctrl?.setHeading(2);
147
+ ctrl?.clear(); // chat send
148
+ ```
149
+
150
+ ### Toolbar
139
151
 
140
- <XMarkdown value={'# Hello'} effect="typewriter" onLink={(e) => open(e.detail.url)} />;
152
+ The built-in toolbar mirrors the renderer's override pattern items are data,
153
+ rendering is replaceable:
154
+
155
+ ```tsx
156
+ <MarkdownEditor toolbar /> // neutral default, below the input
157
+ <MarkdownEditor toolbar="top" /> // above instead
158
+ <MarkdownEditor toolbar toolbarItems={items} /> // custom ToolbarItem[]
159
+ <MarkdownEditor toolbar renderToolbarItem={fn} />// re-skin (what daisyUI does)
141
160
  ```
161
+
162
+ `<EditorToolbar>` is also exported standalone (pass `controller` + `selection`
163
+ yourself — e.g. inside a keyboard-sticky send bar). `ToolbarItem` is
164
+ `{ id, label, icon?, group?, isActive?(sel), run(ctx) }`; plugins contribute
165
+ items through the same shape (P3). The toolbar root carries `ignore-focus` so
166
+ taps never blur the editor. The daisyUI skin lives in `@sigx/lynx-daisyui`.
167
+
168
+ v1 models paragraphs, headings, and bold/italic/strike/code/link in-field;
169
+ everything else (lists, tables, code fences) round-trips **losslessly** as raw
170
+ markdown source via `raw` blocks until later phases model them. Conversion
171
+ helpers `mdToDoc`/`docToMd` are exported for advanced use.
package/dist/ast.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * reconciliation) and `raw` (the exact source slice, used for memo equality and
9
9
  * introspection).
10
10
  */
11
- export type InlineNode = InlineText | InlineStrong | InlineEm | InlineDel | InlineCodeSpan | InlineLink | InlineImage | InlineAutolink | InlineBreak;
11
+ export type InlineNode = InlineText | InlineStrong | InlineEm | InlineDel | InlineCodeSpan | InlineLink | InlineImage | InlineAutolink | InlineBreak | InlineExtension;
12
12
  /** A run of literal text. */
13
13
  export interface InlineText {
14
14
  type: 'text';
@@ -58,6 +58,23 @@ export interface InlineAutolink {
58
58
  export interface InlineBreak {
59
59
  type: 'br';
60
60
  }
61
+ /**
62
+ * A plugin-defined inline construct (e.g. a mention `@[label](id)`), produced
63
+ * by a `ParserInlineExtension`'s `match`. Opaque to the emphasis stack — same
64
+ * precedence tier as code spans and links. Rendered through
65
+ * `components.extension[name]`, falling back to `raw` as literal text.
66
+ */
67
+ export interface InlineExtension {
68
+ type: 'extension';
69
+ /** Extension name; the render dispatch key. */
70
+ name: string;
71
+ /** Opaque structured payload (e.g. `{ id, label }` for a mention). */
72
+ attrs: Record<string, string>;
73
+ /** Optional nested inline content; absent for leaf extensions. */
74
+ children?: InlineNode[];
75
+ /** Exact source slice — the literal-text fallback and round-trip source. */
76
+ raw: string;
77
+ }
61
78
  export interface BlockBase {
62
79
  /** Stable reconciliation key, assigned by the incremental engine. */
63
80
  key: string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
3
+ * `<sigx-richtext>` element.
4
+ *
5
+ * The external contract is **markdown**: `value` in, `onChange(markdown)` out.
6
+ * Internally the editor converts markdown ↔ the element's `RichDoc` span model
7
+ * (`convert/mdToDoc`, `convert/docToMd`) and drives formatting through
8
+ * fire-and-forget commands; the element is the single source of truth for live
9
+ * text and selection (lightly-controlled — keystrokes are never echoed back).
10
+ *
11
+ * ### Echo / IME rules (JS side)
12
+ * - An incoming `value` identical to the last markdown we emitted is our own
13
+ * echo → ignored (string compare; exact).
14
+ * - Otherwise it's compared structurally against the element's last document —
15
+ * only genuinely different content is pushed via `setDocument`.
16
+ * - While the IME is composing, external values are buffered and applied on
17
+ * the composition-end change; `onChange` is also suppressed mid-composition.
18
+ *
19
+ * Sizing: `minLines`/`maxLines` × line height drive the element's auto-grow
20
+ * window (`mode="auto"`, chat-style 1 → N lines then internal scroll);
21
+ * `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
22
+ * parent.
23
+ */
24
+ import { type Define } from '@sigx/lynx';
25
+ import { type SelectionState } from '@sigx/lynx-richtext';
26
+ import { type ToolbarRenderItem } from './toolbar/Toolbar.js';
27
+ import { type ToolbarItem } from './toolbar/items.js';
28
+ import type { MarkdownEditorPlugin } from './plugin.js';
29
+ export type MarkdownEditorMode = 'auto' | 'fixed' | 'fullscreen';
30
+ /** Imperative command surface — what toolbars and plugins drive. */
31
+ export interface MarkdownEditorController {
32
+ toggleBold(): void;
33
+ toggleItalic(): void;
34
+ toggleStrike(): void;
35
+ toggleCode(): void;
36
+ /** 1–6 sets a heading; 0 reverts to paragraph. */
37
+ setHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void;
38
+ insertText(text: string): void;
39
+ /**
40
+ * Replace `[start, end)` (UTF-16 offsets in the document text) with
41
+ * `text`, leaving the caret after it. What trigger plugins use to swap
42
+ * the typed query for the selected suggestion.
43
+ */
44
+ replaceRange(start: number, end: number, text: string): void;
45
+ /** Clear the document (chat send). */
46
+ clear(): void;
47
+ focus(): void;
48
+ blur(): void;
49
+ /** The current markdown (as of the last element change). */
50
+ getMarkdown(): string;
51
+ /** The current selection state (as of the last selection event). */
52
+ getSelection(): SelectionState | null;
53
+ }
54
+ export type MarkdownEditorProps = Define.Prop<'value', string, false> & Define.Prop<'placeholder', string, false> & Define.Prop<'minLines', number, false> & Define.Prop<'maxLines', number, false> & Define.Prop<'mode', MarkdownEditorMode, false> & Define.Prop<'fontSize', number, false> & Define.Prop<'textColor', string, false> & Define.Prop<'accentColor', string, false> & Define.Prop<'placeholderColor', string, false> & Define.Prop<'confirmType', 'send' | 'search' | 'next' | 'go' | 'done', false> & Define.Prop<'autoFocus', boolean, false> & Define.Prop<'disabled', boolean, false> & Define.Prop<'class', string, false>
55
+ /**
56
+ * Built-in formatting toolbar. `true` ≡ `'bottom'` — below the input is
57
+ * the common chat placement (selection handles and the iOS edit menu pop
58
+ * up *above* the selection, so a toolbar on top would sit under them).
59
+ */
60
+ & Define.Prop<'toolbar', boolean | 'top' | 'bottom', false>
61
+ /** Override the built-in toolbar's items (defaults to `defaultToolbarItems`). */
62
+ & Define.Prop<'toolbarItems', ToolbarItem[], false>
63
+ /** Re-skin the built-in toolbar's item rendering (what daisyUI does). */
64
+ & Define.Prop<'renderToolbarItem', ToolbarRenderItem, false>
65
+ /**
66
+ * Editor plugins ({@link MarkdownEditorPlugin}) — inline syntax, trigger
67
+ * suggestions, extra toolbar items. Pass a stable array (e.g. a module
68
+ * constant); the set is captured at mount.
69
+ */
70
+ & Define.Prop<'plugins', MarkdownEditorPlugin[], false> & Define.Prop<'onChange', (markdown: string) => void, false> & Define.Prop<'onSelectionChange', (sel: SelectionState) => void, false> & Define.Prop<'onFocus', () => void, false> & Define.Prop<'onBlur', () => void, false>
71
+ /** Receives the imperative controller once on mount. */
72
+ & Define.Prop<'controllerRef', (ctrl: MarkdownEditorController) => void, false>;
73
+ export declare const MarkdownEditor: import("@sigx/runtime-core").ComponentFactory<MarkdownEditorProps, void, {}>;
@@ -0,0 +1,243 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
2
+ /**
3
+ * `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
4
+ * `<sigx-richtext>` element.
5
+ *
6
+ * The external contract is **markdown**: `value` in, `onChange(markdown)` out.
7
+ * Internally the editor converts markdown ↔ the element's `RichDoc` span model
8
+ * (`convert/mdToDoc`, `convert/docToMd`) and drives formatting through
9
+ * fire-and-forget commands; the element is the single source of truth for live
10
+ * text and selection (lightly-controlled — keystrokes are never echoed back).
11
+ *
12
+ * ### Echo / IME rules (JS side)
13
+ * - An incoming `value` identical to the last markdown we emitted is our own
14
+ * echo → ignored (string compare; exact).
15
+ * - Otherwise it's compared structurally against the element's last document —
16
+ * only genuinely different content is pushed via `setDocument`.
17
+ * - While the IME is composing, external values are buffered and applied on
18
+ * the composition-end change; `onChange` is also suppressed mid-composition.
19
+ *
20
+ * Sizing: `minLines`/`maxLines` × line height drive the element's auto-grow
21
+ * window (`mode="auto"`, chat-style 1 → N lines then internal scroll);
22
+ * `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
23
+ * parent.
24
+ */
25
+ import { component, signal, useElementLayout, watch } from '@sigx/lynx';
26
+ import { RichTextInput, RichTextMethods, docEquals, normalizeDoc, emptyDoc, } from '@sigx/lynx-richtext';
27
+ import { mdToDoc } from './convert/mdToDoc.js';
28
+ import { docToMd } from './convert/docToMd.js';
29
+ import { EditorToolbar } from './toolbar/Toolbar.js';
30
+ import { defaultToolbarItems } from './toolbar/items.js';
31
+ import { createTriggerSessionManager } from './trigger/session.js';
32
+ import { SuggestionPopup } from './trigger/SuggestionPopup.js';
33
+ const DEFAULT_FONT_SIZE = 16;
34
+ /** Vertical padding the element applies internally (8 top + 8 bottom). */
35
+ const ELEMENT_PADDING = 16;
36
+ export const MarkdownEditor = component(({ props }) => {
37
+ let el = null;
38
+ // --- plugins (captured at mount; pass a stable array) ---
39
+ const plugins = props.plugins ?? [];
40
+ const inlinePlugins = plugins.filter((p) => p.inline);
41
+ // Duplicate identifiers would silently last-win (conversion maps) or make
42
+ // trigger routing ambiguous (plugin name lookups) — flag the config error.
43
+ const warnDuplicates = (key, values) => {
44
+ const seen = new Set();
45
+ for (const value of values) {
46
+ if (seen.has(value)) {
47
+ // Conversion maps resolve last-wins, trigger routing first-wins
48
+ // — don't promise either; duplicates are a config error.
49
+ console.warn(`[MarkdownEditor] duplicate plugin ${key} "${value}" — resolution is ambiguous, rename to disambiguate.`);
50
+ }
51
+ seen.add(value);
52
+ }
53
+ };
54
+ warnDuplicates('name', plugins.map((p) => p.name));
55
+ warnDuplicates('syntax.name', inlinePlugins.map((p) => p.inline.syntax.name));
56
+ warnDuplicates('docMapping.spanType', inlinePlugins.map((p) => p.inline.docMapping.spanType));
57
+ // Trigger routing is first-match-wins — a duplicate char/pattern means the
58
+ // later plugin's trigger is silently unreachable.
59
+ warnDuplicates('trigger', plugins
60
+ .filter((p) => p.trigger)
61
+ .map((p) => (p.trigger.char !== undefined ? `char:${p.trigger.char}` : `pattern:${p.trigger.pattern}`)));
62
+ const convertIn = inlinePlugins.length
63
+ ? {
64
+ extensions: inlinePlugins.map((p) => p.inline.syntax),
65
+ spanMappers: Object.fromEntries(inlinePlugins.map((p) => [p.inline.syntax.name, p.inline.docMapping.toSpan])),
66
+ }
67
+ : undefined;
68
+ const convertOut = inlinePlugins.length
69
+ ? {
70
+ serializers: new Map(inlinePlugins.map((p) => [
71
+ p.inline.docMapping.spanType,
72
+ (span, text) => p.inline.serialize(span, text),
73
+ ])),
74
+ }
75
+ : undefined;
76
+ const pluginToolbarItems = plugins.flatMap((p) => p.toolbar ?? []);
77
+ // --- sync state (see module docs) ---
78
+ const initialMd = typeof props.value === 'string' ? props.value : '';
79
+ let lastEmittedMd = initialMd;
80
+ let lastDocFromElement = normalizeDoc(mdToDoc(initialMd, 0, convertIn));
81
+ let lastSeenVersion = 0;
82
+ let composing = false;
83
+ let pendingExternal = null;
84
+ // Reactive box (not a plain var): the built-in toolbar derives active
85
+ // states from it, so selection events must re-render.
86
+ const selBox = signal({ current: null });
87
+ // Auto-grow: the native element reports its (clamped) content height and
88
+ // the editor feeds it back as the element's layout height — Lynx layout
89
+ // sizes views from styles, never from native intrinsic content.
90
+ const reportedHeight = signal(0);
91
+ // --- trigger sessions (suggestion popup) ---
92
+ const triggers = plugins
93
+ .filter((p) => p.trigger)
94
+ .map((p) => ({ plugin: p.name, spec: p.trigger }));
95
+ // Boxed like selBox: signal values must be objects.
96
+ const sessionBox = signal({ current: null });
97
+ const triggerManager = triggers.length
98
+ ? createTriggerSessionManager({
99
+ triggers,
100
+ onUpdate: (s) => {
101
+ sessionBox.current = s;
102
+ },
103
+ })
104
+ : null;
105
+ // Page-absolute frame of the input's relative wrapper — the popup needs
106
+ // it to relate the element-local caret rect to the keyboard.
107
+ const { layout: inputFrame, onLayoutChange: onInputLayout } = useElementLayout();
108
+ const applyExternal = (md) => {
109
+ if (md === lastEmittedMd)
110
+ return; // our own echo
111
+ if (composing) {
112
+ pendingExternal = md;
113
+ return;
114
+ }
115
+ const doc = mdToDoc(md, lastSeenVersion, convertIn);
116
+ if (docEquals(normalizeDoc(doc), lastDocFromElement)) {
117
+ lastEmittedMd = md; // same content, different markdown spelling
118
+ return;
119
+ }
120
+ RichTextMethods.setDocument(el, doc);
121
+ };
122
+ watch(() => props.value, (next) => {
123
+ if (typeof next === 'string')
124
+ applyExternal(next);
125
+ });
126
+ const handleChange = (doc, isComposing) => {
127
+ composing = isComposing;
128
+ lastSeenVersion = doc.v;
129
+ lastDocFromElement = normalizeDoc(doc);
130
+ triggerManager?.syncText(doc.text);
131
+ if (isComposing)
132
+ return;
133
+ const md = docToMd(doc, convertOut);
134
+ if (md !== lastEmittedMd) {
135
+ lastEmittedMd = md;
136
+ props.onChange?.(md);
137
+ }
138
+ if (pendingExternal !== null) {
139
+ const pending = pendingExternal;
140
+ pendingExternal = null;
141
+ applyExternal(pending);
142
+ }
143
+ };
144
+ const controller = {
145
+ toggleBold: () => RichTextMethods.toggleFormat(el, 'bold'),
146
+ toggleItalic: () => RichTextMethods.toggleFormat(el, 'italic'),
147
+ toggleStrike: () => RichTextMethods.toggleFormat(el, 'strike'),
148
+ toggleCode: () => RichTextMethods.toggleFormat(el, 'code'),
149
+ setHeading: (level) => {
150
+ if (level === 0)
151
+ RichTextMethods.setBlockType(el, 'paragraph');
152
+ else
153
+ RichTextMethods.setBlockType(el, 'heading', level);
154
+ },
155
+ insertText: (text) => RichTextMethods.insertText(el, text),
156
+ replaceRange: (start, end, text) => {
157
+ // insertText replaces the selection — two existing fire-and-forget
158
+ // commands compose into a range replace (no new native method).
159
+ RichTextMethods.setSelectionRange(el, start, end);
160
+ RichTextMethods.insertText(el, text);
161
+ },
162
+ clear: () => RichTextMethods.setDocument(el, emptyDoc(lastSeenVersion)),
163
+ focus: () => RichTextMethods.focus(el),
164
+ blur: () => RichTextMethods.blur(el),
165
+ getMarkdown: () => lastEmittedMd ?? '',
166
+ getSelection: () => selBox.current,
167
+ };
168
+ props.controllerRef?.(controller);
169
+ const handleTriggerSelect = (item) => {
170
+ const session = triggerManager?.session;
171
+ if (!session)
172
+ return;
173
+ const spec = plugins.find((p) => p.name === session.plugin)?.trigger;
174
+ if (!spec)
175
+ return;
176
+ const range = { start: session.anchor, end: session.caret };
177
+ const api = {
178
+ replaceQuery: (text) => controller.replaceRange(range.start, range.end, text),
179
+ range,
180
+ controller,
181
+ };
182
+ triggerManager.close();
183
+ spec.onSelect(item, api);
184
+ };
185
+ return () => {
186
+ const fontSize = props.fontSize ?? DEFAULT_FONT_SIZE;
187
+ const lineHeight = Math.round(fontSize * 1.5);
188
+ const mode = props.mode ?? 'auto';
189
+ const minLines = Math.max(1, props.minLines ?? 1);
190
+ const maxLines = Math.max(minLines, props.maxLines ?? 4);
191
+ let minHeight = minLines * lineHeight + ELEMENT_PADDING;
192
+ let maxHeight = maxLines * lineHeight + ELEMENT_PADDING;
193
+ if (mode === 'fixed')
194
+ minHeight = maxHeight;
195
+ if (mode === 'fullscreen')
196
+ maxHeight = 0; // unbounded; element fills parent
197
+ const toolbarPlacement = props.toolbar === true ? 'bottom' : props.toolbar;
198
+ // Plugin items append after the base set (explicit `toolbarItems` wins
199
+ // as the base, otherwise the defaults).
200
+ const toolbarItems = pluginToolbarItems.length
201
+ ? [...(props.toolbarItems ?? defaultToolbarItems), ...pluginToolbarItems]
202
+ : props.toolbarItems;
203
+ const toolbarNode = toolbarPlacement
204
+ ? (_jsx(EditorToolbar, { controller: controller, selection: selBox.current, items: toolbarItems, renderItem: props.renderToolbarItem }))
205
+ : null;
206
+ const session = sessionBox.current;
207
+ const activeTrigger = session
208
+ ? plugins.find((p) => p.name === session.plugin)?.trigger
209
+ : undefined;
210
+ // Gate on the wrapper frame being measured — before the first
211
+ // bindlayoutchange the placement math would clamp against a 0-height
212
+ // container and misposition the popup.
213
+ const popupNode = session && activeTrigger && session.items.length > 0 && inputFrame.value
214
+ ? (_jsx(SuggestionPopup, { items: session.items, caretRect: selBox.current?.caretRect ?? null, containerFrame: inputFrame.value, renderItem: activeTrigger.renderItem, onSelect: handleTriggerSelect }))
215
+ : null;
216
+ const inputNode = (_jsx(RichTextInput, { value: mdToDoc(initialMd, 0, convertIn), placeholder: props.placeholder, editable: props.disabled !== true, minHeight: minHeight, maxHeight: maxHeight, fontSize: fontSize, textColor: props.textColor, accentColor: props.accentColor, placeholderColor: props.placeholderColor, confirmType: props.confirmType, autoFocus: props.autoFocus, style: mode === 'fullscreen'
217
+ ? { flexGrow: 1 }
218
+ : { height: Math.max(minHeight, Math.min(reportedHeight.value || minHeight, maxHeight)) }, onElement: (handle) => {
219
+ el = handle;
220
+ }, onHeightChange: (height) => {
221
+ reportedHeight.value = height;
222
+ }, onChange: handleChange, onSelection: (sel) => {
223
+ selBox.current = sel;
224
+ triggerManager?.syncCaret(sel.start === sel.end ? sel.start : -1);
225
+ props.onSelectionChange?.(sel);
226
+ }, onFocus: () => props.onFocus?.(), onBlur: () => {
227
+ triggerManager?.close();
228
+ props.onBlur?.();
229
+ } }));
230
+ return (_jsxs("view", { class: props.class, style: {
231
+ display: 'flex',
232
+ flexDirection: 'column',
233
+ ...(mode === 'fullscreen' ? { flexGrow: 1, flexShrink: 1 } : {}),
234
+ }, children: [toolbarPlacement === 'top' ? toolbarNode : null, triggers.length
235
+ ? (_jsxs("view", { bindlayoutchange: onInputLayout, style: {
236
+ position: 'relative',
237
+ ...(mode === 'fullscreen'
238
+ ? { display: 'flex', flexDirection: 'column', flexGrow: 1 }
239
+ : {}),
240
+ }, children: [inputNode, popupNode] }))
241
+ : inputNode, toolbarPlacement === 'bottom' ? toolbarNode : null] }));
242
+ };
243
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * {@link RichDoc} → markdown — the serializer (inverse of `mdToDoc`).
3
+ *
4
+ * Segments the flat text by `blocks[]` (raw chunks verbatim, heading lines
5
+ * prefixed, remaining lines as paragraphs), then serializes inline spans per
6
+ * segment via elementary runs + a close/reopen delimiter stack (valid nesting
7
+ * from arbitrarily overlapping spans).
8
+ *
9
+ * Round-trip contract: `mdToDoc(docToMd(doc))` is structurally equal to a
10
+ * *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
11
+ * blank lines normalize; `raw` blocks round-trip byte-for-byte.
12
+ */
13
+ import type { InlineSpan, RichDoc } from '@sigx/lynx-richtext';
14
+ /** Serialize one plugin-owned span back to markdown (a plugin's `inline.serialize`). */
15
+ export type SpanSerializer = (span: InlineSpan, text: string) => string;
16
+ export interface DocToMdOptions {
17
+ /**
18
+ * Plugin serializers keyed by the span type they own
19
+ * (`docMapping.spanType`). A plugin-owned span is emitted **atomically**:
20
+ * the serializer's output replaces the covered text entirely.
21
+ */
22
+ serializers?: ReadonlyMap<string, SpanSerializer>;
23
+ }
24
+ export declare function docToMd(doc: RichDoc, options?: DocToMdOptions): string;