@llui/markdown-editor 0.2.12 → 0.2.14

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 (89) hide show
  1. package/README.md +44 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +34 -8
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -3
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +7 -5
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/paste.d.ts.map +1 -1
  12. package/dist/paste.js +5 -0
  13. package/dist/paste.js.map +1 -1
  14. package/dist/plugins/_preview.js.map +1 -1
  15. package/dist/plugins/callout.d.ts.map +1 -1
  16. package/dist/plugins/callout.js +37 -45
  17. package/dist/plugins/callout.js.map +1 -1
  18. package/dist/plugins/context-menu.js.map +1 -1
  19. package/dist/plugins/core.js.map +1 -1
  20. package/dist/plugins/emoji.js.map +1 -1
  21. package/dist/plugins/floating-toolbar.js.map +1 -1
  22. package/dist/plugins/hr.d.ts.map +1 -1
  23. package/dist/plugins/hr.js +4 -7
  24. package/dist/plugins/hr.js.map +1 -1
  25. package/dist/plugins/image.d.ts.map +1 -1
  26. package/dist/plugins/image.js +28 -20
  27. package/dist/plugins/image.js.map +1 -1
  28. package/dist/plugins/inline.js.map +1 -1
  29. package/dist/plugins/link.d.ts.map +1 -1
  30. package/dist/plugins/link.js +16 -9
  31. package/dist/plugins/link.js.map +1 -1
  32. package/dist/plugins/math.d.ts.map +1 -1
  33. package/dist/plugins/math.js +25 -34
  34. package/dist/plugins/math.js.map +1 -1
  35. package/dist/plugins/mention.js.map +1 -1
  36. package/dist/plugins/mermaid.d.ts.map +1 -1
  37. package/dist/plugins/mermaid.js +25 -34
  38. package/dist/plugins/mermaid.js.map +1 -1
  39. package/dist/plugins/overlay.js.map +1 -1
  40. package/dist/plugins/single-block.js.map +1 -1
  41. package/dist/plugins/slash.js.map +1 -1
  42. package/dist/plugins/table.d.ts.map +1 -1
  43. package/dist/plugins/table.js +7 -4
  44. package/dist/plugins/table.js.map +1 -1
  45. package/dist/plugins/types.js.map +1 -1
  46. package/dist/plugins/ui.js.map +1 -1
  47. package/dist/security.d.ts +26 -0
  48. package/dist/security.d.ts.map +1 -0
  49. package/dist/security.js +87 -0
  50. package/dist/security.js.map +1 -0
  51. package/dist/state.js.map +1 -1
  52. package/dist/surfaces/link-dialog.js.map +1 -1
  53. package/dist/surfaces/toolbar.js.map +1 -1
  54. package/dist/theme.js.map +1 -1
  55. package/dist/transformers/gfm.js.map +1 -1
  56. package/dist/transformers/registry.js.map +1 -1
  57. package/package.json +23 -17
  58. package/src/editor.ts +339 -0
  59. package/src/effects.ts +53 -0
  60. package/src/format.ts +57 -0
  61. package/src/index.ts +93 -0
  62. package/src/paste.ts +96 -0
  63. package/src/plugins/_preview.ts +51 -0
  64. package/src/plugins/callout.ts +180 -0
  65. package/src/plugins/context-menu.ts +118 -0
  66. package/src/plugins/core.ts +179 -0
  67. package/src/plugins/emoji.ts +59 -0
  68. package/src/plugins/floating-toolbar.ts +180 -0
  69. package/src/plugins/hr.ts +56 -0
  70. package/src/plugins/image.ts +240 -0
  71. package/src/plugins/inline.ts +76 -0
  72. package/src/plugins/link.ts +143 -0
  73. package/src/plugins/math.ts +102 -0
  74. package/src/plugins/mention.ts +224 -0
  75. package/src/plugins/mermaid.ts +115 -0
  76. package/src/plugins/overlay.ts +138 -0
  77. package/src/plugins/single-block.ts +193 -0
  78. package/src/plugins/slash.ts +224 -0
  79. package/src/plugins/table.ts +284 -0
  80. package/src/plugins/types.ts +50 -0
  81. package/src/plugins/ui.ts +79 -0
  82. package/src/security.ts +89 -0
  83. package/src/state.ts +220 -0
  84. package/src/styles/editor.css +554 -0
  85. package/src/surfaces/link-dialog.ts +67 -0
  86. package/src/surfaces/toolbar.ts +246 -0
  87. package/src/theme.ts +42 -0
  88. package/src/transformers/gfm.ts +69 -0
  89. package/src/transformers/registry.ts +47 -0
