@airalogy/aimd-editor 1.7.1

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 (36) hide show
  1. package/README.md +59 -0
  2. package/README.zh-CN.md +43 -0
  3. package/dist/AimdEditorTopBar.vue_vue_type_script_setup_true_lang-gbfMDZSh.js +1131 -0
  4. package/dist/AimdSourceEditor.vue_vue_type_script_setup_true_lang-t_sUoXky.js +274 -0
  5. package/dist/AimdWysiwygEditor.vue_vue_type_script_setup_true_lang-B8o1VbUH.js +25012 -0
  6. package/dist/aimd-editor.css +1 -0
  7. package/dist/embedded.js +11 -0
  8. package/dist/index.js +44 -0
  9. package/dist/monaco.js +16 -0
  10. package/dist/theme-B8dCnOx-.js +583 -0
  11. package/dist/vue.js +30 -0
  12. package/dist/wysiwyg.js +9 -0
  13. package/package.json +90 -0
  14. package/src/__tests__/editor.test.ts +296 -0
  15. package/src/embedded.ts +18 -0
  16. package/src/index.ts +10 -0
  17. package/src/language-config.ts +152 -0
  18. package/src/monaco.ts +19 -0
  19. package/src/theme.ts +166 -0
  20. package/src/tokens.ts +120 -0
  21. package/src/vue/AimdEditor.vue +715 -0
  22. package/src/vue/AimdEditorToolbar.vue +83 -0
  23. package/src/vue/AimdEditorTopBar.vue +39 -0
  24. package/src/vue/AimdFieldDialog.vue +1102 -0
  25. package/src/vue/AimdSourceEditor.vue +330 -0
  26. package/src/vue/AimdWysiwygEditor.vue +569 -0
  27. package/src/vue/aimdInlineMarkdownNormalization.ts +10 -0
  28. package/src/vue/comparableAimdMarkdown.ts +6 -0
  29. package/src/vue/env.d.ts +7 -0
  30. package/src/vue/index.ts +45 -0
  31. package/src/vue/locales.ts +667 -0
  32. package/src/vue/milkdown-aimd-plugin.ts +378 -0
  33. package/src/vue/programmaticMarkdownSyncGuard.ts +66 -0
  34. package/src/vue/types.ts +449 -0
  35. package/src/vue/useEditorContent.ts +252 -0
  36. package/src/wysiwyg.ts +17 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Milkdown plugin for AIMD inline nodes.
