@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.
- package/README.md +47 -17
- package/dist/ast.d.ts +18 -1
- package/dist/editor/MarkdownEditor.d.ts +73 -0
- package/dist/editor/MarkdownEditor.js +243 -0
- package/dist/editor/convert/docToMd.d.ts +24 -0
- package/dist/editor/convert/docToMd.js +224 -0
- package/dist/editor/convert/mdToDoc.d.ts +32 -0
- package/dist/editor/convert/mdToDoc.js +221 -0
- package/dist/editor/convert/overlap.d.ts +31 -0
- package/dist/editor/convert/overlap.js +70 -0
- 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 +19 -6
- package/dist/index.js +9 -3
- 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 +10 -4
- package/dist/render/MarkdownView.js +13 -5
- package/dist/render/components.d.ts +13 -1
- package/dist/render/engine.js +11 -0
- package/package.json +18 -7
- package/dist/XMarkdown.d.ts +0 -36
- package/dist/XMarkdown.js +0 -36
- package/dist/jsx-augment.d.ts +0 -83
- 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
|
-
|
|
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
|
-
|
|
135
|
-
`
|
|
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 {
|
|
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
|
-
|
|
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;
|