@@ -0,0 +1,89 @@
1
+ // URL-scheme policy for the editor.
2
+ //
3
+ // The read-only renderer (`@llui/markdown`) already refuses dangerous link/image
4
+ // schemes (`javascript:`, `vbscript:`, `data:text/html`, …) via `sanitizeUrl`.
5
+ // The EDITOR has additional ingress points that must enforce the SAME policy or
6
+ // an attacker-authored document becomes live/clickable inside it:
7
+ //
8
+ // 1. link commit — `$toggleLink` on a raw, user-typed URL,
9
+ // 2. image src — an `![alt](src)` inserted or imported,
10
+ // 3. markdown paste/import — `$convertFromMarkdownString` builds real
11
+ // `LinkNode`s (and image decorator nodes) from untrusted text,
12
+ // 4. the typed `[x](url)` markdown shortcut — builds a `LinkNode` live.
13
+ //
14
+ // Links are additionally guarded by a global `LinkNode` node transform
15
+ // ({@link registerLinkSanitizer}) so #1/#3/#4 all funnel through one enforcement
16
+ // point. Rather than hand-roll (and drift from) a second allowlist, this module
17
+ // reuses `@llui/markdown`'s exact `sanitizeUrl` and only differs in the
18
+ // allowed-scheme SET per surface — which is data, not a divergent implementation.
19
+
20
+ import { sanitizeUrl, defaultAllowedProtocols } from '@llui/markdown/security'
21
+ import { $isLinkNode, LinkNode } from '@lexical/link'
22
+ import { $isElementNode, type LexicalEditor, type LexicalNode } from 'lexical'
23
+
24
+ /** Schemes allowed for a hyperlink href. A click navigates, so this is the strict
25
+ * set — identical to the renderer's default (`http`/`https`/`mailto`/`tel`). */
26
+ export const LINK_PROTOCOLS: readonly string[] = defaultAllowedProtocols
27
+
28
+ /** Schemes allowed for an image `src`. Adds `data:` on top of the link set: a
29
+ * base64 image never executes when loaded through `<img>` (unlike a `data:`
30
+ * hyperlink, which can carry `text/html`), and inline data images are a
31
+ * first-class editor feature (paste / upload). */
32
+ export const IMAGE_PROTOCOLS: readonly string[] = [...defaultAllowedProtocols, 'data']
33
+
34
+ /** Sanitize a hyperlink href. Returns the safe URL, or `null` when the scheme is
35
+ * not on {@link LINK_PROTOCOLS} (the link must then be dropped/unwrapped). */
36
+ export function sanitizeLinkUrl(url: string): string | null {
37
+ return sanitizeUrl(url, LINK_PROTOCOLS)
38
+ }
39
+
40
+ /** Sanitize an image `src`. Returns the safe URL, or `null` when the scheme is
41
+ * not on {@link IMAGE_PROTOCOLS} (the image must then be dropped). */
42
+ export function sanitizeImageUrl(url: string): string | null {
43
+ return sanitizeUrl(url, IMAGE_PROTOCOLS)
44
+ }
45
+
46
+ /** Neutralize one `LinkNode` in place (must run inside `editor.update`): rewrite a
47
+ * merely-normalized href, or — when the scheme is disallowed — UNWRAP the link so
48
+ * its visible text survives but the clickable `javascript:`/`data:` payload is
49
+ * gone. Returns true if the node was unwrapped/removed. */
50
+ function neutralizeLink(node: LinkNode): boolean {
51
+ const url = node.getURL()
52
+ const safe = sanitizeLinkUrl(url)
53
+ if (safe === null) {
54
+ for (const child of node.getChildren()) node.insertBefore(child)
55
+ node.remove()
56
+ return true
57
+ }
58
+ if (safe !== url) node.setURL(safe)
59
+ return false
60
+ }
61
+
62
+ /** Walk `node` (depth-first) neutralizing every unsafe `LinkNode` beneath it. Must
63
+ * run inside an `editor.update`. Use right after importing untrusted markdown
64
+ * (e.g. paste) into a detached scratch tree, before it reaches the live document. */
65
+ export function $sanitizeLinkNodes(node: LexicalNode): void {
66
+ if ($isLinkNode(node)) {
67
+ // Snapshot children first: unwrapping lifts them out of the (removed) link,
68
+ // so recurse the snapshot to catch a defensively-nested inner link either way.
69
+ const children = node.getChildren()
70
+ neutralizeLink(node)
71
+ for (const child of children) $sanitizeLinkNodes(child)
72
+ return
73
+ }
74
+ if ($isElementNode(node)) {
75
+ for (const child of node.getChildren()) $sanitizeLinkNodes(child)
76
+ }
77
+ }
78
+
79
+ /** Register a global `LinkNode` transform that keeps every link's href on the
80
+ * allowlist — the single choke point covering the link dialog, pasted/imported
81
+ * markdown, AND the typed `[text](url)` shortcut. Returns a disposer. A no-op when
82
+ * `LinkNode` isn't registered on the editor (no links are possible then, e.g. the
83
+ * single-block preset), since `registerNodeTransform` requires a registered node. */
84
+ export function registerLinkSanitizer(editor: LexicalEditor): () => void {
85
+ if (!editor.hasNodes([LinkNode])) return () => {}
86
+ return editor.registerNodeTransform(LinkNode, (node) => {
87
+ neutralizeLink(node)
88
+ })
89
+ }
package/src/state.ts ADDED
@@ -0,0 +1,220 @@
1
+ // The editor's TEA state, messages, effects, and pure reducer. Lexical owns the
2
+ // live document; this state holds JSON-serializable mirrors/derivations only, so
3
+ // `update` stays pure and DOM-free (fully unit-testable).
4
+
5
+ import type { Alignment } from '@llui/lexical'
6
+
7
+ /** The block kind at the selection — base rich-text kinds plus list/code,
8
+ * resolved by the markdown layer. */
9
+ export type BlockType =
10
+ | 'paragraph'
11
+ | 'h1'
12
+ | 'h2'
13
+ | 'h3'
14
+ | 'h4'
15
+ | 'h5'
16
+ | 'h6'
17
+ | 'quote'
18
+ | 'code'
19
+ | 'bullet'
20
+ | 'number'
21
+ | 'check'
22
+ | 'other'
23
+
24
+ /** The toolbar-facing format surface at the current selection (all primitives). */
25
+ export interface FormatState {
26
+ bold: boolean
27
+ italic: boolean
28
+ strikethrough: boolean
29
+ code: boolean
30
+ link: boolean
31
+ blockType: BlockType
32
+ alignment: Alignment
33
+ canUndo: boolean
34
+ canRedo: boolean
35
+ }
36
+
37
+ export const EMPTY_FORMAT: FormatState = {
38
+ bold: false,
39
+ italic: false,
40
+ strikethrough: false,
41
+ code: false,
42
+ link: false,
43
+ blockType: 'paragraph',
44
+ alignment: null,
45
+ canUndo: false,
46
+ canRedo: false,
47
+ }
48
+
49
+ /** Which floating surface is currently open. */
50
+ export type OverlayKind = 'none' | 'floating' | 'slash' | 'context' | 'link'
51
+
52
+ /** Live collaborative-session status (mirror of the CRDT provider state).
53
+ * `enabled` is false unless the editor was created with a `collab` factory. */
54
+ export interface CollabStatus {
55
+ enabled: boolean
56
+ connected: boolean
57
+ synced: boolean
58
+ /** Remote peers currently present (excludes this client). */
59
+ peers: number
60
+ }
61
+
62
+ export const COLLAB_OFF: CollabStatus = {
63
+ enabled: false,
64
+ connected: false,
65
+ synced: false,
66
+ peers: 0,
67
+ }
68
+
69
+ export interface EditorState {
70
+ /** Last serialized markdown (mirror of the live document). */
71
+ value: string
72
+ format: FormatState
73
+ wordCount: number
74
+ charCount: number
75
+ ui: {
76
+ activeOverlay: OverlayKind
77
+ slashQuery: string
78
+ menu: { x: number; y: number }
79
+ }
80
+ /** Per-plugin UI state slices, keyed by plugin name (see {@link PluginUI}). */
81
+ plugins: Record<string, unknown>
82
+ dirty: boolean
83
+ readonly: boolean
84
+ /** Collaborative-session status (always present; inert unless `collab` set). */
85
+ collab: CollabStatus
86
+ }
87
+
88
+ export type EditorMsg =
89
+ | { type: 'markdownChanged'; value: string }
90
+ | { type: 'formatChanged'; format: FormatState; wordCount: number; charCount: number }
91
+ | { type: 'runCommand'; id: string }
92
+ | { type: 'setValue'; value: string }
93
+ | { type: 'openOverlay'; overlay: OverlayKind; x?: number; y?: number }
94
+ | { type: 'closeOverlay' }
95
+ | { type: 'slashQuery'; query: string }
96
+ | { type: 'setReadOnly'; readonly: boolean }
97
+ | { type: 'collabStatus'; connected: boolean }
98
+ | { type: 'collabSync'; synced: boolean }
99
+ | { type: 'collabPeers'; peers: number }
100
+ /** Route a message to a plugin's UI reducer (see {@link PluginUI}). */
101
+ | { type: 'plugin'; name: string; msg: unknown }
102
+
103
+ /** The subset of messages a plugin may emit through its `PluginContext` (e.g. a
104
+ * `register` listener routing an editor event into its own plugin UI). */
105
+ export type EditorOutMsg = Extract<
106
+ EditorMsg,
107
+ { type: 'openOverlay' | 'closeOverlay' | 'slashQuery' | 'plugin' }
108
+ >
109
+
110
+ export type EditorEffect =
111
+ | { type: 'execCommand'; id: string }
112
+ | { type: 'applyValue'; value: string }
113
+ | { type: 'emitChange'; value: string }
114
+ | { type: 'emitFormat'; format: FormatState }
115
+ /** An effect produced by a plugin's UI reducer (see {@link PluginUI}). */
116
+ | { type: 'pluginEffect'; name: string; effect: unknown }
117
+
118
+ export interface InitOptions {
119
+ value: string
120
+ readonly: boolean
121
+ /** Whether a collaborative session is wired (drives `collab.enabled`). */
122
+ collab?: boolean
123
+ }
124
+
125
+ export function init(opts: InitOptions): [EditorState, EditorEffect[]] {
126
+ const wordCount = countWords(opts.value)
127
+ return [
128
+ {
129
+ value: opts.value,
130
+ format: EMPTY_FORMAT,
131
+ wordCount,
132
+ charCount: opts.value.length,
133
+ ui: { activeOverlay: 'none', slashQuery: '', menu: { x: 0, y: 0 } },
134
+ plugins: {},
135
+ dirty: false,
136
+ readonly: opts.readonly,
137
+ collab: { ...COLLAB_OFF, enabled: opts.collab ?? false },
138
+ },
139
+ [],
140
+ ]
141
+ }
142
+
143
+ export function update(state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]] {
144
+ switch (msg.type) {
145
+ case 'markdownChanged': {
146
+ // Idempotent: re-emitting the current value is a no-op (echo safety).
147
+ if (msg.value === state.value) return [state, []]
148
+ return [
149
+ { ...state, value: msg.value, dirty: true },
150
+ [{ type: 'emitChange', value: msg.value }],
151
+ ]
152
+ }
153
+ case 'formatChanged': {
154
+ return [
155
+ { ...state, format: msg.format, wordCount: msg.wordCount, charCount: msg.charCount },
156
+ [{ type: 'emitFormat', format: msg.format }],
157
+ ]
158
+ }
159
+ case 'runCommand': {
160
+ return [state, [{ type: 'execCommand', id: msg.id }]]
161
+ }
162
+ case 'setValue': {
163
+ // External markdown push (via the component handle). Idempotent; does not
164
+ // re-emit onChange (the consumer already owns this value).
165
+ if (msg.value === state.value) return [state, []]
166
+ return [
167
+ { ...state, value: msg.value, dirty: true },
168
+ [{ type: 'applyValue', value: msg.value }],
169
+ ]
170
+ }
171
+ case 'openOverlay': {
172
+ return [
173
+ {
174
+ ...state,
175
+ ui: {
176
+ ...state.ui,
177
+ activeOverlay: msg.overlay,
178
+ slashQuery: msg.overlay === 'slash' ? '' : state.ui.slashQuery,
179
+ menu: { x: msg.x ?? state.ui.menu.x, y: msg.y ?? state.ui.menu.y },
180
+ },
181
+ },
182
+ [],
183
+ ]
184
+ }
185
+ case 'closeOverlay': {
186
+ if (state.ui.activeOverlay === 'none') return [state, []]
187
+ return [{ ...state, ui: { ...state.ui, activeOverlay: 'none', slashQuery: '' } }, []]
188
+ }
189
+ case 'slashQuery': {
190
+ return [{ ...state, ui: { ...state.ui, slashQuery: msg.query } }, []]
191
+ }
192
+ case 'setReadOnly': {
193
+ if (state.readonly === msg.readonly) return [state, []]
194
+ return [{ ...state, readonly: msg.readonly }, []]
195
+ }
196
+ case 'collabStatus': {
197
+ if (state.collab.connected === msg.connected) return [state, []]
198
+ return [{ ...state, collab: { ...state.collab, connected: msg.connected } }, []]
199
+ }
200
+ case 'collabSync': {
201
+ if (state.collab.synced === msg.synced) return [state, []]
202
+ return [{ ...state, collab: { ...state.collab, synced: msg.synced } }, []]
203
+ }
204
+ case 'collabPeers': {
205
+ if (state.collab.peers === msg.peers) return [state, []]
206
+ return [{ ...state, collab: { ...state.collab, peers: msg.peers } }, []]
207
+ }
208
+ case 'plugin': {
209
+ // Plugin messages are routed by the host's composed reducer (it holds the
210
+ // plugin registry); the pure core reducer treats them as a no-op.
211
+ return [state, []]
212
+ }
213
+ }
214
+ }
215
+
216
+ /** Count whitespace-delimited words (shared by init and the format handler). */
217
+ export function countWords(text: string): number {
218
+ const trimmed = text.trim()
219
+ return trimmed === '' ? 0 : trimmed.split(/\s+/).length
220
+ }