@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `MarkdownEditor` plugin contract — the P3 pluggability layer. Another
|
|
3
|
+
* project adds e.g. mention support without touching this package:
|
|
4
|
+
*
|
|
5
|
+
* - `inline` teaches the **parser** the syntax (a {@link ParserInlineExtension}),
|
|
6
|
+
* the **editor field** how to model it (`docMapping`: AST node → editor span)
|
|
7
|
+
* and how to write it back out (`serialize`: span → markdown), and optionally
|
|
8
|
+
* the **preview** how to render it (`component`).
|
|
9
|
+
* - `trigger` opens a suggestion session on a trigger char (`@`, `:`), feeds it
|
|
10
|
+
* query results, and inserts the selection.
|
|
11
|
+
* - `toolbar` contributes {@link ToolbarItem}s to the built-in toolbar.
|
|
12
|
+
*
|
|
13
|
+
* Every part is optional and independent — a plugin can be toolbar-only, or
|
|
14
|
+
* trigger-only (e.g. a slash-command menu that inserts plain markdown).
|
|
15
|
+
*/
|
|
16
|
+
import type { JSXElement } from '@sigx/lynx';
|
|
17
|
+
import type { InlineSpan } from '@sigx/lynx-richtext';
|
|
18
|
+
import type { InlineExtension } from '../ast.js';
|
|
19
|
+
import type { ParserInlineExtension } from '../parser/extensions.js';
|
|
20
|
+
import type { ExtensionProps, MarkdownChild } from '../render/components.js';
|
|
21
|
+
import type { ToolbarItem } from './toolbar/items.js';
|
|
22
|
+
import type { MarkdownEditorController } from './MarkdownEditor.js';
|
|
23
|
+
export interface MarkdownEditorPlugin {
|
|
24
|
+
/** Stable plugin name (diagnostics; trigger sessions report it). */
|
|
25
|
+
name: string;
|
|
26
|
+
inline?: InlinePluginSpec;
|
|
27
|
+
trigger?: TriggerSpec;
|
|
28
|
+
/** Extra toolbar items, appended after the editor's base item set. */
|
|
29
|
+
toolbar?: ToolbarItem[];
|
|
30
|
+
}
|
|
31
|
+
export interface InlinePluginSpec {
|
|
32
|
+
/** The parser extension that recognizes this construct in markdown source. */
|
|
33
|
+
syntax: ParserInlineExtension;
|
|
34
|
+
/**
|
|
35
|
+
* Preview renderer (the `components.extension[syntax.name]` slot). Optional —
|
|
36
|
+
* without it, `MarkdownView` falls back to the node's `raw` source as text.
|
|
37
|
+
*/
|
|
38
|
+
component?: (props: ExtensionProps) => MarkdownChild;
|
|
39
|
+
/**
|
|
40
|
+
* doc → markdown: serialize one plugin-owned span back to markdown source.
|
|
41
|
+
* Receives the span and the text it covers in the editor field. Emitted
|
|
42
|
+
* atomically in place of the covered text (e.g. a mention span covering
|
|
43
|
+
* `Andy` serializes to `@[Andy](u1)`).
|
|
44
|
+
*/
|
|
45
|
+
serialize(span: InlineSpan, text: string): string;
|
|
46
|
+
/** The AST ↔ editor-span bridge for the editable field. */
|
|
47
|
+
docMapping: {
|
|
48
|
+
/**
|
|
49
|
+
* The span type this plugin owns in the editor document. Must be one of
|
|
50
|
+
* the codec-allowed {@link InlineSpan} types (`mention` is reserved for
|
|
51
|
+
* exactly this) — unknown types are dropped by the native codec.
|
|
52
|
+
*/
|
|
53
|
+
spanType: InlineSpan['type'];
|
|
54
|
+
/**
|
|
55
|
+
* markdown → doc: map a parsed extension node to the text the field
|
|
56
|
+
* should display plus the span carrying its data. Return `null` to keep
|
|
57
|
+
* the surrounding block as a raw (source-edited) block instead.
|
|
58
|
+
*/
|
|
59
|
+
toSpan(node: InlineExtension): {
|
|
60
|
+
text: string;
|
|
61
|
+
span: Omit<InlineSpan, 'start' | 'end'>;
|
|
62
|
+
} | null;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** One entry in a trigger session's result list. */
|
|
66
|
+
export interface TriggerItem {
|
|
67
|
+
id: string;
|
|
68
|
+
label: string;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
interface TriggerSpecBase {
|
|
72
|
+
/** Debounce between `onQuery` calls in ms. `0`/omitted = immediate. */
|
|
73
|
+
debounce?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Resolve suggestions for the current query (the text typed after the
|
|
76
|
+
* trigger). May be async — stale results (a newer query has since been
|
|
77
|
+
* issued, or the session closed) are discarded.
|
|
78
|
+
*/
|
|
79
|
+
onQuery(query: string): TriggerItem[] | Promise<TriggerItem[]>;
|
|
80
|
+
/** Re-skin a suggestion row (the popup's neutral default renders `label`). */
|
|
81
|
+
renderItem?(item: TriggerItem, active: boolean): JSXElement;
|
|
82
|
+
/** A suggestion was picked. Typically calls `api.replaceQuery(...)`. */
|
|
83
|
+
onSelect(item: TriggerItem, api: TriggerSelectApi): void;
|
|
84
|
+
}
|
|
85
|
+
/** Exactly one of `char`/`pattern` — enforced by the union. */
|
|
86
|
+
export type TriggerSpec = (TriggerSpecBase & {
|
|
87
|
+
/** Single trigger character (`'@'`). */
|
|
88
|
+
char: string;
|
|
89
|
+
pattern?: undefined;
|
|
90
|
+
}) | (TriggerSpecBase & {
|
|
91
|
+
char?: undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Multi-char trigger: matched against the run of non-whitespace text
|
|
94
|
+
* between the last boundary and the caret; must match at its start
|
|
95
|
+
* (e.g. `/^::/`). The match length is the trigger length; what
|
|
96
|
+
* follows is the query.
|
|
97
|
+
*/
|
|
98
|
+
pattern: RegExp;
|
|
99
|
+
});
|
|
100
|
+
/** What `onSelect` receives to act on the editor. */
|
|
101
|
+
export interface TriggerSelectApi {
|
|
102
|
+
/**
|
|
103
|
+
* Replace the whole trigger run (trigger char/prefix + query) with `text`,
|
|
104
|
+
* leaving the caret after it.
|
|
105
|
+
*
|
|
106
|
+
* Sessions are re-derived from `(doc.text, caret)`, so end the replacement
|
|
107
|
+
* with a boundary (usually a trailing space) — otherwise text that still
|
|
108
|
+
* forms a trigger run at the caret immediately re-opens the session.
|
|
109
|
+
*/
|
|
110
|
+
replaceQuery(text: string): void;
|
|
111
|
+
/** The `[start, end)` range of the trigger run in the document text. */
|
|
112
|
+
range: {
|
|
113
|
+
start: number;
|
|
114
|
+
end: number;
|
|
115
|
+
};
|
|
116
|
+
controller: MarkdownEditorController;
|
|
117
|
+
}
|
|
118
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `MarkdownEditor` plugin contract — the P3 pluggability layer. Another
|
|
3
|
+
* project adds e.g. mention support without touching this package:
|
|
4
|
+
*
|
|
5
|
+
* - `inline` teaches the **parser** the syntax (a {@link ParserInlineExtension}),
|
|
6
|
+
* the **editor field** how to model it (`docMapping`: AST node → editor span)
|
|
7
|
+
* and how to write it back out (`serialize`: span → markdown), and optionally
|
|
8
|
+
* the **preview** how to render it (`component`).
|
|
9
|
+
* - `trigger` opens a suggestion session on a trigger char (`@`, `:`), feeds it
|
|
10
|
+
* query results, and inserts the selection.
|
|
11
|
+
* - `toolbar` contributes {@link ToolbarItem}s to the built-in toolbar.
|
|
12
|
+
*
|
|
13
|
+
* Every part is optional and independent — a plugin can be toolbar-only, or
|
|
14
|
+
* trigger-only (e.g. a slash-command menu that inserts plain markdown).
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<EditorToolbar>` — the neutral formatting toolbar for `MarkdownEditor`,
|
|
3
|
+
* mirroring the renderer's override pattern: the *items* are data
|
|
4
|
+
* ({@link ToolbarItem}), the *rendering* is replaceable per item via
|
|
5
|
+
* `renderItem` (that's all a design-system skin is — see
|
|
6
|
+
* `@sigx/lynx-daisyui`'s `EditorToolbar`).
|
|
7
|
+
*
|
|
8
|
+
* Ships with `ignore-focus` on the root: toolbar taps must never blur the
|
|
9
|
+
* editor — on iOS, Lynx dispatches `endEditing:` on any touch-down whose
|
|
10
|
+
* target doesn't ignore focus, folding the keyboard before the tapped
|
|
11
|
+
* command could run.
|
|
12
|
+
*
|
|
13
|
+
* Usable two ways:
|
|
14
|
+
* - **Built in**: `<MarkdownEditor toolbar />` (or `toolbar="top"`), which
|
|
15
|
+
* wires `controller`/`selection` internally.
|
|
16
|
+
* - **Standalone**: place it anywhere (e.g. a `KeyboardStickyView` send bar)
|
|
17
|
+
* and pass `controller` + `selection` yourself.
|
|
18
|
+
*/
|
|
19
|
+
import { type Define, type JSXElement } from '@sigx/lynx';
|
|
20
|
+
import type { SelectionState } from '@sigx/lynx-richtext';
|
|
21
|
+
import type { MarkdownEditorController } from '../MarkdownEditor.js';
|
|
22
|
+
import type { ToolbarItem } from './items.js';
|
|
23
|
+
export type ToolbarRenderItem = (item: ToolbarItem, active: boolean, run: () => void) => JSXElement;
|
|
24
|
+
export type EditorToolbarProps = Define.Prop<'controller', MarkdownEditorController | null, false> & Define.Prop<'selection', SelectionState | null, false> & Define.Prop<'items', ToolbarItem[], false> & Define.Prop<'renderItem', ToolbarRenderItem, false> & Define.Prop<'class', string, false>;
|
|
25
|
+
export declare const EditorToolbar: import("@sigx/runtime-core").ComponentFactory<EditorToolbarProps, void, {}>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<EditorToolbar>` — the neutral formatting toolbar for `MarkdownEditor`,
|
|
4
|
+
* mirroring the renderer's override pattern: the *items* are data
|
|
5
|
+
* ({@link ToolbarItem}), the *rendering* is replaceable per item via
|
|
6
|
+
* `renderItem` (that's all a design-system skin is — see
|
|
7
|
+
* `@sigx/lynx-daisyui`'s `EditorToolbar`).
|
|
8
|
+
*
|
|
9
|
+
* Ships with `ignore-focus` on the root: toolbar taps must never blur the
|
|
10
|
+
* editor — on iOS, Lynx dispatches `endEditing:` on any touch-down whose
|
|
11
|
+
* target doesn't ignore focus, folding the keyboard before the tapped
|
|
12
|
+
* command could run.
|
|
13
|
+
*
|
|
14
|
+
* Usable two ways:
|
|
15
|
+
* - **Built in**: `<MarkdownEditor toolbar />` (or `toolbar="top"`), which
|
|
16
|
+
* wires `controller`/`selection` internally.
|
|
17
|
+
* - **Standalone**: place it anywhere (e.g. a `KeyboardStickyView` send bar)
|
|
18
|
+
* and pass `controller` + `selection` yourself.
|
|
19
|
+
*/
|
|
20
|
+
import { component } from '@sigx/lynx';
|
|
21
|
+
import { defaultToolbarItems } from './items.js';
|
|
22
|
+
/** Neutral, theme-agnostic item chrome (mid-gray works on light + dark). */
|
|
23
|
+
const ITEM_STYLE = {
|
|
24
|
+
paddingLeft: '10px',
|
|
25
|
+
paddingRight: '10px',
|
|
26
|
+
paddingTop: '6px',
|
|
27
|
+
paddingBottom: '6px',
|
|
28
|
+
borderRadius: '6px',
|
|
29
|
+
};
|
|
30
|
+
const ACTIVE_BG = 'rgba(128,128,128,0.25)';
|
|
31
|
+
export const EditorToolbar = component(({ props }) => {
|
|
32
|
+
const run = (item) => {
|
|
33
|
+
const controller = props.controller;
|
|
34
|
+
if (controller)
|
|
35
|
+
item.run({ controller });
|
|
36
|
+
};
|
|
37
|
+
const defaultRenderItem = (item, active, runItem) => (_jsx("view", { style: { ...ITEM_STYLE, ...(active ? { backgroundColor: ACTIVE_BG } : {}) }, bindtap: runItem, "accessibility-element": true, "accessibility-label": item.label ?? item.id, "accessibility-trait": "button", "accessibility-status": active ? 'selected' : undefined, children: _jsx("text", { style: active ? { fontWeight: 'bold' } : undefined, children: item.label ?? item.id }) }, item.id));
|
|
38
|
+
return () => {
|
|
39
|
+
const items = props.items ?? defaultToolbarItems;
|
|
40
|
+
const sel = props.selection ?? null;
|
|
41
|
+
const renderItem = props.renderItem ?? defaultRenderItem;
|
|
42
|
+
return (_jsx("view", { "ignore-focus": true, class: props.class, style: {
|
|
43
|
+
display: 'flex',
|
|
44
|
+
flexDirection: 'row',
|
|
45
|
+
flexWrap: 'wrap',
|
|
46
|
+
alignItems: 'center',
|
|
47
|
+
columnGap: '4px',
|
|
48
|
+
rowGap: '4px',
|
|
49
|
+
}, children: items.map((item) => renderItem(item, item.isActive?.(sel) ?? false, () => run(item))) }));
|
|
50
|
+
};
|
|
51
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toolbar item contract — the editor analogue of the renderer's
|
|
3
|
+
* `MarkdownComponents` map. A toolbar is just an array of these; design
|
|
4
|
+
* systems re-skin the *rendering* (see `@sigx/lynx-daisyui`'s
|
|
5
|
+
* `EditorToolbar`) while the items stay shared, and P3 plugins contribute
|
|
6
|
+
* additional items through the same shape.
|
|
7
|
+
*/
|
|
8
|
+
import type { SelectionState } from '@sigx/lynx-richtext';
|
|
9
|
+
import type { MarkdownEditorController } from '../MarkdownEditor.js';
|
|
10
|
+
/** What an item's `run` receives — the editor's imperative surface. */
|
|
11
|
+
export interface ToolbarContext {
|
|
12
|
+
controller: MarkdownEditorController;
|
|
13
|
+
}
|
|
14
|
+
export interface ToolbarItem {
|
|
15
|
+
/** Stable identifier (also the default `key`). */
|
|
16
|
+
id: string;
|
|
17
|
+
/**
|
|
18
|
+
* Short text rendering (`B`, `H1`, …). The neutral toolbar renders this
|
|
19
|
+
* (falling back to `id` when omitted); skins may render `icon` instead.
|
|
20
|
+
* Optional so icon-only items/skins don't need a dummy label.
|
|
21
|
+
*/
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Optional icon hint for skins (e.g. an icon-set name). Never required. */
|
|
24
|
+
icon?: string;
|
|
25
|
+
/** Items with the same group render adjacent (skins may add separators). */
|
|
26
|
+
group?: string;
|
|
27
|
+
/** Highlighted state, derived from the last selection event. */
|
|
28
|
+
isActive?(sel: SelectionState | null): boolean;
|
|
29
|
+
run(ctx: ToolbarContext): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The neutral default item set: every command the v1 controller exposes.
|
|
33
|
+
* (Lists / quote / link items arrive with the block-WYSIWYG work — #153.)
|
|
34
|
+
*/
|
|
35
|
+
export declare const defaultToolbarItems: ToolbarItem[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toolbar item contract — the editor analogue of the renderer's
|
|
3
|
+
* `MarkdownComponents` map. A toolbar is just an array of these; design
|
|
4
|
+
* systems re-skin the *rendering* (see `@sigx/lynx-daisyui`'s
|
|
5
|
+
* `EditorToolbar`) while the items stay shared, and P3 plugins contribute
|
|
6
|
+
* additional items through the same shape.
|
|
7
|
+
*/
|
|
8
|
+
const formatActive = (format) => (sel) => !!sel && sel.activeFormats.includes(format);
|
|
9
|
+
const headingActive = (level) => (sel) => sel?.activeBlock === 'heading' && sel.headingLevel === level;
|
|
10
|
+
/**
|
|
11
|
+
* The neutral default item set: every command the v1 controller exposes.
|
|
12
|
+
* (Lists / quote / link items arrive with the block-WYSIWYG work — #153.)
|
|
13
|
+
*/
|
|
14
|
+
export const defaultToolbarItems = [
|
|
15
|
+
{ id: 'bold', label: 'B', icon: 'bold', group: 'inline', isActive: formatActive('bold'), run: ({ controller }) => controller.toggleBold() },
|
|
16
|
+
{ id: 'italic', label: 'I', icon: 'italic', group: 'inline', isActive: formatActive('italic'), run: ({ controller }) => controller.toggleItalic() },
|
|
17
|
+
{ id: 'strike', label: 'S', icon: 'strikethrough', group: 'inline', isActive: formatActive('strike'), run: ({ controller }) => controller.toggleStrike() },
|
|
18
|
+
{ id: 'code', label: '</>', icon: 'code', group: 'inline', isActive: formatActive('code'), run: ({ controller }) => controller.toggleCode() },
|
|
19
|
+
{ id: 'h1', label: 'H1', icon: 'heading-1', group: 'block', isActive: headingActive(1), run: ({ controller }) => controller.setHeading(1) },
|
|
20
|
+
{ id: 'h2', label: 'H2', icon: 'heading-2', group: 'block', isActive: headingActive(2), run: ({ controller }) => controller.setHeading(2) },
|
|
21
|
+
{
|
|
22
|
+
id: 'paragraph',
|
|
23
|
+
label: '¶',
|
|
24
|
+
icon: 'pilcrow',
|
|
25
|
+
group: 'block',
|
|
26
|
+
isActive: (sel) => (sel ? sel.activeBlock === 'paragraph' : false),
|
|
27
|
+
run: ({ controller }) => controller.setHeading(0),
|
|
28
|
+
},
|
|
29
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<SuggestionPopup>` — the neutral suggestion list for trigger sessions,
|
|
3
|
+
* mirroring the toolbar's override pattern: items are data
|
|
4
|
+
* ({@link TriggerItem}), row rendering is replaceable via `renderItem`
|
|
5
|
+
* (a plugin's `trigger.renderItem`).
|
|
6
|
+
*
|
|
7
|
+
* Anchored by the caret rect from `bindselection`, placed above the caret by
|
|
8
|
+
* default and flipped below when there's no room, and clamped so it never
|
|
9
|
+
* extends under the keyboard — see `position.ts` for the math. The keyboard
|
|
10
|
+
* inset comes from `@sigx/lynx-keyboard`'s `useKeyboard()`, which requires a
|
|
11
|
+
* `<SafeAreaProvider>` ancestor; without one it reads 0 and the keyboard
|
|
12
|
+
* clamp is effectively disabled. The page-absolute frame of the relative
|
|
13
|
+
* container it's positioned in arrives via `containerFrame` (the editor
|
|
14
|
+
* measures it with `bindlayoutchange`).
|
|
15
|
+
*
|
|
16
|
+
* Ships with `ignore-focus` on the root: tapping a suggestion must never
|
|
17
|
+
* blur the editor (same iOS `endEditing:` rule the toolbar handles).
|
|
18
|
+
*/
|
|
19
|
+
import { type Define, type ElementLayout, type JSXElement } from '@sigx/lynx';
|
|
20
|
+
import type { TriggerItem } from '../plugin.js';
|
|
21
|
+
import { type CaretRect } from './position.js';
|
|
22
|
+
export type SuggestionRenderItem = (item: TriggerItem, active: boolean) => JSXElement;
|
|
23
|
+
export type SuggestionPopupProps = Define.Prop<'items', TriggerItem[], false> & Define.Prop<'caretRect', CaretRect | null, false>
|
|
24
|
+
/** Page-absolute frame of the relative container (from `bindlayoutchange`). */
|
|
25
|
+
& Define.Prop<'containerFrame', ElementLayout | null, false> & Define.Prop<'renderItem', SuggestionRenderItem, false> & Define.Prop<'onSelect', (item: TriggerItem) => void, false>
|
|
26
|
+
/** Highlighted row index (e.g. hardware-keyboard navigation); `-1`/omitted = none. */
|
|
27
|
+
& Define.Prop<'activeIndex', number, false> & Define.Prop<'maxHeight', number, false> & Define.Prop<'width', number, false> & Define.Prop<'class', string, false>;
|
|
28
|
+
export declare const SuggestionPopup: import("@sigx/runtime-core").ComponentFactory<SuggestionPopupProps, void, {}>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<SuggestionPopup>` — the neutral suggestion list for trigger sessions,
|
|
4
|
+
* mirroring the toolbar's override pattern: items are data
|
|
5
|
+
* ({@link TriggerItem}), row rendering is replaceable via `renderItem`
|
|
6
|
+
* (a plugin's `trigger.renderItem`).
|
|
7
|
+
*
|
|
8
|
+
* Anchored by the caret rect from `bindselection`, placed above the caret by
|
|
9
|
+
* default and flipped below when there's no room, and clamped so it never
|
|
10
|
+
* extends under the keyboard — see `position.ts` for the math. The keyboard
|
|
11
|
+
* inset comes from `@sigx/lynx-keyboard`'s `useKeyboard()`, which requires a
|
|
12
|
+
* `<SafeAreaProvider>` ancestor; without one it reads 0 and the keyboard
|
|
13
|
+
* clamp is effectively disabled. The page-absolute frame of the relative
|
|
14
|
+
* container it's positioned in arrives via `containerFrame` (the editor
|
|
15
|
+
* measures it with `bindlayoutchange`).
|
|
16
|
+
*
|
|
17
|
+
* Ships with `ignore-focus` on the root: tapping a suggestion must never
|
|
18
|
+
* blur the editor (same iOS `endEditing:` rule the toolbar handles).
|
|
19
|
+
*/
|
|
20
|
+
import { component } from '@sigx/lynx';
|
|
21
|
+
import { useKeyboard } from '@sigx/lynx-keyboard';
|
|
22
|
+
import { placeSuggestionPopup, screenHeightDp } from './position.js';
|
|
23
|
+
const DEFAULT_WIDTH = 240;
|
|
24
|
+
const DEFAULT_MAX_HEIGHT = 220;
|
|
25
|
+
const BORDER = 'rgba(127, 127, 127, 0.32)';
|
|
26
|
+
/** Opaque neutral surface — the popup floats over editor text. */
|
|
27
|
+
const SURFACE = '#f4f4f5';
|
|
28
|
+
const ACTIVE_BG = 'rgba(128,128,128,0.25)';
|
|
29
|
+
export const SuggestionPopup = component(({ props }) => {
|
|
30
|
+
const keyboard = useKeyboard();
|
|
31
|
+
const defaultRenderItem = (item, active) => (_jsx("view", { style: {
|
|
32
|
+
paddingLeft: '12px',
|
|
33
|
+
paddingRight: '12px',
|
|
34
|
+
paddingTop: '8px',
|
|
35
|
+
paddingBottom: '8px',
|
|
36
|
+
...(active ? { backgroundColor: ACTIVE_BG } : {}),
|
|
37
|
+
}, children: _jsx("text", { style: { fontSize: 15 }, children: item.label }) }));
|
|
38
|
+
return () => {
|
|
39
|
+
const items = props.items ?? [];
|
|
40
|
+
const caretRect = props.caretRect ?? null;
|
|
41
|
+
const frame = props.containerFrame ?? null;
|
|
42
|
+
// Both anchors are required for meaningful placement — render nothing
|
|
43
|
+
// until they exist rather than flashing at a bogus top-left position.
|
|
44
|
+
if (!caretRect || !frame)
|
|
45
|
+
return null;
|
|
46
|
+
// Clamp to the container so the popup can never overflow to the right
|
|
47
|
+
// in narrow layouts (placement clamps left to 0).
|
|
48
|
+
const width = Math.min(props.width ?? DEFAULT_WIDTH, frame.width);
|
|
49
|
+
const renderItem = props.renderItem ?? defaultRenderItem;
|
|
50
|
+
const pos = placeSuggestionPopup({
|
|
51
|
+
caretRect,
|
|
52
|
+
containerTop: frame.top,
|
|
53
|
+
containerWidth: frame.width,
|
|
54
|
+
containerHeight: frame.height,
|
|
55
|
+
screenHeight: screenHeightDp(),
|
|
56
|
+
keyboardHeight: keyboard.value.height,
|
|
57
|
+
popupWidth: width,
|
|
58
|
+
maxPopupHeight: props.maxHeight ?? DEFAULT_MAX_HEIGHT,
|
|
59
|
+
});
|
|
60
|
+
return (_jsx("view", { "ignore-focus": true, class: props.class, style: {
|
|
61
|
+
position: 'absolute',
|
|
62
|
+
left: pos.left,
|
|
63
|
+
...(pos.top !== undefined ? { top: pos.top } : {}),
|
|
64
|
+
...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}),
|
|
65
|
+
width,
|
|
66
|
+
zIndex: 20,
|
|
67
|
+
borderRadius: '10px',
|
|
68
|
+
borderWidth: '1px',
|
|
69
|
+
borderColor: BORDER,
|
|
70
|
+
backgroundColor: SURFACE,
|
|
71
|
+
overflow: 'hidden',
|
|
72
|
+
}, children: _jsx("scroll-view", { "scroll-orientation": "vertical", style: { maxHeight: pos.maxHeight }, children: items.map((item, index) => {
|
|
73
|
+
const active = index === (props.activeIndex ?? -1);
|
|
74
|
+
return (_jsx("view", { bindtap: () => props.onSelect?.(item), "accessibility-element": true, "accessibility-label": item.label, "accessibility-trait": "button", "accessibility-status": active ? 'selected' : undefined, children: renderItem(item, active) }, item.id));
|
|
75
|
+
}) }) }));
|
|
76
|
+
};
|
|
77
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure placement math for the suggestion popup.
|
|
3
|
+
*
|
|
4
|
+
* Inputs are deliberately primitive so this is unit-testable without a
|
|
5
|
+
* renderer: the element-local caret rect (from `bindselection`), the
|
|
6
|
+
* positioning container's page-absolute frame (from `bindlayoutchange`),
|
|
7
|
+
* the screen height and the keyboard height (`@sigx/lynx-keyboard`).
|
|
8
|
+
*
|
|
9
|
+
* Placement: **above the caret by default** — the editor usually sits in a
|
|
10
|
+
* bottom-docked composer, so above is where the room is — flipping below
|
|
11
|
+
* when there isn't enough space above. Either way the popup is clamped so it
|
|
12
|
+
* never extends under the keyboard, and the list scrolls internally when the
|
|
13
|
+
* clamp bites.
|
|
14
|
+
*/
|
|
15
|
+
export interface CaretRect {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
/** Absolute-position style for the popup within its relative container. */
|
|
21
|
+
export interface PopupPlacement {
|
|
22
|
+
placement: 'above' | 'below';
|
|
23
|
+
left: number;
|
|
24
|
+
/** Set for `below`: container-local top edge. */
|
|
25
|
+
top?: number;
|
|
26
|
+
/** Set for `above`: container-local bottom anchor (no height knowledge needed). */
|
|
27
|
+
bottom?: number;
|
|
28
|
+
/** Clamp for the scrollable list. */
|
|
29
|
+
maxHeight: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function placeSuggestionPopup(opts: {
|
|
32
|
+
caretRect: CaretRect;
|
|
33
|
+
/** Page-absolute top of the positioning container (0 until measured). */
|
|
34
|
+
containerTop: number;
|
|
35
|
+
containerWidth: number;
|
|
36
|
+
containerHeight: number;
|
|
37
|
+
screenHeight: number;
|
|
38
|
+
keyboardHeight: number;
|
|
39
|
+
popupWidth: number;
|
|
40
|
+
maxPopupHeight: number;
|
|
41
|
+
}): PopupPlacement;
|
|
42
|
+
/**
|
|
43
|
+
* Logical screen height in dp from `lynx.SystemInfo` (same pattern as
|
|
44
|
+
* `@sigx/lynx-navigation`'s screen-width module). Falls back to a typical
|
|
45
|
+
* phone height in tests / non-Lynx hosts.
|
|
46
|
+
*/
|
|
47
|
+
export declare function screenHeightDp(fallback?: number): number;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure placement math for the suggestion popup.
|
|
3
|
+
*
|
|
4
|
+
* Inputs are deliberately primitive so this is unit-testable without a
|
|
5
|
+
* renderer: the element-local caret rect (from `bindselection`), the
|
|
6
|
+
* positioning container's page-absolute frame (from `bindlayoutchange`),
|
|
7
|
+
* the screen height and the keyboard height (`@sigx/lynx-keyboard`).
|
|
8
|
+
*
|
|
9
|
+
* Placement: **above the caret by default** — the editor usually sits in a
|
|
10
|
+
* bottom-docked composer, so above is where the room is — flipping below
|
|
11
|
+
* when there isn't enough space above. Either way the popup is clamped so it
|
|
12
|
+
* never extends under the keyboard, and the list scrolls internally when the
|
|
13
|
+
* clamp bites.
|
|
14
|
+
*/
|
|
15
|
+
const GAP = 4;
|
|
16
|
+
/** Roughly one suggestion row — below this, flip rather than squeeze. */
|
|
17
|
+
const MIN_USEFUL_HEIGHT = 44;
|
|
18
|
+
export function placeSuggestionPopup(opts) {
|
|
19
|
+
const caretTopAbs = opts.containerTop + opts.caretRect.y;
|
|
20
|
+
const caretBottomAbs = caretTopAbs + opts.caretRect.height;
|
|
21
|
+
const keyboardTop = opts.screenHeight - opts.keyboardHeight;
|
|
22
|
+
const spaceAbove = caretTopAbs - GAP;
|
|
23
|
+
const spaceBelow = keyboardTop - caretBottomAbs - GAP;
|
|
24
|
+
const fitsAbove = spaceAbove >= Math.min(opts.maxPopupHeight, MIN_USEFUL_HEIGHT);
|
|
25
|
+
const placement = fitsAbove || spaceAbove >= spaceBelow ? 'above' : 'below';
|
|
26
|
+
const left = Math.max(0, Math.min(opts.caretRect.x, opts.containerWidth - opts.popupWidth));
|
|
27
|
+
// Never exceed the actual available space — the clamp guarantee wins over
|
|
28
|
+
// a comfortable minimum height (the list scrolls inside whatever fits).
|
|
29
|
+
if (placement === 'above') {
|
|
30
|
+
return {
|
|
31
|
+
placement,
|
|
32
|
+
left,
|
|
33
|
+
bottom: opts.containerHeight - opts.caretRect.y + GAP,
|
|
34
|
+
maxHeight: Math.max(0, Math.min(opts.maxPopupHeight, spaceAbove)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
placement,
|
|
39
|
+
left,
|
|
40
|
+
top: opts.caretRect.y + opts.caretRect.height + GAP,
|
|
41
|
+
maxHeight: Math.max(0, Math.min(opts.maxPopupHeight, spaceBelow)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Logical screen height in dp from `lynx.SystemInfo` (same pattern as
|
|
46
|
+
* `@sigx/lynx-navigation`'s screen-width module). Falls back to a typical
|
|
47
|
+
* phone height in tests / non-Lynx hosts.
|
|
48
|
+
*/
|
|
49
|
+
export function screenHeightDp(fallback = 800) {
|
|
50
|
+
try {
|
|
51
|
+
const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
|
|
52
|
+
const px = info?.pixelHeight;
|
|
53
|
+
const pr = info?.pixelRatio || 1;
|
|
54
|
+
if (typeof px === 'number' && px > 0) {
|
|
55
|
+
return Math.round(px / pr);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Lynx globals not present (test env / SSR) — use fallback.
|
|
60
|
+
}
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger sessions — the state machine behind the suggestion popup.
|
|
3
|
+
*
|
|
4
|
+
* The element exposes no keystrokes, so the session is a pure function of the
|
|
5
|
+
* editor's two event streams: document text (`bindchange`) and the collapsed
|
|
6
|
+
* caret offset (`bindselection`). On every sync we look at the **run** — the
|
|
7
|
+
* non-whitespace text between the last boundary and the caret. A run starting
|
|
8
|
+
* with a trigger (`@` / a pattern match) means an active session whose query
|
|
9
|
+
* is the rest of the run; anything else means no session. Whitespace, caret
|
|
10
|
+
* exits, blur and selection all close it for free, because they all change
|
|
11
|
+
* the run.
|
|
12
|
+
*
|
|
13
|
+
* `onQuery` may be async: results are tagged with an epoch and discarded when
|
|
14
|
+
* a newer query (or a close) supersedes them. An optional per-trigger
|
|
15
|
+
* `debounce` batches fast typing.
|
|
16
|
+
*/
|
|
17
|
+
import type { TriggerItem, TriggerSpec } from '../plugin.js';
|
|
18
|
+
export interface TriggerSession {
|
|
19
|
+
/** Owning plugin's name. */
|
|
20
|
+
plugin: string;
|
|
21
|
+
/** Offset of the trigger char (run start) in the document text. */
|
|
22
|
+
anchor: number;
|
|
23
|
+
/** Text typed after the trigger prefix. */
|
|
24
|
+
query: string;
|
|
25
|
+
/** Caret offset (exclusive end of the run). */
|
|
26
|
+
caret: number;
|
|
27
|
+
/** Latest resolved suggestions ([] while loading or empty). */
|
|
28
|
+
items: TriggerItem[];
|
|
29
|
+
/** True while an async `onQuery` for the current query is in flight. */
|
|
30
|
+
loading: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface TriggerSessionManager {
|
|
33
|
+
/** Feed the latest document text (from `bindchange`). */
|
|
34
|
+
syncText(text: string): void;
|
|
35
|
+
/** Feed the collapsed caret offset (from `bindselection`); `-1` = no collapsed caret. */
|
|
36
|
+
syncCaret(caret: number): void;
|
|
37
|
+
/** Close the active session (blur, selection made, escape). */
|
|
38
|
+
close(): void;
|
|
39
|
+
readonly session: TriggerSession | null;
|
|
40
|
+
}
|
|
41
|
+
export interface TriggerSessionManagerOptions {
|
|
42
|
+
triggers: ReadonlyArray<{
|
|
43
|
+
plugin: string;
|
|
44
|
+
spec: TriggerSpec;
|
|
45
|
+
}>;
|
|
46
|
+
/** Fired whenever the session opens, updates (query/items), or closes. */
|
|
47
|
+
onUpdate(session: TriggerSession | null): void;
|
|
48
|
+
}
|
|
49
|
+
export declare function createTriggerSessionManager(opts: TriggerSessionManagerOptions): TriggerSessionManager;
|