@llui/markdown-editor 0.1.0

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__llui_deps.json +252 -0
  3. package/dist/editor.d.ts +42 -0
  4. package/dist/editor.d.ts.map +1 -0
  5. package/dist/editor.js +157 -0
  6. package/dist/editor.js.map +1 -0
  7. package/dist/effects.d.ts +17 -0
  8. package/dist/effects.d.ts.map +1 -0
  9. package/dist/effects.js +33 -0
  10. package/dist/effects.js.map +1 -0
  11. package/dist/format.d.ts +6 -0
  12. package/dist/format.d.ts.map +1 -0
  13. package/dist/format.js +51 -0
  14. package/dist/format.js.map +1 -0
  15. package/dist/index.d.ts +23 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +24 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/plugins/callout.d.ts +15 -0
  20. package/dist/plugins/callout.d.ts.map +1 -0
  21. package/dist/plugins/callout.js +151 -0
  22. package/dist/plugins/callout.js.map +1 -0
  23. package/dist/plugins/context-menu.d.ts +3 -0
  24. package/dist/plugins/context-menu.d.ts.map +1 -0
  25. package/dist/plugins/context-menu.js +93 -0
  26. package/dist/plugins/context-menu.js.map +1 -0
  27. package/dist/plugins/core.d.ts +7 -0
  28. package/dist/plugins/core.d.ts.map +1 -0
  29. package/dist/plugins/core.js +189 -0
  30. package/dist/plugins/core.js.map +1 -0
  31. package/dist/plugins/emoji.d.ts +9 -0
  32. package/dist/plugins/emoji.d.ts.map +1 -0
  33. package/dist/plugins/emoji.js +50 -0
  34. package/dist/plugins/emoji.js.map +1 -0
  35. package/dist/plugins/floating-toolbar.d.ts +3 -0
  36. package/dist/plugins/floating-toolbar.d.ts.map +1 -0
  37. package/dist/plugins/floating-toolbar.js +137 -0
  38. package/dist/plugins/floating-toolbar.js.map +1 -0
  39. package/dist/plugins/hr.d.ts +5 -0
  40. package/dist/plugins/hr.d.ts.map +1 -0
  41. package/dist/plugins/hr.js +46 -0
  42. package/dist/plugins/hr.js.map +1 -0
  43. package/dist/plugins/image.d.ts +8 -0
  44. package/dist/plugins/image.d.ts.map +1 -0
  45. package/dist/plugins/image.js +173 -0
  46. package/dist/plugins/image.js.map +1 -0
  47. package/dist/plugins/link.d.ts +7 -0
  48. package/dist/plugins/link.d.ts.map +1 -0
  49. package/dist/plugins/link.js +100 -0
  50. package/dist/plugins/link.js.map +1 -0
  51. package/dist/plugins/math.d.ts +8 -0
  52. package/dist/plugins/math.d.ts.map +1 -0
  53. package/dist/plugins/math.js +81 -0
  54. package/dist/plugins/math.js.map +1 -0
  55. package/dist/plugins/mention.d.ts +11 -0
  56. package/dist/plugins/mention.d.ts.map +1 -0
  57. package/dist/plugins/mention.js +163 -0
  58. package/dist/plugins/mention.js.map +1 -0
  59. package/dist/plugins/mermaid.d.ts +8 -0
  60. package/dist/plugins/mermaid.d.ts.map +1 -0
  61. package/dist/plugins/mermaid.js +92 -0
  62. package/dist/plugins/mermaid.js.map +1 -0
  63. package/dist/plugins/overlay.d.ts +46 -0
  64. package/dist/plugins/overlay.d.ts.map +1 -0
  65. package/dist/plugins/overlay.js +83 -0
  66. package/dist/plugins/overlay.js.map +1 -0
  67. package/dist/plugins/slash.d.ts +3 -0
  68. package/dist/plugins/slash.d.ts.map +1 -0
  69. package/dist/plugins/slash.js +167 -0
  70. package/dist/plugins/slash.js.map +1 -0
  71. package/dist/plugins/table.d.ts +3 -0
  72. package/dist/plugins/table.d.ts.map +1 -0
  73. package/dist/plugins/table.js +227 -0
  74. package/dist/plugins/table.js.map +1 -0
  75. package/dist/plugins/types.d.ts +44 -0
  76. package/dist/plugins/types.d.ts.map +1 -0
  77. package/dist/plugins/types.js +4 -0
  78. package/dist/plugins/types.js.map +1 -0
  79. package/dist/plugins/ui.d.ts +44 -0
  80. package/dist/plugins/ui.d.ts.map +1 -0
  81. package/dist/plugins/ui.js +34 -0
  82. package/dist/plugins/ui.js.map +1 -0
  83. package/dist/state.d.ts +105 -0
  84. package/dist/state.d.ts.map +1 -0
  85. package/dist/state.js +100 -0
  86. package/dist/state.js.map +1 -0
  87. package/dist/styles/editor.css +517 -0
  88. package/dist/surfaces/link-dialog.d.ts +19 -0
  89. package/dist/surfaces/link-dialog.d.ts.map +1 -0
  90. package/dist/surfaces/link-dialog.js +45 -0
  91. package/dist/surfaces/link-dialog.js.map +1 -0
  92. package/dist/surfaces/toolbar.d.ts +48 -0
  93. package/dist/surfaces/toolbar.d.ts.map +1 -0
  94. package/dist/surfaces/toolbar.js +134 -0
  95. package/dist/surfaces/toolbar.js.map +1 -0
  96. package/dist/transformers/gfm.d.ts +7 -0
  97. package/dist/transformers/gfm.d.ts.map +1 -0
  98. package/dist/transformers/gfm.js +41 -0
  99. package/dist/transformers/gfm.js.map +1 -0
  100. package/dist/transformers/registry.d.ts +9 -0
  101. package/dist/transformers/registry.d.ts.map +1 -0
  102. package/dist/transformers/registry.js +43 -0
  103. package/dist/transformers/registry.js.map +1 -0
  104. package/package.json +89 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Franco Ponticelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,252 @@