3
+ *
4
+ * Parses `{{type|content}}` syntax in markdown and renders them as
5
+ * styled inline chips in the WYSIWYG editor. The chips are editable
6
+ * via a click-to-edit popover.
7
+ *
8
+ * Also provides an InputRule so typing `{{` triggers the node creation flow.
9
+ */
10
+ import type { Ctx, MilkdownPlugin } from '@milkdown/kit/ctx'
11
+ import type { NodeSchema, MarkdownNode } from '@milkdown/kit/transformer'
12
+ import type { Node as ProsemirrorNode } from '@milkdown/kit/prose/model'
13
+ import type { EditorView, NodeView } from '@milkdown/kit/prose/view'
14
+ import { restoreAimdInlineTemplates } from '@airalogy/aimd-core'
15
+ import { hardbreakAttr, hardbreakSchema } from '@milkdown/kit/preset/commonmark'
16
+ import { $node, $view, $remark, $inputRule } from '@milkdown/kit/utils'
17
+ import { InputRule } from '@milkdown/kit/prose/inputrules'
18
+
19
+ // ─── AIMD field type colors (matches types.ts) ───
20
+ const AIMD_COLORS: Record<string, string> = {
21
+ var: '#2563eb',
22
+ var_table: '#059669',
23
+ step: '#d97706',
24
+ check: '#dc2626',
25
+ ref_step: '#0891b2',
26
+ ref_var: '#0891b2',
27
+ ref_fig: '#0891b2',
28
+ cite: '#6d28d9',
29
+ }
30
+
31
+ const AIMD_LABELS: Record<string, string> = {
32
+ var: 'var',
33
+ var_table: 'var_table',
34
+ step: 'step',
35
+ check: 'check',
36
+ ref_step: 'ref_step',
37
+ ref_var: 'ref_var',
38
+ ref_fig: 'ref_fig',
39
+ cite: 'cite',
40
+ }
41
+
42
+ function getColor(fieldType: string): string {
43
+ return AIMD_COLORS[fieldType] || '#6b7280'
44
+ }
45
+
46
+ // ─── 1. Remark plugin: parse {{type|content}} into custom MDAST nodes ───
47
+
48
+ function remarkAimdInline() {
49
+ const AIMD_RE = /\{\{(\w+)\|([^}]*)\}\}/g
50
+
51
+ function transformer(tree: any) {
52
+ visitNode(tree)
53
+ }
54
+
55
+ function visitNode(node: any) {
56
+ if (
57
+ typeof node.value === 'string'
58
+ && (node.type === 'text' || node.type === 'code' || node.type === 'inlineCode' || node.type === 'html')
59
+ ) {
60
+ node.value = restoreAimdInlineTemplates(node.value)
61
+ }
62
+
63
+ if (node.type === 'text' && typeof node.value === 'string') {
64
+ const value: string = node.value
65
+ AIMD_RE.lastIndex = 0
66
+ if (!AIMD_RE.test(value)) return
67
+
68
+ // Split text around AIMD fields
69
+ AIMD_RE.lastIndex = 0
70
+ const children: any[] = []
71
+ let lastIndex = 0
72
+ let match: RegExpExecArray | null
73
+
74
+ while ((match = AIMD_RE.exec(value)) !== null) {
75
+ if (match.index > lastIndex) {
76
+ children.push({ type: 'text', value: value.slice(lastIndex, match.index) })
77
+ }
78
+ children.push({
79
+ type: 'aimdField',
80
+ data: {
81
+ hName: 'aimd-field',
82
+ hProperties: { fieldType: match[1], fieldContent: match[2] },
83
+ },
84
+ fieldType: match[1],
85
+ fieldContent: match[2],
86
+ })
87
+ lastIndex = match.index + match[0].length
88
+ }
89
+
90
+ if (lastIndex < value.length) {
91
+ children.push({ type: 'text', value: value.slice(lastIndex) })
92
+ }
93
+
94
+ if (children.length > 0) {
95
+ // Replace this text node with the split children
96
+ node._aimdChildren = children
97
+ }
98
+ }
99
+
100
+ // Recurse into children
101
+ if (node.children) {
102
+ const newChildren: any[] = []
103
+ for (const child of node.children) {
104
+ visitNode(child)
105
+ if (child._aimdChildren) {
106
+ newChildren.push(...child._aimdChildren)
107
+ delete child._aimdChildren
108
+ } else {
109
+ newChildren.push(child)
110
+ }
111
+ }
112
+ node.children = newChildren
113
+ }
114
+ }
115
+
116
+ return transformer
117
+ }
118
+
119
+ // ─── 2. $remark plugin registration ───
120
+
121
+ export const aimdRemarkPlugin = $remark('aimdInline', () => remarkAimdInline)
122
+
123
+ // ─── 3. $node: ProseMirror node schema + markdown parser/serializer ───
124
+
125
+ export const aimdFieldNode = $node('aimd_field', () => ({
126
+ group: 'inline',
127
+ inline: true,
128
+ atom: true,
129
+ attrs: {
130
+ fieldType: { default: 'var' },
131
+ fieldContent: { default: '' },
132
+ },
133
+ parseDOM: [{
134
+ tag: 'aimd-field',
135
+ getAttrs: (dom: HTMLElement) => ({
136
+ fieldType: (dom as HTMLElement).getAttribute('data-field-type') || 'var',
137
+ fieldContent: (dom as HTMLElement).getAttribute('data-field-content') || '',
138
+ }),
139
+ }],
140
+ toDOM: (node: ProsemirrorNode) => ['aimd-field', {
141
+ 'data-field-type': node.attrs.fieldType,
142
+ 'data-field-content': node.attrs.fieldContent,
143
+ class: 'aimd-field-inline',
144
+ }],
145
+ parseMarkdown: {
146
+ match: (mdNode: MarkdownNode) => mdNode.type === 'aimdField',
147
+ runner: (state, mdNode, proseType) => {
148
+ state.addNode(proseType, {
149
+ fieldType: (mdNode as any).fieldType || 'var',
150
+ fieldContent: (mdNode as any).fieldContent || '',
151
+ })
152
+ },
153
+ },
154
+ toMarkdown: {
155
+ match: (node: ProsemirrorNode) => node.type.name === 'aimd_field',
156
+ runner: (state, node) => {
157
+ state.addNode('text', undefined, `{{${node.attrs.fieldType}|${node.attrs.fieldContent}}}`)
158
+ },
159
+ },
160
+ } as NodeSchema))
161
+
162
+ // ─── 4. $view: Custom NodeView for rendering AIMD fields as styled chips ───
163
+
164
+ class AimdFieldNodeView implements NodeView {
165
+ dom: HTMLElement
166
+ private node: ProsemirrorNode
167
+ private view: EditorView
168
+ private getPos: () => number | undefined
169
+ private editing = false
170
+ private labelEl: HTMLElement
171
+ private contentEl: HTMLElement
172
+
173
+ constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number | undefined) {
174
+ this.node = node
175
+ this.view = view
176
+ this.getPos = getPos
177
+
178
+ const fieldType = node.attrs.fieldType as string
179
+ const fieldContent = node.attrs.fieldContent as string
180
+ const color = getColor(fieldType)
181
+
182
+ // Create DOM
183
+ this.dom = document.createElement('span')
184
+ this.dom.className = 'aimd-field-chip'
185
+ this.dom.setAttribute('data-field-type', fieldType)
186
+ this.dom.contentEditable = 'false'
187
+ this.dom.style.cssText = `
188
+ display: inline-flex;
189
+ align-items: center;
190
+ gap: 3px;
191
+ padding: 1px 8px 1px 6px;
192
+ border-radius: 4px;
193
+ font-size: 13px;
194
+ font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
195
+ line-height: 1.6;
196
+ cursor: pointer;
197
+ user-select: none;
198
+ vertical-align: baseline;
199
+ border: 1px solid ${color}33;
200
+ background: ${color}0d;
201
+ color: ${color};
202
+ transition: all 0.15s;
203
+ `
204
+
205
+ // Type label
206
+ this.labelEl = document.createElement('span')
207
+ this.labelEl.className = 'aimd-field-chip-label'
208
+ this.labelEl.textContent = AIMD_LABELS[fieldType] || fieldType
209
+ this.labelEl.style.cssText = `
210
+ font-weight: 600;
211
+ font-size: 11px;
212
+ opacity: 0.7;
213
+ margin-right: 2px;
214
+ `
215
+
216
+ // Content
217
+ this.contentEl = document.createElement('span')
218
+ this.contentEl.className = 'aimd-field-chip-content'
219
+ this.contentEl.textContent = fieldContent
220
+ this.contentEl.style.cssText = `
221
+ font-weight: 500;
222
+ `
223
+
224
+ this.dom.appendChild(this.labelEl)
225
+ this.dom.appendChild(this.contentEl)
226
+
227
+ // Hover effect
228
+ this.dom.addEventListener('mouseenter', () => {
229
+ this.dom.style.background = `${color}1a`
230
+ this.dom.style.borderColor = `${color}66`
231
+ })
232
+ this.dom.addEventListener('mouseleave', () => {
233
+ if (!this.editing) {
234
+ this.dom.style.background = `${color}0d`
235
+ this.dom.style.borderColor = `${color}33`
236
+ }
237
+ })
238
+
239
+ // Click to edit
240
+ this.dom.addEventListener('click', (e) => {
241
+ e.preventDefault()
242
+ e.stopPropagation()
243
+ this.startEditing()
244
+ })
245
+ }
246
+
247
+ private startEditing() {
248
+ if (this.editing) return
249
+ this.editing = true
250
+
251
+ const fieldType = this.node.attrs.fieldType as string
252
+ const fieldContent = this.node.attrs.fieldContent as string
253
+ const color = getColor(fieldType)
254
+
255
+ // Make content editable
256
+ this.contentEl.contentEditable = 'true'
257
+ this.contentEl.style.outline = 'none'
258
+ this.contentEl.style.minWidth = '30px'
259
+ this.contentEl.style.borderBottom = `1px dashed ${color}`
260
+ this.dom.style.background = `${color}1a`
261
+ this.dom.style.borderColor = `${color}66`
262
+
263
+ // Focus and select all
264
+ this.contentEl.focus()
265
+ const range = document.createRange()
266
+ range.selectNodeContents(this.contentEl)
267
+ const sel = window.getSelection()
268
+ sel?.removeAllRanges()
269
+ sel?.addRange(range)
270
+
271
+ // Handle blur = commit
272
+ const commit = () => {
273
+ this.editing = false
274
+ const newContent = this.contentEl.textContent || ''
275
+ this.contentEl.contentEditable = 'false'
276
+ this.contentEl.style.borderBottom = 'none'
277
+ this.dom.style.background = `${color}0d`
278
+ this.dom.style.borderColor = `${color}33`
279
+
280
+ if (newContent !== fieldContent) {
281
+ const pos = this.getPos()
282
+ if (pos !== undefined) {
283
+ const tr = this.view.state.tr.setNodeMarkup(pos, undefined, {
284
+ ...this.node.attrs,
285
+ fieldContent: newContent,
286
+ })
287
+ this.view.dispatch(tr)
288
+ }
289
+ }
290
+ }
291
+
292
+ this.contentEl.addEventListener('blur', commit, { once: true })
293
+ this.contentEl.addEventListener('keydown', (e) => {
294
+ if (e.key === 'Enter') {
295
+ e.preventDefault()
296
+ this.contentEl.blur()
297
+ }
298
+ if (e.key === 'Escape') {
299
+ e.preventDefault()
300
+ this.contentEl.textContent = fieldContent
301
+ this.contentEl.blur()
302
+ }
303
+ })
304
+ }
305
+
306
+ update(node: ProsemirrorNode): boolean {
307
+ if (node.type.name !== 'aimd_field') return false
308
+ this.node = node
309
+ const fieldType = node.attrs.fieldType as string
310
+ const fieldContent = node.attrs.fieldContent as string
311
+ const color = getColor(fieldType)
312
+
313
+ this.labelEl.textContent = AIMD_LABELS[fieldType] || fieldType
314
+ if (!this.editing) {
315
+ this.contentEl.textContent = fieldContent
316
+ }
317
+ this.dom.setAttribute('data-field-type', fieldType)
318
+ this.dom.style.borderColor = `${color}33`
319
+ this.dom.style.background = `${color}0d`
320
+ this.dom.style.color = color
321
+ return true
322
+ }
323
+
324
+ stopEvent(event: Event): boolean {
325
+ // Allow events inside the chip when editing
326
+ if (this.editing) return true
327
+ if (event.type === 'click') return true
328
+ return false
329
+ }
330
+
331
+ ignoreMutation(): boolean {
332
+ return true
333
+ }
334
+
335
+ destroy() {
336
+ // cleanup
337
+ }
338
+ }
339
+
340
+ export const aimdFieldView = $view(aimdFieldNode, () => {
341
+ return (node, view, getPos) => new AimdFieldNodeView(node, view, getPos)
342
+ })
343
+
344
+ // ─── 5. Schema override: render inline hardbreak (`\n`) as <br> in WYSIWYG ───
345
+ // Use schema-level toDOM override for stable rendering across editor view init/order.
346
+ export const inlineHardbreakSchema = hardbreakSchema.extendSchema((prev) => {
347
+ return (ctx) => {
348
+ const schema = prev(ctx)
349
+ return {
350
+ ...schema,
351
+ toDOM: (node: ProsemirrorNode) => ['br', ctx.get(hardbreakAttr.key)(node)],
352
+ } as NodeSchema
353
+ }
354
+ })
355
+
356
+ // ─── 6. $inputRule: typing `{{type|content}}` creates an AIMD field node ───
357
+
358
+ export const aimdFieldInputRule = $inputRule((ctx) => {
359
+ return new InputRule(
360
+ /\{\{(\w+)\|([^}]*)\}\}$/,
361
+ (state, match, start, end) => {
362
+ const [, fieldType, fieldContent] = match
363
+ const nodeType = aimdFieldNode.type(ctx)
364
+ const node = nodeType.create({ fieldType, fieldContent })
365
+ return state.tr.replaceRangeWith(start, end, node)
366
+ }
367
+ )
368
+ })
369
+
370
+ // ─── 7. Combined plugin list for easy use ───
371
+
372
+ export const aimdMilkdownPlugins: MilkdownPlugin[] = [
373
+ aimdRemarkPlugin,
374
+ aimdFieldNode,
375
+ aimdFieldView,
376
+ inlineHardbreakSchema,
377
+ aimdFieldInputRule,
378
+ ].flat()
@@ -0,0 +1,66 @@
1
+ const DEFAULT_RETENTION_MS = 1000
2
+
3
+ export interface ProgrammaticMarkdownSyncGuard {
4
+ track(markdown: string): void
5
+ consume(markdown: string): boolean
6
+ clear(): void
7
+ }
8
+
9
+ export function createProgrammaticMarkdownSyncGuard(
10
+ retentionMs = DEFAULT_RETENTION_MS,
11
+ ): ProgrammaticMarkdownSyncGuard {
12
+ const pendingCounts = new Map<string, number>()
13
+ const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
14
+
15
+ function clearTimer(markdown: string) {
16
+ const timer = cleanupTimers.get(markdown)
17
+ if (!timer) {
18
+ return
19
+ }
20
+
21
+ clearTimeout(timer)
22
+ cleanupTimers.delete(markdown)
23
+ }
24
+
25
+ function drop(markdown: string) {
26
+ pendingCounts.delete(markdown)
27
+ clearTimer(markdown)
28
+ }
29
+
30
+ function scheduleCleanup(markdown: string) {
31
+ clearTimer(markdown)
32
+ cleanupTimers.set(markdown, setTimeout(() => {
33
+ drop(markdown)
34
+ }, retentionMs))
35
+ }
36
+
37
+ return {
38
+ track(markdown: string) {
39
+ pendingCounts.set(markdown, (pendingCounts.get(markdown) ?? 0) + 1)
40
+ scheduleCleanup(markdown)
41
+ },
42
+ consume(markdown: string) {
43
+ const count = pendingCounts.get(markdown) ?? 0
44
+ if (count <= 0) {
45
+ return false
46
+ }
47
+
48
+ if (count === 1) {
49
+ drop(markdown)
50
+ } else {
51
+ pendingCounts.set(markdown, count - 1)
52
+ scheduleCleanup(markdown)
53
+ }
54
+
55
+ return true
56
+ },
57
+ clear() {
58
+ for (const timer of cleanupTimers.values()) {
59
+ clearTimeout(timer)
60
+ }
61
+
62
+ cleanupTimers.clear()
63
+ pendingCounts.clear()
64
+ },
65
+ }
66
+ }