@sigx/lynx-markdown 0.4.8 → 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.
- package/README.md +18 -0
- package/dist/ast.d.ts +18 -1
- package/dist/editor/MarkdownEditor.d.ts +26 -1
- package/dist/editor/MarkdownEditor.js +134 -18
- package/dist/editor/convert/docToMd.d.ts +12 -2
- package/dist/editor/convert/docToMd.js +27 -4
- package/dist/editor/convert/mdToDoc.d.ts +15 -2
- package/dist/editor/convert/mdToDoc.js +45 -11
- package/dist/editor/plugin.d.ts +118 -0
- package/dist/editor/plugin.js +16 -0
- package/dist/editor/toolbar/Toolbar.d.ts +25 -0
- package/dist/editor/toolbar/Toolbar.js +51 -0
- package/dist/editor/toolbar/items.d.ts +35 -0
- package/dist/editor/toolbar/items.js +29 -0
- package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
- package/dist/editor/trigger/SuggestionPopup.js +77 -0
- package/dist/editor/trigger/position.d.ts +47 -0
- package/dist/editor/trigger/position.js +62 -0
- package/dist/editor/trigger/session.d.ts +49 -0
- package/dist/editor/trigger/session.js +162 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +4 -0
- package/dist/parser/blocks.d.ts +2 -1
- package/dist/parser/blocks.js +13 -13
- package/dist/parser/extensions.d.ts +51 -0
- package/dist/parser/extensions.js +18 -0
- package/dist/parser/incremental.d.ts +10 -1
- package/dist/parser/incremental.js +5 -2
- package/dist/parser/inline.d.ts +15 -5
- package/dist/parser/inline.js +55 -9
- package/dist/render/MarkdownView.d.ts +8 -1
- package/dist/render/MarkdownView.js +11 -2
- package/dist/render/components.d.ts +13 -1
- package/dist/render/engine.js +11 -0
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -147,6 +147,24 @@ ctrl?.setHeading(2);
|
|
|
147
147
|
ctrl?.clear(); // chat send
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Toolbar
|
|
151
|
+
|
|
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)
|
|
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
|
+
|
|
150
168
|
v1 models paragraphs, headings, and bold/italic/strike/code/link in-field;
|
|
151
169
|
everything else (lists, tables, code fences) round-trips **losslessly** as raw
|
|
152
170
|
markdown source via `raw` blocks until later phases model them. Conversion
|
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;
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { type Define } from '@sigx/lynx';
|
|
25
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';
|
|
26
29
|
export type MarkdownEditorMode = 'auto' | 'fixed' | 'fullscreen';
|
|
27
30
|
/** Imperative command surface — what toolbars and plugins drive. */
|
|
28
31
|
export interface MarkdownEditorController {
|
|
@@ -33,6 +36,12 @@ export interface MarkdownEditorController {
|
|
|
33
36
|
/** 1–6 sets a heading; 0 reverts to paragraph. */
|
|
34
37
|
setHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void;
|
|
35
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;
|
|
36
45
|
/** Clear the document (chat send). */
|
|
37
46
|
clear(): void;
|
|
38
47
|
focus(): void;
|
|
@@ -42,7 +51,23 @@ export interface MarkdownEditorController {
|
|
|
42
51
|
/** The current selection state (as of the last selection event). */
|
|
43
52
|
getSelection(): SelectionState | null;
|
|
44
53
|
}
|
|
45
|
-
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>
|
|
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>
|
|
46
71
|
/** Receives the imperative controller once on mount. */
|
|
47
72
|
& Define.Prop<'controllerRef', (ctrl: MarkdownEditorController) => void, false>;
|
|
48
73
|
export declare const MarkdownEditor: import("@sigx/runtime-core").ComponentFactory<MarkdownEditorProps, void, {}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
|
|
4
4
|
* `<sigx-richtext>` element.
|
|
@@ -22,27 +22,89 @@ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
|
22
22
|
* `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
|
|
23
23
|
* parent.
|
|
24
24
|
*/
|
|
25
|
-
import { component, signal, watch } from '@sigx/lynx';
|
|
25
|
+
import { component, signal, useElementLayout, watch } from '@sigx/lynx';
|
|
26
26
|
import { RichTextInput, RichTextMethods, docEquals, normalizeDoc, emptyDoc, } from '@sigx/lynx-richtext';
|
|
27
27
|
import { mdToDoc } from './convert/mdToDoc.js';
|
|
28
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';
|
|
29
33
|
const DEFAULT_FONT_SIZE = 16;
|
|
30
34
|
/** Vertical padding the element applies internally (8 top + 8 bottom). */
|
|
31
35
|
const ELEMENT_PADDING = 16;
|
|
32
36
|
export const MarkdownEditor = component(({ props }) => {
|
|
33
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 ?? []);
|
|
34
77
|
// --- sync state (see module docs) ---
|
|
35
78
|
const initialMd = typeof props.value === 'string' ? props.value : '';
|
|
36
79
|
let lastEmittedMd = initialMd;
|
|
37
|
-
let lastDocFromElement = normalizeDoc(mdToDoc(initialMd));
|
|
80
|
+
let lastDocFromElement = normalizeDoc(mdToDoc(initialMd, 0, convertIn));
|
|
38
81
|
let lastSeenVersion = 0;
|
|
39
82
|
let composing = false;
|
|
40
83
|
let pendingExternal = null;
|
|
41
|
-
|
|
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 });
|
|
42
87
|
// Auto-grow: the native element reports its (clamped) content height and
|
|
43
88
|
// the editor feeds it back as the element's layout height — Lynx layout
|
|
44
89
|
// sizes views from styles, never from native intrinsic content.
|
|
45
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();
|
|
46
108
|
const applyExternal = (md) => {
|
|
47
109
|
if (md === lastEmittedMd)
|
|
48
110
|
return; // our own echo
|
|
@@ -50,7 +112,7 @@ export const MarkdownEditor = component(({ props }) => {
|
|
|
50
112
|
pendingExternal = md;
|
|
51
113
|
return;
|
|
52
114
|
}
|
|
53
|
-
const doc = mdToDoc(md, lastSeenVersion);
|
|
115
|
+
const doc = mdToDoc(md, lastSeenVersion, convertIn);
|
|
54
116
|
if (docEquals(normalizeDoc(doc), lastDocFromElement)) {
|
|
55
117
|
lastEmittedMd = md; // same content, different markdown spelling
|
|
56
118
|
return;
|
|
@@ -65,9 +127,10 @@ export const MarkdownEditor = component(({ props }) => {
|
|
|
65
127
|
composing = isComposing;
|
|
66
128
|
lastSeenVersion = doc.v;
|
|
67
129
|
lastDocFromElement = normalizeDoc(doc);
|
|
130
|
+
triggerManager?.syncText(doc.text);
|
|
68
131
|
if (isComposing)
|
|
69
132
|
return;
|
|
70
|
-
const md = docToMd(doc);
|
|
133
|
+
const md = docToMd(doc, convertOut);
|
|
71
134
|
if (md !== lastEmittedMd) {
|
|
72
135
|
lastEmittedMd = md;
|
|
73
136
|
props.onChange?.(md);
|
|
@@ -90,13 +153,35 @@ export const MarkdownEditor = component(({ props }) => {
|
|
|
90
153
|
RichTextMethods.setBlockType(el, 'heading', level);
|
|
91
154
|
},
|
|
92
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
|
+
},
|
|
93
162
|
clear: () => RichTextMethods.setDocument(el, emptyDoc(lastSeenVersion)),
|
|
94
163
|
focus: () => RichTextMethods.focus(el),
|
|
95
164
|
blur: () => RichTextMethods.blur(el),
|
|
96
165
|
getMarkdown: () => lastEmittedMd ?? '',
|
|
97
|
-
getSelection: () =>
|
|
166
|
+
getSelection: () => selBox.current,
|
|
98
167
|
};
|
|
99
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
|
+
};
|
|
100
185
|
return () => {
|
|
101
186
|
const fontSize = props.fontSize ?? DEFAULT_FONT_SIZE;
|
|
102
187
|
const lineHeight = Math.round(fontSize * 1.5);
|
|
@@ -109,19 +194,50 @@ export const MarkdownEditor = component(({ props }) => {
|
|
|
109
194
|
minHeight = maxHeight;
|
|
110
195
|
if (mode === 'fullscreen')
|
|
111
196
|
maxHeight = 0; // unbounded; element fills parent
|
|
112
|
-
|
|
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: {
|
|
113
231
|
display: 'flex',
|
|
114
232
|
flexDirection: 'column',
|
|
115
233
|
...(mode === 'fullscreen' ? { flexGrow: 1, flexShrink: 1 } : {}),
|
|
116
|
-
}, children:
|
|
117
|
-
? {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
props.onSelectionChange?.(sel);
|
|
125
|
-
}, onFocus: () => props.onFocus?.(), onBlur: () => props.onBlur?.() }) }));
|
|
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] }));
|
|
126
242
|
};
|
|
127
243
|
});
|
|
@@ -10,5 +10,15 @@
|
|
|
10
10
|
* *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
|
|
11
11
|
* blank lines normalize; `raw` blocks round-trip byte-for-byte.
|
|
12
12
|
*/
|
|
13
|
-
import type { RichDoc } from '@sigx/lynx-richtext';
|
|
14
|
-
|
|
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;
|
|
@@ -11,14 +11,18 @@
|
|
|
11
11
|
* blank lines normalize; `raw` blocks round-trip byte-for-byte.
|
|
12
12
|
*/
|
|
13
13
|
import { computeRuns, markPriority } from './overlap.js';
|
|
14
|
-
export function docToMd(doc) {
|
|
14
|
+
export function docToMd(doc, options) {
|
|
15
15
|
const parts = [];
|
|
16
|
+
const serializers = options?.serializers;
|
|
17
|
+
// Plugin-owned spans, prefiltered once for the whole doc — the per-run
|
|
18
|
+
// lookup searches only these (typically zero or a handful).
|
|
19
|
+
const pluginSpans = serializers ? doc.spans.filter((s) => serializers.has(s.type)) : [];
|
|
16
20
|
for (const seg of segmentize(doc)) {
|
|
17
21
|
if (seg.type === 'raw') {
|
|
18
22
|
parts.push(doc.text.slice(seg.start, seg.end));
|
|
19
23
|
continue;
|
|
20
24
|
}
|
|
21
|
-
const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph');
|
|
25
|
+
const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph', serializers, pluginSpans);
|
|
22
26
|
if (seg.type === 'heading') {
|
|
23
27
|
const level = Math.min(6, Math.max(1, seg.level ?? 1));
|
|
24
28
|
parts.push(`${'#'.repeat(level)} ${inline}`);
|
|
@@ -84,7 +88,7 @@ function segmentize(doc) {
|
|
|
84
88
|
return segments;
|
|
85
89
|
}
|
|
86
90
|
/** Serialize one segment's text + spans into markdown inline syntax. */
|
|
87
|
-
function serializeInline(doc, start, end, escapeLineStart) {
|
|
91
|
+
function serializeInline(doc, start, end, escapeLineStart, serializers, pluginSpans = []) {
|
|
88
92
|
const runs = computeRuns(doc.spans, start, end);
|
|
89
93
|
if (runs.length === 0) {
|
|
90
94
|
return escapeText(doc.text.slice(start, end), escapeLineStart);
|
|
@@ -107,7 +111,16 @@ function serializeInline(doc, start, end, escapeLineStart) {
|
|
|
107
111
|
let first = true;
|
|
108
112
|
for (let i = 0; i < runs.length; i++) {
|
|
109
113
|
const run = runs[i];
|
|
110
|
-
|
|
114
|
+
// A plugin-owned mark serializes atomically: its serializer output
|
|
115
|
+
// replaces the covered text, and the mark never enters the
|
|
116
|
+
// close/reopen stack (other marks may still wrap it). Atomic emission
|
|
117
|
+
// only applies when exactly ONE plugin mark covers the run —
|
|
118
|
+
// overlapping plugin spans are ambiguous, so the run degrades to its
|
|
119
|
+
// plain text (content preserved, no plugin syntax emitted).
|
|
120
|
+
const pluginMarks = serializers ? run.active.filter((m) => serializers.has(m.type)) : [];
|
|
121
|
+
const pluginMark = pluginMarks.length === 1 ? pluginMarks[0] : undefined;
|
|
122
|
+
const active = pluginMarks.length ? run.active.filter((m) => !serializers.has(m.type)) : run.active;
|
|
123
|
+
const desired = [...active].sort((a, b) => {
|
|
111
124
|
const ea = contEnd[i].get(a.key) ?? run.end;
|
|
112
125
|
const eb = contEnd[i].get(b.key) ?? run.end;
|
|
113
126
|
return eb - ea || markPriority(a) - markPriority(b);
|
|
@@ -123,6 +136,16 @@ function serializeInline(doc, start, end, escapeLineStart) {
|
|
|
123
136
|
out += delimiter(desired[j], doc, 'open');
|
|
124
137
|
open.push(desired[j]);
|
|
125
138
|
}
|
|
139
|
+
if (pluginMark) {
|
|
140
|
+
// Emit the serialized form once — on the span's first run within
|
|
141
|
+
// this segment — and suppress the covered text everywhere.
|
|
142
|
+
const span = pluginSpans.find((s) => s.type === pluginMark.type && s.start <= run.start && s.end >= run.end);
|
|
143
|
+
if (span && run.start === Math.max(span.start, start)) {
|
|
144
|
+
out += serializers.get(pluginMark.type)(span, doc.text.slice(span.start, span.end));
|
|
145
|
+
}
|
|
146
|
+
first = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
126
149
|
const slice = doc.text.slice(run.start, run.end);
|
|
127
150
|
out += open.some((m) => m.type === 'code')
|
|
128
151
|
? slice
|
|
@@ -15,5 +15,18 @@
|
|
|
15
15
|
* serializes doc lines back as blank-line-separated paragraphs (hard breaks
|
|
16
16
|
* normalize to paragraph breaks — documented).
|
|
17
17
|
*/
|
|
18
|
-
import type { RichDoc } from '@sigx/lynx-richtext';
|
|
19
|
-
|
|
18
|
+
import type { InlineSpan, RichDoc } from '@sigx/lynx-richtext';
|
|
19
|
+
import type { ParserInlineExtension } from '../../parser/extensions.js';
|
|
20
|
+
import type { InlineExtension } from '../../ast.js';
|
|
21
|
+
/** AST extension node → editor span (a plugin's `docMapping.toSpan`). Must be pure. */
|
|
22
|
+
export type ExtensionSpanMapper = (node: InlineExtension) => {
|
|
23
|
+
text: string;
|
|
24
|
+
span: Omit<InlineSpan, 'start' | 'end'>;
|
|
25
|
+
} | null;
|
|
26
|
+
export interface MdToDocOptions {
|
|
27
|
+
/** Inline extensions to parse with (plugin `inline.syntax`). */
|
|
28
|
+
extensions?: readonly ParserInlineExtension[];
|
|
29
|
+
/** Span mappers keyed by extension name (plugin `docMapping.toSpan`). */
|
|
30
|
+
spanMappers?: Record<string, ExtensionSpanMapper>;
|
|
31
|
+
}
|
|
32
|
+
export declare function mdToDoc(markdown: string, v?: number, options?: MdToDocOptions): RichDoc;
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* normalize to paragraph breaks — documented).
|
|
17
17
|
*/
|
|
18
18
|
import { parseBlocks } from '../../parser/blocks.js';
|
|
19
|
-
export function mdToDoc(markdown, v = 0) {
|
|
20
|
-
const ast = parseBlocks(markdown ?? '');
|
|
19
|
+
export function mdToDoc(markdown, v = 0, options) {
|
|
20
|
+
const ast = parseBlocks(markdown ?? '', undefined, options?.extensions);
|
|
21
|
+
const mappers = options?.spanMappers;
|
|
21
22
|
let text = '';
|
|
22
23
|
const spans = [];
|
|
23
24
|
const blocks = [];
|
|
@@ -40,23 +41,23 @@ export function mdToDoc(markdown, v = 0) {
|
|
|
40
41
|
for (const block of ast) {
|
|
41
42
|
switch (block.type) {
|
|
42
43
|
case 'paragraph': {
|
|
43
|
-
if (!inlineRepresentable(block.children)) {
|
|
44
|
+
if (!inlineRepresentable(block.children, mappers)) {
|
|
44
45
|
push(block.raw, { type: 'raw' });
|
|
45
46
|
break;
|
|
46
47
|
}
|
|
47
48
|
for (const line of splitOnBreaks(block.children)) {
|
|
48
|
-
const flat = flattenInline(line, text.length);
|
|
49
|
+
const flat = flattenInline(line, text.length, mappers);
|
|
49
50
|
spans.push(...flat.spans);
|
|
50
51
|
push(flat.text);
|
|
51
52
|
}
|
|
52
53
|
break;
|
|
53
54
|
}
|
|
54
55
|
case 'heading': {
|
|
55
|
-
if (!inlineRepresentable(block.children)) {
|
|
56
|
+
if (!inlineRepresentable(block.children, mappers)) {
|
|
56
57
|
push(block.raw, { type: 'raw' });
|
|
57
58
|
break;
|
|
58
59
|
}
|
|
59
|
-
const flat = flattenInline(block.children, text.length);
|
|
60
|
+
const flat = flattenInline(block.children, text.length, mappers);
|
|
60
61
|
spans.push(...flat.spans);
|
|
61
62
|
push(flat.text, { type: 'heading', level: block.level });
|
|
62
63
|
break;
|
|
@@ -76,7 +77,7 @@ export function mdToDoc(markdown, v = 0) {
|
|
|
76
77
|
return { text, spans, blocks, v };
|
|
77
78
|
}
|
|
78
79
|
/** Inline node types the editor can model in-field. */
|
|
79
|
-
function inlineRepresentable(nodes) {
|
|
80
|
+
function inlineRepresentable(nodes, mappers) {
|
|
80
81
|
for (const node of nodes) {
|
|
81
82
|
switch (node.type) {
|
|
82
83
|
case 'text':
|
|
@@ -87,19 +88,36 @@ function inlineRepresentable(nodes) {
|
|
|
87
88
|
case 'strong':
|
|
88
89
|
case 'em':
|
|
89
90
|
case 'del':
|
|
90
|
-
if (!inlineRepresentable(node.children))
|
|
91
|
+
if (!inlineRepresentable(node.children, mappers))
|
|
91
92
|
return false;
|
|
92
93
|
break;
|
|
93
94
|
case 'link':
|
|
94
|
-
if (!inlineRepresentable(node.children))
|
|
95
|
+
if (!inlineRepresentable(node.children, mappers))
|
|
96
|
+
return false;
|
|
97
|
+
break;
|
|
98
|
+
case 'extension':
|
|
99
|
+
// Representable only when a plugin maps it to an editor span
|
|
100
|
+
// (toSpan is pure, so probing here and mapping later agree).
|
|
101
|
+
// A throwing mapper means "not representable" — the block
|
|
102
|
+
// degrades to raw instead of crashing the conversion.
|
|
103
|
+
if (!tryMap(mappers, node))
|
|
95
104
|
return false;
|
|
96
105
|
break;
|
|
97
106
|
default:
|
|
98
|
-
return false; // image,
|
|
107
|
+
return false; // image, unmapped extension nodes
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
return true;
|
|
102
111
|
}
|
|
112
|
+
/** Run a plugin mapper defensively: a throwing mapper counts as no mapping. */
|
|
113
|
+
function tryMap(mappers, node) {
|
|
114
|
+
try {
|
|
115
|
+
return mappers?.[node.name]?.(node) ?? null;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
103
121
|
/** Split a paragraph's inline children into visual lines at top-level hard breaks. */
|
|
104
122
|
function splitOnBreaks(nodes) {
|
|
105
123
|
const lines = [];
|
|
@@ -117,7 +135,7 @@ function splitOnBreaks(nodes) {
|
|
|
117
135
|
return lines;
|
|
118
136
|
}
|
|
119
137
|
/** Depth-first flatten of an inline tree into text + overlapping spans. */
|
|
120
|
-
function flattenInline(nodes, base) {
|
|
138
|
+
function flattenInline(nodes, base, mappers) {
|
|
121
139
|
let text = '';
|
|
122
140
|
const spans = [];
|
|
123
141
|
const walk = (list) => {
|
|
@@ -176,6 +194,22 @@ function flattenInline(nodes, base) {
|
|
|
176
194
|
});
|
|
177
195
|
break;
|
|
178
196
|
}
|
|
197
|
+
case 'extension': {
|
|
198
|
+
const mapped = tryMap(mappers, node);
|
|
199
|
+
if (!mapped) {
|
|
200
|
+
// Defense in depth: if the mapper disagrees with the
|
|
201
|
+
// earlier representability probe (impure/buggy), keep
|
|
202
|
+
// the source text rather than silently dropping it.
|
|
203
|
+
text += node.raw;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
const start = base + text.length;
|
|
207
|
+
text += mapped.text;
|
|
208
|
+
// Plugin fields first — the converter always controls the
|
|
209
|
+
// final range, even if a mapper sneaks in start/end.
|
|
210
|
+
spans.push({ ...mapped.span, start, end: base + text.length });
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
179
213
|
default:
|
|
180
214
|
break; // unreachable — filtered by inlineRepresentable
|
|
181
215
|
}
|