1
+ {
2
+ "compilerVersion": "0.3.0",
3
+ "components": {},
4
+ "helpers": {
5
+ "editor#markdownEditor": {
6
+ "helperLocalPaths": [],
7
+ "kind": "view-helper",
8
+ "viaParams": [
9
+ {
10
+ "index": 0,
11
+ "reads": [
12
+ "changeDebounceMs",
13
+ "defaultValue",
14
+ "namespace",
15
+ "onChange",
16
+ "onFormatChange",
17
+ "placeholder",
18
+ "plugins",
19
+ "plugins.length",
20
+ "readOnly",
21
+ "theme",
22
+ "toolbar",
23
+ "value"
24
+ ],
25
+ "shape": "state-value"
26
+ }
27
+ ]
28
+ },
29
+ "format#computeFormatState": {
30
+ "helperLocalPaths": [],
31
+ "kind": "view-helper",
32
+ "viaParams": [
33
+ {
34
+ "index": 0,
35
+ "shape": "opaque"
36
+ },
37
+ {
38
+ "index": 1,
39
+ "reads": [
40
+ "canRedo",
41
+ "canUndo"
42
+ ],
43
+ "shape": "state-value"
44
+ }
45
+ ]
46
+ },
47
+ "plugins/callout#calloutPlugin": {
48
+ "helperLocalPaths": [],
49
+ "kind": "view-helper",
50
+ "viaParams": [
51
+ {
52
+ "index": 0,
53
+ "reads": [
54
+ "defaultKind"
55
+ ],
56
+ "shape": "state-value"
57
+ }
58
+ ]
59
+ },
60
+ "plugins/emoji#emojiPlugin": {
61
+ "helperLocalPaths": [],
62
+ "kind": "view-helper",
63
+ "viaParams": [
64
+ {
65
+ "index": 0,
66
+ "reads": [
67
+ "emoji"
68
+ ],
69
+ "shape": "state-value"
70
+ }
71
+ ]
72
+ },
73
+ "plugins/image#imagePlugin": {
74
+ "helperLocalPaths": [],
75
+ "kind": "view-helper",
76
+ "viaParams": [
77
+ {
78
+ "index": 0,
79
+ "reads": [
80
+ "upload"
81
+ ],
82
+ "shape": "state-value"
83
+ }
84
+ ]
85
+ },
86
+ "plugins/link#linkPlugin": {
87
+ "helperLocalPaths": [],
88
+ "kind": "view-helper",
89
+ "viaParams": [
90
+ {
91
+ "index": 0,
92
+ "reads": [
93
+ "defaultUrl"
94
+ ],
95
+ "shape": "state-value"
96
+ }
97
+ ]
98
+ },
99
+ "plugins/math#mathPlugin": {
100
+ "helperLocalPaths": [],
101
+ "kind": "view-helper",
102
+ "viaParams": [
103
+ {
104
+ "index": 0,
105
+ "reads": [
106
+ "render"
107
+ ],
108
+ "shape": "state-value"
109
+ }
110
+ ]
111
+ },
112
+ "plugins/mention#mentionPlugin": {
113
+ "helperLocalPaths": [],
114
+ "kind": "view-helper",
115
+ "viaParams": [
116
+ {
117
+ "index": 0,
118
+ "reads": [
119
+ "source"
120
+ ],
121
+ "shape": "state-value"
122
+ }
123
+ ]
124
+ },
125
+ "plugins/mermaid#mermaidPlugin": {
126
+ "helperLocalPaths": [],
127
+ "kind": "view-helper",
128
+ "viaParams": [
129
+ {
130
+ "index": 0,
131
+ "reads": [
132
+ "render"
133
+ ],
134
+ "shape": "state-value"
135
+ }
136
+ ]
137
+ },
138
+ "plugins/overlay#overlayRoot": {
139
+ "helperLocalPaths": [],
140
+ "kind": "view-helper",
141
+ "viaParams": [
142
+ {
143
+ "index": 0,
144
+ "reads": [
145
+ "attrs",
146
+ "before",
147
+ "open",
148
+ "transform",
149
+ "x",
150
+ "y",
151
+ "zIndex"
152
+ ],
153
+ "shape": "state-value"
154
+ }
155
+ ]
156
+ },
157
+ "plugins/ui#definePluginUI": {
158
+ "helperLocalPaths": [],
159
+ "kind": "view-helper",
160
+ "viaParams": [
161
+ {
162
+ "index": 0,
163
+ "reads": [
164
+ "init",
165
+ "onEffect",
166
+ "update",
167
+ "view"
168
+ ],
169
+ "shape": "state-value"
170
+ }
171
+ ]
172
+ },
173
+ "state#init": {
174
+ "helperLocalPaths": [],
175
+ "kind": "view-helper",
176
+ "viaParams": [
177
+ {
178
+ "index": 0,
179
+ "reads": [
180
+ "readOnly",
181
+ "value",
182
+ "value.length"
183
+ ],
184
+ "shape": "state-value"
185
+ }
186
+ ]
187
+ },
188
+ "state#update": {
189
+ "helperLocalPaths": [],
190
+ "kind": "view-helper",
191
+ "viaParams": [
192
+ {
193
+ "index": 0,
194
+ "shape": "opaque"
195
+ },
196
+ {
197
+ "index": 1,
198
+ "reads": [
199
+ "charCount",
200
+ "format",
201
+ "id",
202
+ "overlay",
203
+ "query",
204
+ "readOnly",
205
+ "type",
206
+ "value",
207
+ "wordCount",
208
+ "x",
209
+ "y"
210
+ ],
211
+ "shape": "state-value"
212
+ }
213
+ ]
214
+ },
215
+ "surfaces/link-dialog#linkDialog": {
216
+ "helperLocalPaths": [],
217
+ "kind": "view-helper",
218
+ "viaParams": [
219
+ {
220
+ "index": 0,
221
+ "reads": [
222
+ "dialog",
223
+ "id",
224
+ "onDialog",
225
+ "url"
226
+ ],
227
+ "shape": "state-value"
228
+ }
229
+ ]
230
+ },
231
+ "surfaces/toolbar#toolbar": {
232
+ "helperLocalPaths": [],
233
+ "kind": "view-helper",
234
+ "viaParams": [
235
+ {
236
+ "index": 0,
237
+ "reads": [
238
+ "aria-label",
239
+ "blockSelect",
240
+ "format",
241
+ "glyphs",
242
+ "groups",
243
+ "items",
244
+ "send"
245
+ ],
246
+ "shape": "state-value"
247
+ }
248
+ ]
249
+ }
250
+ },
251
+ "version": 2
252
+ }
@@ -0,0 +1,42 @@
1
+ import { type EditorThemeClasses, type LexicalEditor } from 'lexical';
2
+ import { type Signal, type SignalComponentDef } from '@llui/dom';
3
+ import type { CommandItem, MarkdownPlugin } from './plugins/types.js';
4
+ import { type EditorEffect, type EditorMsg, type EditorState, type FormatState } from './state.js';
5
+ export interface EditorConfig {
6
+ /** Plugins composing the feature set; order defines transformer precedence.
7
+ * Defaults to `[corePlugin(), linkPlugin()]` so the minimal editor has GFM + links. */
8
+ plugins?: readonly MarkdownPlugin[];
9
+ /** Initial markdown (uncontrolled seed). */
10
+ defaultValue?: string;
11
+ /** Controlled: the consumer owns this signal; the editor follows it. */
12
+ value?: Signal<string>;
13
+ /** Debounced markdown-emission window (ms). Default 300. */
14
+ changeDebounceMs?: number;
15
+ placeholder?: string;
16
+ readOnly?: boolean;
17
+ /** Lexical theme class map. */
18
+ theme?: EditorThemeClasses;
19
+ /** Editor namespace (instance isolation). */
20
+ namespace?: string;
21
+ /** Outbound markdown (after debounce). */
22
+ onChange?: (markdown: string) => void;
23
+ /** Outbound format surface (for chrome built outside this package). */
24
+ onFormatChange?: (format: FormatState) => void;
25
+ /** Receives the live Lexical editor at mount (imperative access, collab hooks). */
26
+ onReady?: (editor: LexicalEditor) => void;
27
+ /** Render the built-in toolbar above the editor. Default false (minimal). */
28
+ toolbar?: boolean;
29
+ }
30
+ /** Hooks the chrome layer (toolbar/menus) uses to compose around the editor. */
31
+ export interface EditorParts {
32
+ /** The merged, surface-filtered command items. */
33
+ items: readonly CommandItem[];
34
+ /** Reactive format signal for `connect`-style toolbars. */
35
+ format: Signal<FormatState>;
36
+ }
37
+ /**
38
+ * Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
39
+ * or compose it inside a larger component.
40
+ */
41
+ export declare function markdownEditor(config?: EditorConfig): SignalComponentDef<EditorState, EditorMsg, EditorEffect>;
42
+ //# sourceMappingURL=editor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"editor.d.ts","sourceRoot":"","sources":["../src/editor.ts"],"names":[],"mappings":"AAMA,OAAO,EAA2B,KAAK,kBAAkB,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAA;AAM9F,OAAO,EAAmC,KAAK,MAAM,EAAE,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAUjG,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAKrE,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,SAAS,EAEd,KAAK,WAAW,EAChB,KAAK,WAAW,EACjB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,YAAY;IAC3B;2FACuF;IACvF,OAAO,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;IACnC,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IACtB,4DAA4D;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,kBAAkB,CAAA;IAC1B,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC,uEAAuE;IACvE,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IAC9C,mFAAmF;IACnF,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAA;IACzC,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,KAAK,EAAE,SAAS,WAAW,EAAE,CAAA;IAC7B,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC,WAAW,CAAC,CAAA;CAC5B;AAOD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,MAAM,GAAE,YAAiB,GACxB,kBAAkB,CAAC,WAAW,EAAE,SAAS,EAAE,YAAY,CAAC,CAwJ1D"}
package/dist/editor.js ADDED
@@ -0,0 +1,157 @@
1
+ // `markdownEditor(config)` — the high-level component. Lexical owns the live
2
+ // document; this wires the foreign seam to the markdown transformer converters,
3
+ // surfaces the format state for the chrome, routes command intents back to the
4
+ // live editor through effects, and COMPOSES plugin UI extensions (each plugin's
5
+ // state slice + reducer + view + effects) into the single component.
6
+ import { $getRoot, $setSelection } from 'lexical';
7
+ import { $convertFromMarkdownString, $convertToMarkdownString, registerMarkdownShortcuts, } from '@lexical/markdown';
8
+ import { component, div } from '@llui/dom';
9
+ import { lexicalForeign, registerDecoratorBridges, PROGRAMMATIC_TAG, } from '@llui/lexical';
10
+ import { corePlugin } from './plugins/core.js';
11
+ import { linkPlugin } from './plugins/link.js';
12
+ import { toolbar as renderToolbar } from './surfaces/toolbar.js';
13
+ import { buildTransformers } from './transformers/registry.js';
14
+ import { computeFormatState } from './format.js';
15
+ import { makeOnEffect } from './effects.js';
16
+ import { countWords, init, update, } from './state.js';
17
+ /** Default plugin set when the consumer supplies none. */
18
+ function defaultPlugins() {
19
+ return [corePlugin(), linkPlugin()];
20
+ }
21
+ /**
22
+ * Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
23
+ * or compose it inside a larger component.
24
+ */
25
+ export function markdownEditor(config = {}) {
26
+ const plugins = config.plugins && config.plugins.length > 0 ? config.plugins : defaultPlugins();
27
+ const transformers = buildTransformers(plugins);
28
+ const items = plugins.flatMap((p) => p.items ?? []);
29
+ const itemsById = new Map(items.map((i) => [i.id, i]));
30
+ // Share the merged item list with plugins that want it (e.g. the slash menu).
31
+ for (const plugin of plugins)
32
+ plugin.onItems?.(items);
33
+ const decorators = plugins.flatMap((p) => p.decorators ?? []);
34
+ const pluginUIs = plugins
35
+ .filter((p) => p.ui !== undefined)
36
+ .map((p) => ({ name: p.name, ui: p.ui }));
37
+ const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]));
38
+ // The live editor, captured at mount; effects dispatch through it.
39
+ let editorRef = null;
40
+ const getEditor = () => editorRef;
41
+ const baseOnEffect = makeOnEffect(getEditor, itemsById, {
42
+ onChange: config.onChange,
43
+ onFormatChange: config.onFormatChange,
44
+ applyValue: (editor, value) => editor.update(() => {
45
+ $convertFromMarkdownString(value, transformers);
46
+ // Clear selection so the reconciler doesn't pull DOM focus into the
47
+ // editor on an external push (e.g. typing in a bound source textarea).
48
+ $setSelection(null);
49
+ }, { tag: PROGRAMMATIC_TAG }),
50
+ });
51
+ const seedValue = config.value ? config.value.peek() : (config.defaultValue ?? '');
52
+ // ── Composed TEA: core + plugin UI slices ──────────────────────────────────
53
+ const composedInit = () => {
54
+ const [core, effects] = init({ value: seedValue, readOnly: config.readOnly ?? false });
55
+ const slices = {};
56
+ for (const { name, ui } of pluginUIs)
57
+ slices[name] = ui.init();
58
+ return [{ ...core, plugins: slices }, effects];
59
+ };
60
+ const composedUpdate = (state, msg) => {
61
+ if (msg.type === 'plugin') {
62
+ const ui = pluginUIByName.get(msg.name);
63
+ if (!ui?.update)
64
+ return [state, []];
65
+ const result = ui.update(state.plugins[msg.name], msg.msg);
66
+ const [slice, effects] = (Array.isArray(result) ? result : [result, []]);
67
+ return [
68
+ { ...state, plugins: { ...state.plugins, [msg.name]: slice } },
69
+ effects.map((effect) => ({ type: 'pluginEffect', name: msg.name, effect })),
70
+ ];
71
+ }
72
+ return update(state, msg);
73
+ };
74
+ const composedOnEffect = (effect, api) => {
75
+ if (effect.type === 'pluginEffect') {
76
+ const ui = pluginUIByName.get(effect.name);
77
+ ui?.onEffect?.(effect.effect, {
78
+ editor: getEditor,
79
+ send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),
80
+ emit: (msg) => api.send(msg),
81
+ });
82
+ return;
83
+ }
84
+ baseOnEffect(effect, api);
85
+ };
86
+ const view = ({ state, send, }) => {
87
+ const host = lexicalForeign({
88
+ namespace: config.namespace ?? 'llui-markdown',
89
+ theme: config.theme,
90
+ plugins,
91
+ serialize: (editor) => editor.getEditorState().read(() => $convertToMarkdownString(transformers)),
92
+ deserialize: (_editor, value) => {
93
+ $convertFromMarkdownString(value, transformers);
94
+ $setSelection(null);
95
+ },
96
+ defaultValue: config.value ? undefined : (config.defaultValue ?? ''),
97
+ ...(config.value ? { value: config.value } : {}),
98
+ readOnly: state.at('readOnly'),
99
+ ...(config.changeDebounceMs !== undefined
100
+ ? { changeDebounceMs: config.changeDebounceMs }
101
+ : {}),
102
+ register: (editor) => {
103
+ const disposers = [registerMarkdownShortcuts(editor, transformers)];
104
+ if (decorators.length > 0)
105
+ disposers.push(registerDecoratorBridges(editor, decorators));
106
+ return () => {
107
+ for (const dispose of disposers)
108
+ dispose();
109
+ };
110
+ },
111
+ onReady: (editor) => {
112
+ editorRef = editor;
113
+ if (config.placeholder) {
114
+ editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder);
115
+ }
116
+ config.onReady?.(editor);
117
+ },
118
+ onChange: (value) => send({ type: 'markdownChanged', value }),
119
+ onSelectionChange: (ctx) => {
120
+ const format = computeFormatState(ctx.editor, ctx);
121
+ const text = ctx.editor.getEditorState().read(() => $getRoot().getTextContent());
122
+ // Toggle an empty marker so CSS can show the placeholder.
123
+ ctx.editor.getRootElement()?.setAttribute('data-empty', text === '' ? 'true' : 'false');
124
+ send({ type: 'formatChanged', format, wordCount: countWords(text), charCount: text.length });
125
+ },
126
+ emit: (msg) => send(msg),
127
+ });
128
+ // Plugin view contributions (overlays/panels) — each gets its own slice + send.
129
+ const pluginViews = pluginUIs.flatMap(({ name, ui }) => {
130
+ if (!ui.view)
131
+ return [];
132
+ const rendered = ui.view({
133
+ state: state.at(`plugins.${name}`),
134
+ send: (msg) => send({ type: 'plugin', name, msg }),
135
+ editor: getEditor,
136
+ });
137
+ return Array.isArray(rendered) ? rendered : [rendered];
138
+ });
139
+ if (!config.toolbar)
140
+ return [host, ...pluginViews];
141
+ return [
142
+ div({ 'data-scope': 'md-editor', 'data-part': 'root' }, [
143
+ renderToolbar({ format: state.at('format'), send, items }),
144
+ div({ 'data-scope': 'md-editor', 'data-part': 'surface' }, [host]),
145
+ ]),
146
+ ...pluginViews,
147
+ ];
148
+ };
149
+ return component({
150
+ name: 'MarkdownEditor',
151
+ init: composedInit,
152
+ update: composedUpdate,
153
+ view,
154
+ onEffect: composedOnEffect,
155
+ });
156
+ }
157
+ //# sourceMappingURL=editor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"editor.js","sourceRoot":"","sources":["../src/editor.ts"],"names":[],"mappings":"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","sourcesContent":["// `markdownEditor(config)` — the high-level component. Lexical owns the live\n// document; this wires the foreign seam to the markdown transformer converters,\n// surfaces the format state for the chrome, routes command intents back to the\n// live editor through effects, and COMPOSES plugin UI extensions (each plugin's\n// state slice + reducer + view + effects) into the single component.\n\nimport { $getRoot, $setSelection, type EditorThemeClasses, type LexicalEditor } from 'lexical'\nimport {\n $convertFromMarkdownString,\n $convertToMarkdownString,\n registerMarkdownShortcuts,\n} from '@lexical/markdown'\nimport { component, div, type Renderable, type Signal, type SignalComponentDef } from '@llui/dom'\nimport {\n lexicalForeign,\n registerDecoratorBridges,\n PROGRAMMATIC_TAG,\n type DecoratorBridge,\n} from '@llui/lexical'\nimport { corePlugin } from './plugins/core.js'\nimport { linkPlugin } from './plugins/link.js'\nimport { toolbar as renderToolbar } from './surfaces/toolbar.js'\nimport type { CommandItem, MarkdownPlugin } from './plugins/types.js'\nimport type { PluginUI } from './plugins/ui.js'\nimport { buildTransformers } from './transformers/registry.js'\nimport { computeFormatState } from './format.js'\nimport { makeOnEffect } from './effects.js'\nimport {\n countWords,\n init,\n update,\n type EditorEffect,\n type EditorMsg,\n type EditorOutMsg,\n type EditorState,\n type FormatState,\n} from './state.js'\n\nexport interface EditorConfig {\n /** Plugins composing the feature set; order defines transformer precedence.\n * Defaults to `[corePlugin(), linkPlugin()]` so the minimal editor has GFM + links. */\n plugins?: readonly MarkdownPlugin[]\n /** Initial markdown (uncontrolled seed). */\n defaultValue?: string\n /** Controlled: the consumer owns this signal; the editor follows it. */\n value?: Signal<string>\n /** Debounced markdown-emission window (ms). Default 300. */\n changeDebounceMs?: number\n placeholder?: string\n readOnly?: boolean\n /** Lexical theme class map. */\n theme?: EditorThemeClasses\n /** Editor namespace (instance isolation). */\n namespace?: string\n /** Outbound markdown (after debounce). */\n onChange?: (markdown: string) => void\n /** Outbound format surface (for chrome built outside this package). */\n onFormatChange?: (format: FormatState) => void\n /** Receives the live Lexical editor at mount (imperative access, collab hooks). */\n onReady?: (editor: LexicalEditor) => void\n /** Render the built-in toolbar above the editor. Default false (minimal). */\n toolbar?: boolean\n}\n\n/** Hooks the chrome layer (toolbar/menus) uses to compose around the editor. */\nexport interface EditorParts {\n /** The merged, surface-filtered command items. */\n items: readonly CommandItem[]\n /** Reactive format signal for `connect`-style toolbars. */\n format: Signal<FormatState>\n}\n\n/** Default plugin set when the consumer supplies none. */\nfunction defaultPlugins(): MarkdownPlugin[] {\n return [corePlugin(), linkPlugin()]\n}\n\n/**\n * Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`\n * or compose it inside a larger component.\n */\nexport function markdownEditor(\n config: EditorConfig = {},\n): SignalComponentDef<EditorState, EditorMsg, EditorEffect> {\n const plugins = config.plugins && config.plugins.length > 0 ? config.plugins : defaultPlugins()\n const transformers = buildTransformers(plugins)\n\n const items: CommandItem[] = plugins.flatMap((p) => p.items ?? [])\n const itemsById = new Map(items.map((i) => [i.id, i]))\n // Share the merged item list with plugins that want it (e.g. the slash menu).\n for (const plugin of plugins) plugin.onItems?.(items)\n const decorators: DecoratorBridge[] = plugins.flatMap((p) => p.decorators ?? [])\n const pluginUIs: Array<{ name: string; ui: PluginUI }> = plugins\n .filter((p): p is MarkdownPlugin & { ui: PluginUI } => p.ui !== undefined)\n .map((p) => ({ name: p.name, ui: p.ui }))\n const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]))\n\n // The live editor, captured at mount; effects dispatch through it.\n let editorRef: LexicalEditor | null = null\n const getEditor = (): LexicalEditor | null => editorRef\n\n const baseOnEffect = makeOnEffect(getEditor, itemsById, {\n onChange: config.onChange,\n onFormatChange: config.onFormatChange,\n applyValue: (editor, value) =>\n editor.update(\n () => {\n $convertFromMarkdownString(value, transformers)\n // Clear selection so the reconciler doesn't pull DOM focus into the\n // editor on an external push (e.g. typing in a bound source textarea).\n $setSelection(null)\n },\n { tag: PROGRAMMATIC_TAG },\n ),\n })\n\n const seedValue = config.value ? config.value.peek() : (config.defaultValue ?? '')\n\n // ── Composed TEA: core + plugin UI slices ──────────────────────────────────\n const composedInit = (): [EditorState, EditorEffect[]] => {\n const [core, effects] = init({ value: seedValue, readOnly: config.readOnly ?? false })\n const slices: Record<string, unknown> = {}\n for (const { name, ui } of pluginUIs) slices[name] = ui.init()\n return [{ ...core, plugins: slices }, effects]\n }\n\n const composedUpdate = (state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]] => {\n if (msg.type === 'plugin') {\n const ui = pluginUIByName.get(msg.name)\n if (!ui?.update) return [state, []]\n const result = ui.update(state.plugins[msg.name], msg.msg)\n const [slice, effects] = (Array.isArray(result) ? result : [result, []]) as [\n unknown,\n unknown[],\n ]\n return [\n { ...state, plugins: { ...state.plugins, [msg.name]: slice } },\n effects.map((effect) => ({ type: 'pluginEffect' as const, name: msg.name, effect })),\n ]\n }\n return update(state, msg)\n }\n\n const composedOnEffect = (\n effect: EditorEffect,\n api: { send: (msg: EditorMsg) => void; state: Signal<EditorState> },\n ): void => {\n if (effect.type === 'pluginEffect') {\n const ui = pluginUIByName.get(effect.name)\n ui?.onEffect?.(effect.effect, {\n editor: getEditor,\n send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),\n emit: (msg) => api.send(msg as EditorMsg),\n })\n return\n }\n baseOnEffect(effect, api)\n }\n\n const view = ({\n state,\n send,\n }: {\n state: Signal<EditorState>\n send: (msg: EditorMsg) => void\n }): Renderable => {\n const host = lexicalForeign<EditorOutMsg>({\n namespace: config.namespace ?? 'llui-markdown',\n theme: config.theme,\n plugins,\n serialize: (editor) =>\n editor.getEditorState().read(() => $convertToMarkdownString(transformers)),\n deserialize: (_editor, value) => {\n $convertFromMarkdownString(value, transformers)\n $setSelection(null)\n },\n defaultValue: config.value ? undefined : (config.defaultValue ?? ''),\n ...(config.value ? { value: config.value } : {}),\n readOnly: state.at('readOnly'),\n ...(config.changeDebounceMs !== undefined\n ? { changeDebounceMs: config.changeDebounceMs }\n : {}),\n register: (editor) => {\n const disposers = [registerMarkdownShortcuts(editor, transformers)]\n if (decorators.length > 0) disposers.push(registerDecoratorBridges(editor, decorators))\n return () => {\n for (const dispose of disposers) dispose()\n }\n },\n onReady: (editor) => {\n editorRef = editor\n if (config.placeholder) {\n editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder)\n }\n config.onReady?.(editor)\n },\n onChange: (value) => send({ type: 'markdownChanged', value }),\n onSelectionChange: (ctx) => {\n const format = computeFormatState(ctx.editor, ctx)\n const text = ctx.editor.getEditorState().read(() => $getRoot().getTextContent())\n // Toggle an empty marker so CSS can show the placeholder.\n ctx.editor.getRootElement()?.setAttribute('data-empty', text === '' ? 'true' : 'false')\n send({ type: 'formatChanged', format, wordCount: countWords(text), charCount: text.length })\n },\n emit: (msg) => send(msg),\n })\n\n // Plugin view contributions (overlays/panels) — each gets its own slice + send.\n const pluginViews: Renderable = pluginUIs.flatMap(({ name, ui }) => {\n if (!ui.view) return []\n const rendered = ui.view({\n state: state.at(`plugins.${name}`),\n send: (msg) => send({ type: 'plugin', name, msg }),\n editor: getEditor,\n })\n return Array.isArray(rendered) ? rendered : [rendered]\n })\n\n if (!config.toolbar) return [host, ...pluginViews]\n return [\n div({ 'data-scope': 'md-editor', 'data-part': 'root' }, [\n renderToolbar({ format: state.at('format'), send, items }),\n div({ 'data-scope': 'md-editor', 'data-part': 'surface' }, [host]),\n ]),\n ...pluginViews,\n ]\n }\n\n return component<EditorState, EditorMsg, EditorEffect>({\n name: 'MarkdownEditor',\n init: composedInit,\n update: composedUpdate,\n view,\n onEffect: composedOnEffect,\n })\n}\n"]}
@@ -0,0 +1,17 @@
1
+ import type { LexicalEditor } from 'lexical';
2
+ import type { CommandItem } from './plugins/types.js';
3
+ import type { EditorEffect, EditorMsg, FormatState } from './state.js';
4
+ /** The api the component passes to `onEffect` (send + state). */
5
+ export interface EffectApi {
6
+ send: (msg: EditorMsg) => void;
7
+ }
8
+ export interface EffectConfig {
9
+ onChange?: (markdown: string) => void;
10
+ onFormatChange?: (format: FormatState) => void;
11
+ /** Push markdown into the live editor (deserialize), without echoing onChange. */
12
+ applyValue: (editor: LexicalEditor, value: string) => void;
13
+ }
14
+ /** Build the component's `onEffect`. `getEditor` returns the live editor (set at
15
+ * mount via the foreign `onReady`); `items` is the merged id → command map. */
16
+ export declare function makeOnEffect(getEditor: () => LexicalEditor | null, items: ReadonlyMap<string, CommandItem>, config: EffectConfig): (effect: EditorEffect, api: EffectApi) => void;
17
+ //# sourceMappingURL=effects.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effects.d.ts","sourceRoot":"","sources":["../src/effects.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAEtE,iEAAiE;AACjE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,CAAA;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;IAC9C,kFAAkF;IAClF,UAAU,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAC3D;AAED;+EAC+E;AAC/E,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,aAAa,GAAG,IAAI,EACrC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,EACvC,MAAM,EAAE,YAAY,GACnB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,SAAS,KAAK,IAAI,CAwBhD"}
@@ -0,0 +1,33 @@
1
+ // Effect handler: the only place TEA reaches back into the live Lexical editor.
2
+ // `execCommand` looks an id up in the merged command-item map and runs it on the
3
+ // editor captured at mount; emit* forward to the consumer's callbacks.
4
+ /** Build the component's `onEffect`. `getEditor` returns the live editor (set at
5
+ * mount via the foreign `onReady`); `items` is the merged id → command map. */
6
+ export function makeOnEffect(getEditor, items, config) {
7
+ return (effect, api) => {
8
+ switch (effect.type) {
9
+ case 'execCommand': {
10
+ const editor = getEditor();
11
+ const item = items.get(effect.id);
12
+ if (editor && item)
13
+ item.run(editor, { send: api.send });
14
+ return;
15
+ }
16
+ case 'applyValue': {
17
+ const editor = getEditor();
18
+ if (editor)
19
+ config.applyValue(editor, effect.value);
20
+ return;
21
+ }
22
+ case 'emitChange': {
23
+ config.onChange?.(effect.value);
24
+ return;
25
+ }
26
+ case 'emitFormat': {
27
+ config.onFormatChange?.(effect.format);
28
+ return;
29
+ }
30
+ }
31
+ };
32
+ }
33
+ //# sourceMappingURL=effects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effects.js","sourceRoot":"","sources":["../src/effects.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,iFAAiF;AACjF,uEAAuE;AAkBvE;+EAC+E;AAC/E,MAAM,UAAU,YAAY,CAC1B,SAAqC,EACrC,KAAuC,EACvC,MAAoB;IAEpB,OAAO,CAAC,MAAM,EAAE,GAAG,EAAE,EAAE;QACrB,QAAQ,MAAM,CAAC,IAAI,EAAE,CAAC;YACpB,KAAK,aAAa,CAAC,CAAC,CAAC;gBACnB,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;gBAC1B,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;gBACjC,IAAI,MAAM,IAAI,IAAI;oBAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,CAAA;gBACxD,OAAM;YACR,CAAC;YACD,KAAK,YAAY,CAAC,CAAC,CAAC;gBAClB,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;gBAC1B,IAAI,MAAM;oBAAE,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;gBACnD,OAAM;YACR,CAAC;YACD,KAAK,YAAY,CAAC,CAAC,CAAC;gBAClB,MAAM,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;gBAC/B,OAAM;YACR,CAAC;YACD,KAAK,YAAY,CAAC,CAAC,CAAC;gBAClB,MAAM,CAAC,cAAc,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;gBACtC,OAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC,CAAA;AACH,CAAC","sourcesContent":["// Effect handler: the only place TEA reaches back into the live Lexical editor.\n// `execCommand` looks an id up in the merged command-item map and runs it on the\n// editor captured at mount; emit* forward to the consumer's callbacks.\n\nimport type { LexicalEditor } from 'lexical'\nimport type { CommandItem } from './plugins/types.js'\nimport type { EditorEffect, EditorMsg, FormatState } from './state.js'\n\n/** The api the component passes to `onEffect` (send + state). */\nexport interface EffectApi {\n send: (msg: EditorMsg) => void\n}\n\nexport interface EffectConfig {\n onChange?: (markdown: string) => void\n onFormatChange?: (format: FormatState) => void\n /** Push markdown into the live editor (deserialize), without echoing onChange. */\n applyValue: (editor: LexicalEditor, value: string) => void\n}\n\n/** Build the component's `onEffect`. `getEditor` returns the live editor (set at\n * mount via the foreign `onReady`); `items` is the merged id → command map. */\nexport function makeOnEffect(\n getEditor: () => LexicalEditor | null,\n items: ReadonlyMap<string, CommandItem>,\n config: EffectConfig,\n): (effect: EditorEffect, api: EffectApi) => void {\n return (effect, api) => {\n switch (effect.type) {\n case 'execCommand': {\n const editor = getEditor()\n const item = items.get(effect.id)\n if (editor && item) item.run(editor, { send: api.send })\n return\n }\n case 'applyValue': {\n const editor = getEditor()\n if (editor) config.applyValue(editor, effect.value)\n return\n }\n case 'emitChange': {\n config.onChange?.(effect.value)\n return\n }\n case 'emitFormat': {\n config.onFormatChange?.(effect.format)\n return\n }\n }\n }\n}\n"]}
@@ -0,0 +1,6 @@
1
+ import { type LexicalEditor } from 'lexical';
2
+ import type { SelectionContext } from '@llui/lexical';
3
+ import { type FormatState } from './state.js';
4
+ /** Read the full format surface at the current selection (opens a read ctx). */
5
+ export declare function computeFormatState(editor: LexicalEditor, history: Pick<SelectionContext, 'canUndo' | 'canRedo'>): FormatState;
6
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAIA,OAAO,EAAoC,KAAK,aAAa,EAAE,MAAM,SAAS,CAAA;AAM9E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AAE3E,gFAAgF;AAChF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,SAAS,CAAC,GACrD,WAAW,CAuCb"}
package/dist/format.js ADDED
@@ -0,0 +1,51 @@
1
+ // Compute the full toolbar FormatState from a Lexical selection — the base
2
+ // rich-text format (from @llui/lexical) refined with list/code/link detection
3
+ // (which needs the list/code/link packages this layer depends on).
4
+ import { $getSelection, $isRangeSelection } from 'lexical';
5
+ import { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils';
6
+ import { ListNode, $isListItemNode } from '@lexical/list';
7
+ import { $isCodeNode } from '@lexical/code-core';
8
+ import { $isLinkNode } from '@lexical/link';
9
+ import { $readBaseFormat } from '@llui/lexical';
10
+ import { EMPTY_FORMAT } from './state.js';
11
+ /** Read the full format surface at the current selection (opens a read ctx). */
12
+ export function computeFormatState(editor, history) {
13
+ return editor.getEditorState().read(() => {
14
+ const base = $readBaseFormat();
15
+ if (!base.hasSelection) {
16
+ return { ...EMPTY_FORMAT, canUndo: history.canUndo, canRedo: history.canRedo };
17
+ }
18
+ let blockType = base.blockType;
19
+ let link = false;
20
+ const selection = $getSelection();
21
+ if ($isRangeSelection(selection)) {
22
+ const anchorNode = selection.anchor.getNode();
23
+ link = $findMatchingParent(anchorNode, (node) => $isLinkNode(node)) !== null;
24
+ if (base.blockType === 'other') {
25
+ const listItem = $findMatchingParent(anchorNode, (node) => $isListItemNode(node));
26
+ if (listItem) {
27
+ const list = $getNearestNodeOfType(anchorNode, ListNode);
28
+ if (list)
29
+ blockType = list.getListType();
30
+ }
31
+ else {
32
+ const top = anchorNode.getKey() === 'root' ? null : anchorNode.getTopLevelElement();
33
+ if (top && $isCodeNode(top))
34
+ blockType = 'code';
35
+ }
36
+ }
37
+ }
38
+ return {
39
+ bold: base.bold,
40
+ italic: base.italic,
41
+ strikethrough: base.strikethrough,
42
+ code: base.code,
43
+ link,
44
+ blockType,
45
+ alignment: base.alignment,
46
+ canUndo: history.canUndo,
47
+ canRedo: history.canRedo,
48
+ };
49
+ });
50
+ }
51
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,8EAA8E;AAC9E,mEAAmE;AAEnE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAsB,MAAM,SAAS,CAAA;AAC9E,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAC3E,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACzD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE/C,OAAO,EAAE,YAAY,EAAoC,MAAM,YAAY,CAAA;AAE3E,gFAAgF;AAChF,MAAM,UAAU,kBAAkB,CAChC,MAAqB,EACrB,OAAsD;IAEtD,OAAO,MAAM,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;QACvC,MAAM,IAAI,GAAG,eAAe,EAAE,CAAA;QAC9B,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACvB,OAAO,EAAE,GAAG,YAAY,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAA;QAChF,CAAC;QAED,IAAI,SAAS,GAAc,IAAI,CAAC,SAAS,CAAA;QACzC,IAAI,IAAI,GAAG,KAAK,CAAA;QAEhB,MAAM,SAAS,GAAG,aAAa,EAAE,CAAA;QACjC,IAAI,iBAAiB,CAAC,SAAS,CAAC,EAAE,CAAC;YACjC,MAAM,UAAU,GAAG,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;YAC7C,IAAI,GAAG,mBAAmB,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,CAAA;YAE5E,IAAI,IAAI,CAAC,SAAS,KAAK,OAAO,EAAE,CAAC;gBAC/B,MAAM,QAAQ,GAAG,mBAAmB,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAA;gBACjF,IAAI,QAAQ,EAAE,CAAC;oBACb,MAAM,IAAI,GAAG,qBAAqB,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;oBACxD,IAAI,IAAI;wBAAE,SAAS,GAAG,IAAI,CAAC,WAAW,EAAE,CAAA;gBAC1C,CAAC;qBAAM,CAAC;oBACN,MAAM,GAAG,GAAG,UAAU,CAAC,MAAM,EAAE,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,kBAAkB,EAAE,CAAA;oBACnF,IAAI,GAAG,IAAI,WAAW,CAAC,GAAG,CAAC;wBAAE,SAAS,GAAG,MAAM,CAAA;gBACjD,CAAC;YACH,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,IAAI;YACJ,SAAS;YACT,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,OAAO,EAAE,OAAO,CAAC,OAAO;YACxB,OAAO,EAAE,OAAO,CAAC,OAAO;SACzB,CAAA;IACH,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["// Compute the full toolbar FormatState from a Lexical selection — the base\n// rich-text format (from @llui/lexical) refined with list/code/link detection\n// (which needs the list/code/link packages this layer depends on).\n\nimport { $getSelection, $isRangeSelection, type LexicalEditor } from 'lexical'\nimport { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'\nimport { ListNode, $isListItemNode } from '@lexical/list'\nimport { $isCodeNode } from '@lexical/code-core'\nimport { $isLinkNode } from '@lexical/link'\nimport { $readBaseFormat } from '@llui/lexical'\nimport type { SelectionContext } from '@llui/lexical'\nimport { EMPTY_FORMAT, type BlockType, type FormatState } from './state.js'\n\n/** Read the full format surface at the current selection (opens a read ctx). */\nexport function computeFormatState(\n editor: LexicalEditor,\n history: Pick<SelectionContext, 'canUndo' | 'canRedo'>,\n): FormatState {\n return editor.getEditorState().read(() => {\n const base = $readBaseFormat()\n if (!base.hasSelection) {\n return { ...EMPTY_FORMAT, canUndo: history.canUndo, canRedo: history.canRedo }\n }\n\n let blockType: BlockType = base.blockType\n let link = false\n\n const selection = $getSelection()\n if ($isRangeSelection(selection)) {\n const anchorNode = selection.anchor.getNode()\n link = $findMatchingParent(anchorNode, (node) => $isLinkNode(node)) !== null\n\n if (base.blockType === 'other') {\n const listItem = $findMatchingParent(anchorNode, (node) => $isListItemNode(node))\n if (listItem) {\n const list = $getNearestNodeOfType(anchorNode, ListNode)\n if (list) blockType = list.getListType()\n } else {\n const top = anchorNode.getKey() === 'root' ? null : anchorNode.getTopLevelElement()\n if (top && $isCodeNode(top)) blockType = 'code'\n }\n }\n }\n\n return {\n bold: base.bold,\n italic: base.italic,\n strikethrough: base.strikethrough,\n code: base.code,\n link,\n blockType,\n alignment: base.alignment,\n canUndo: history.canUndo,\n canRedo: history.canRedo,\n }\n })\n}\n"]}