@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.
- package/README.md +59 -0
- package/README.zh-CN.md +43 -0
- package/dist/AimdEditorTopBar.vue_vue_type_script_setup_true_lang-gbfMDZSh.js +1131 -0
- package/dist/AimdSourceEditor.vue_vue_type_script_setup_true_lang-t_sUoXky.js +274 -0
- package/dist/AimdWysiwygEditor.vue_vue_type_script_setup_true_lang-B8o1VbUH.js +25012 -0
- package/dist/aimd-editor.css +1 -0
- package/dist/embedded.js +11 -0
- package/dist/index.js +44 -0
- package/dist/monaco.js +16 -0
- package/dist/theme-B8dCnOx-.js +583 -0
- package/dist/vue.js +30 -0
- package/dist/wysiwyg.js +9 -0
- package/package.json +90 -0
- package/src/__tests__/editor.test.ts +296 -0
- package/src/embedded.ts +18 -0
- package/src/index.ts +10 -0
- package/src/language-config.ts +152 -0
- package/src/monaco.ts +19 -0
- package/src/theme.ts +166 -0
- package/src/tokens.ts +120 -0
- package/src/vue/AimdEditor.vue +715 -0
- package/src/vue/AimdEditorToolbar.vue +83 -0
- package/src/vue/AimdEditorTopBar.vue +39 -0
- package/src/vue/AimdFieldDialog.vue +1102 -0
- package/src/vue/AimdSourceEditor.vue +330 -0
- package/src/vue/AimdWysiwygEditor.vue +569 -0
- package/src/vue/aimdInlineMarkdownNormalization.ts +10 -0
- package/src/vue/comparableAimdMarkdown.ts +6 -0
- package/src/vue/env.d.ts +7 -0
- package/src/vue/index.ts +45 -0
- package/src/vue/locales.ts +667 -0
- package/src/vue/milkdown-aimd-plugin.ts +378 -0
- package/src/vue/programmaticMarkdownSyncGuard.ts +66 -0
- package/src/vue/types.ts +449 -0
- package/src/vue/useEditorContent.ts +252 -0
- 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
|
+
}
|