@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,1102 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue'
3
+ import type { AimdEditorMessages } from './locales'
4
+ import {
5
+ createAimdFieldTypes,
6
+ createAimdVarTypePresets,
7
+ getDefaultAimdFields,
8
+ buildAimdSyntax,
9
+ type AimdFieldType,
10
+ type AimdVarTypePresetOption,
11
+ } from './types'
12
+
13
+ const props = defineProps<{
14
+ visible: boolean
15
+ initialType?: string
16
+ messages: AimdEditorMessages
17
+ refSuggestions?: string[]
18
+ varTypePlugins?: AimdVarTypePresetOption[]
19
+ allowedTypes?: string[]
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ (e: 'update:visible', val: boolean): void
24
+ (e: 'insert', syntax: string): void
25
+ }>()
26
+
27
+ const localizedFieldTypes = computed(() => {
28
+ const allTypes = createAimdFieldTypes(props.messages)
29
+ if (!props.allowedTypes || props.allowedTypes.length === 0) {
30
+ return allTypes
31
+ }
32
+
33
+ const allowed = new Set(props.allowedTypes)
34
+ return allTypes.filter(fieldType => allowed.has(fieldType.type))
35
+ })
36
+
37
+ function resolveDialogType(type?: string): string {
38
+ if (type && localizedFieldTypes.value.some(fieldType => fieldType.type === type)) {
39
+ return type
40
+ }
41
+
42
+ return localizedFieldTypes.value[0]?.type ?? type ?? 'var'
43
+ }
44
+
45
+ const dialogType = ref(resolveDialogType(props.initialType))
46
+ const fields = ref<Record<string, string>>(getDefaultAimdFields(dialogType.value, props.messages))
47
+
48
+ interface ChoiceOptionItem {
49
+ key: string
50
+ text: string
51
+ }
52
+
53
+ interface BlankItem {
54
+ key: string
55
+ answer: string
56
+ }
57
+
58
+ const quizChoiceOptions = ref<ChoiceOptionItem[]>([])
59
+ const quizBlankItems = ref<BlankItem[]>([])
60
+ const quizMultipleAnswers = ref<string[]>([])
61
+ const draggingChoiceIndex = ref<number | null>(null)
62
+ const dragOverChoiceIndex = ref<number | null>(null)
63
+ const formError = ref('')
64
+ const varTypePresets = computed<AimdVarTypePresetOption[]>(() =>
65
+ createAimdVarTypePresets(props.messages, props.varTypePlugins ?? []),
66
+ )
67
+
68
+ function normalizeVarTypeToken(value: string | undefined): string {
69
+ return (value || '').trim().toLowerCase().replace(/[\s_-]/g, '')
70
+ }
71
+
72
+ function isVarTypePresetActive(value: string): boolean {
73
+ return normalizeVarTypeToken(fields.value.type) === normalizeVarTypeToken(value)
74
+ }
75
+
76
+ function selectVarTypePreset(value: string) {
77
+ fields.value.type = value
78
+ }
79
+
80
+ function parseChoiceOptions(input: string): ChoiceOptionItem[] {
81
+ const parts = input.split(',').map(s => s.trim()).filter(Boolean)
82
+ if (parts.length === 0) {
83
+ return [
84
+ { key: 'A', text: props.messages.defaults.optionText('A') },
85
+ { key: 'B', text: props.messages.defaults.optionText('B') },
86
+ ]
87
+ }
88
+
89
+ return parts.map((part, index) => {
90
+ const sepIndex = part.indexOf(':')
91
+ if (sepIndex > 0) {
92
+ const key = part.slice(0, sepIndex).trim() || String.fromCharCode(65 + index)
93
+ const text = part.slice(sepIndex + 1).trim() || props.messages.defaults.optionText(key)
94
+ return { key, text }
95
+ }
96
+ const key = String.fromCharCode(65 + index)
97
+ return { key, text: part }
98
+ })
99
+ }
100
+
101
+ function serializeChoiceOptions(items: ChoiceOptionItem[]): string {
102
+ const normalized = items
103
+ .map(item => ({ key: item.key.trim(), text: item.text.trim() }))
104
+ .filter(item => item.key && item.text)
105
+
106
+ if (normalized.length === 0) {
107
+ return `A:${props.messages.defaults.optionText('A')}, B:${props.messages.defaults.optionText('B')}`
108
+ }
109
+
110
+ return normalized.map(item => `${item.key}:${item.text}`).join(', ')
111
+ }
112
+
113
+ function parseBlankItems(input: string): BlankItem[] {
114
+ const parts = input.split(',').map(s => s.trim()).filter(Boolean)
115
+ if (parts.length === 0) {
116
+ return [{ key: 'b1', answer: '21%' }]
117
+ }
118
+
119
+ return parts.map((part, index) => {
120
+ const sepIndex = part.indexOf(':')
121
+ if (sepIndex > 0) {
122
+ const key = part.slice(0, sepIndex).trim() || `b${index + 1}`
123
+ const answer = part.slice(sepIndex + 1).trim() || ''
124
+ return { key, answer }
125
+ }
126
+ return { key: `b${index + 1}`, answer: part }
127
+ })
128
+ }
129
+
130
+ function serializeBlankItems(items: BlankItem[]): string {
131
+ const normalized = items
132
+ .map(item => ({ key: item.key.trim(), answer: item.answer.trim() }))
133
+ .filter(item => item.key)
134
+
135
+ if (normalized.length === 0) {
136
+ return 'b1:21%'
137
+ }
138
+
139
+ return normalized.map(item => `${item.key}:${item.answer}`).join(', ')
140
+ }
141
+
142
+ function parseAnswerKeys(input: string): string[] {
143
+ return input
144
+ .split(',')
145
+ .map(s => s.trim())
146
+ .filter(Boolean)
147
+ }
148
+
149
+ function hydrateQuizDraftsFromFields() {
150
+ if (dialogType.value !== 'quiz') return
151
+
152
+ if (fields.value.quizType === 'choice') {
153
+ quizChoiceOptions.value = parseChoiceOptions(fields.value.options || '')
154
+ quizMultipleAnswers.value = parseAnswerKeys(fields.value.answer || '')
155
+ }
156
+ else if (fields.value.quizType === 'blank') {
157
+ quizBlankItems.value = parseBlankItems(fields.value.blanks || '')
158
+ }
159
+ }
160
+
161
+ function ensureChoiceAnswersValid() {
162
+ if (dialogType.value !== 'quiz' || fields.value.quizType !== 'choice') return
163
+
164
+ const optionKeys = new Set(
165
+ quizChoiceOptions.value
166
+ .map(option => option.key.trim())
167
+ .filter(Boolean),
168
+ )
169
+
170
+ if (fields.value.mode === 'multiple') {
171
+ const normalized = quizMultipleAnswers.value
172
+ .map(key => key.trim())
173
+ .filter(key => key && optionKeys.has(key))
174
+ if (normalized.length !== quizMultipleAnswers.value.length) {
175
+ quizMultipleAnswers.value = normalized
176
+ }
177
+ }
178
+ else {
179
+ const answer = (fields.value.answer || '').trim()
180
+ if (answer && !optionKeys.has(answer)) {
181
+ fields.value.answer = ''
182
+ }
183
+ }
184
+ }
185
+
186
+ function nextChoiceKey(): string {
187
+ const used = new Set(
188
+ quizChoiceOptions.value
189
+ .map(option => option.key.trim().toUpperCase())
190
+ .filter(Boolean),
191
+ )
192
+
193
+ for (let i = 0; i < 26; i++) {
194
+ const candidate = String.fromCharCode(65 + i)
195
+ if (!used.has(candidate)) return candidate
196
+ }
197
+ return `K${quizChoiceOptions.value.length + 1}`
198
+ }
199
+
200
+ function addChoiceOption() {
201
+ const key = nextChoiceKey()
202
+ quizChoiceOptions.value.push({ key, text: props.messages.defaults.optionText(key) })
203
+ }
204
+
205
+ function removeChoiceOption(index: number) {
206
+ if (quizChoiceOptions.value.length <= 1) return
207
+ quizChoiceOptions.value.splice(index, 1)
208
+ }
209
+
210
+ function startChoiceDrag(index: number) {
211
+ draggingChoiceIndex.value = index
212
+ dragOverChoiceIndex.value = index
213
+ }
214
+
215
+ function onChoiceDragOver(index: number) {
216
+ if (draggingChoiceIndex.value === null) return
217
+ dragOverChoiceIndex.value = index
218
+ }
219
+
220
+ function dropChoiceAt(index: number) {
221
+ const from = draggingChoiceIndex.value
222
+ if (from === null || from === index) {
223
+ draggingChoiceIndex.value = null
224
+ dragOverChoiceIndex.value = null
225
+ return
226
+ }
227
+
228
+ const moved = quizChoiceOptions.value.splice(from, 1)[0]
229
+ quizChoiceOptions.value.splice(index, 0, moved)
230
+ draggingChoiceIndex.value = null
231
+ dragOverChoiceIndex.value = null
232
+ }
233
+
234
+ function endChoiceDrag() {
235
+ draggingChoiceIndex.value = null
236
+ dragOverChoiceIndex.value = null
237
+ }
238
+
239
+ function nextBlankKey(): string {
240
+ const used = new Set(
241
+ quizBlankItems.value
242
+ .map(item => item.key.trim().toLowerCase())
243
+ .filter(Boolean),
244
+ )
245
+ let index = 1
246
+ while (used.has(`b${index}`)) {
247
+ index += 1
248
+ }
249
+ return `b${index}`
250
+ }
251
+
252
+ function addBlankItem() {
253
+ quizBlankItems.value.push({ key: nextBlankKey(), answer: '' })
254
+ }
255
+
256
+ function removeBlankItem(index: number) {
257
+ if (quizBlankItems.value.length <= 1) return
258
+ quizBlankItems.value.splice(index, 1)
259
+ }
260
+
261
+ function collectDuplicateValues(values: string[]): string[] {
262
+ const seen = new Set<string>()
263
+ const duplicates: string[] = []
264
+ for (const value of values) {
265
+ if (seen.has(value) && !duplicates.includes(value)) {
266
+ duplicates.push(value)
267
+ }
268
+ seen.add(value)
269
+ }
270
+ return duplicates
271
+ }
272
+
273
+ function extractBlankPlaceholders(stem: string): string[] {
274
+ const keys: string[] = []
275
+ const pattern = /\[\[([^\[\]\s]+)\]\]/g
276
+ for (const match of stem.matchAll(pattern)) {
277
+ keys.push(match[1])
278
+ }
279
+ return keys
280
+ }
281
+
282
+ function validateBlankQuizBeforeInsert(): string | null {
283
+ const blankKeys = quizBlankItems.value
284
+ .map(item => item.key.trim())
285
+ .filter(Boolean)
286
+ if (blankKeys.length === 0) {
287
+ return props.messages.errors.blankQuizRequiresBlankKey
288
+ }
289
+
290
+ const duplicateBlankKeys = collectDuplicateValues(blankKeys)
291
+ if (duplicateBlankKeys.length > 0) {
292
+ return props.messages.errors.blankKeysMustBeUnique(duplicateBlankKeys)
293
+ }
294
+
295
+ const stem = fields.value.stem || ''
296
+ const placeholderKeys = extractBlankPlaceholders(stem)
297
+ if (placeholderKeys.length === 0) {
298
+ return props.messages.errors.blankStemRequiresPlaceholders
299
+ }
300
+
301
+ const duplicatePlaceholders = collectDuplicateValues(placeholderKeys)
302
+ if (duplicatePlaceholders.length > 0) {
303
+ return props.messages.errors.duplicatePlaceholders(duplicatePlaceholders)
304
+ }
305
+
306
+ const blankKeySet = new Set(blankKeys)
307
+ const placeholderSet = new Set(placeholderKeys)
308
+ const unknownPlaceholders = [...placeholderSet].filter(key => !blankKeySet.has(key))
309
+ if (unknownPlaceholders.length > 0) {
310
+ return props.messages.errors.undefinedPlaceholders(unknownPlaceholders)
311
+ }
312
+
313
+ const missingPlaceholders = [...blankKeySet].filter(key => !placeholderSet.has(key))
314
+ if (missingPlaceholders.length > 0) {
315
+ return props.messages.errors.missingPlaceholders(missingPlaceholders)
316
+ }
317
+
318
+ return null
319
+ }
320
+
321
+ function validateBeforeInsert(): string | null {
322
+ if (dialogType.value !== 'quiz') return null
323
+ if (fields.value.quizType !== 'blank') return null
324
+ return validateBlankQuizBeforeInsert()
325
+ }
326
+
327
+ watch(() => props.initialType, (t) => {
328
+ const resolvedType = resolveDialogType(t)
329
+ dialogType.value = resolvedType
330
+ fields.value = getDefaultAimdFields(resolvedType, props.messages)
331
+ hydrateQuizDraftsFromFields()
332
+ formError.value = ''
333
+ })
334
+
335
+ watch(localizedFieldTypes, () => {
336
+ const resolvedType = resolveDialogType(dialogType.value)
337
+ if (resolvedType === dialogType.value) {
338
+ return
339
+ }
340
+
341
+ dialogType.value = resolvedType
342
+ fields.value = getDefaultAimdFields(resolvedType, props.messages)
343
+ hydrateQuizDraftsFromFields()
344
+ formError.value = ''
345
+ })
346
+
347
+ watch(() => props.visible, (v) => {
348
+ if (v) {
349
+ fields.value = getDefaultAimdFields(dialogType.value, props.messages)
350
+ hydrateQuizDraftsFromFields()
351
+ formError.value = ''
352
+ }
353
+ })
354
+
355
+ function switchType(type: string) {
356
+ const resolvedType = resolveDialogType(type)
357
+ dialogType.value = resolvedType
358
+ fields.value = getDefaultAimdFields(resolvedType, props.messages)
359
+ hydrateQuizDraftsFromFields()
360
+ formError.value = ''
361
+ }
362
+
363
+ const preview = computed(() => buildAimdSyntax(dialogType.value, fields.value, props.messages))
364
+
365
+ function getTypeInfo(type: string): AimdFieldType {
366
+ return localizedFieldTypes.value.find(f => f.type === type) || { type, label: type, icon: '?', svgIcon: '', desc: '', color: '#666' }
367
+ }
368
+
369
+ const currentType = computed(() => getTypeInfo(dialogType.value))
370
+
371
+ const suggestions = computed(() => {
372
+ if (!props.refSuggestions) return []
373
+ return props.refSuggestions
374
+ })
375
+
376
+ const referencedLabel = computed(() => {
377
+ if (dialogType.value === 'ref_step') return props.messages.dialog.referencedStepId
378
+ if (dialogType.value === 'ref_var') return props.messages.dialog.referencedVariableId
379
+ return props.messages.dialog.referencedFigureId
380
+ })
381
+
382
+ const referencedPlaceholder = computed(() => {
383
+ if (dialogType.value === 'ref_step') return 'step_id'
384
+ if (dialogType.value === 'ref_var') return 'var_id'
385
+ return 'fig_id'
386
+ })
387
+
388
+ watch(() => [dialogType.value, fields.value.quizType], ([type, quizType], [prevType, prevQuizType]) => {
389
+ if (type !== 'quiz') return
390
+ if (type !== prevType || quizType !== prevQuizType) {
391
+ hydrateQuizDraftsFromFields()
392
+ }
393
+ })
394
+
395
+ watch(() => fields.value.mode, () => {
396
+ ensureChoiceAnswersValid()
397
+ })
398
+
399
+ watch(quizChoiceOptions, (items) => {
400
+ if (dialogType.value === 'quiz' && fields.value.quizType === 'choice') {
401
+ fields.value.options = serializeChoiceOptions(items)
402
+ ensureChoiceAnswersValid()
403
+ }
404
+ }, { deep: true })
405
+
406
+ watch(quizBlankItems, (items) => {
407
+ if (dialogType.value === 'quiz' && fields.value.quizType === 'blank') {
408
+ fields.value.blanks = serializeBlankItems(items)
409
+ }
410
+ }, { deep: true })
411
+
412
+ watch(quizMultipleAnswers, (answers) => {
413
+ if (dialogType.value === 'quiz' && fields.value.quizType === 'choice' && fields.value.mode === 'multiple') {
414
+ fields.value.answer = answers.join(', ')
415
+ }
416
+ }, { deep: true })
417
+
418
+ watch(fields, () => {
419
+ if (formError.value) {
420
+ formError.value = ''
421
+ }
422
+ }, { deep: true })
423
+
424
+ watch(quizBlankItems, () => {
425
+ if (formError.value) {
426
+ formError.value = ''
427
+ }
428
+ }, { deep: true })
429
+
430
+ function doInsert() {
431
+ const validationError = validateBeforeInsert()
432
+ if (validationError) {
433
+ formError.value = validationError
434
+ return
435
+ }
436
+
437
+ formError.value = ''
438
+ emit('insert', buildAimdSyntax(dialogType.value, fields.value, props.messages))
439
+ emit('update:visible', false)
440
+ }
441
+
442
+ function close() {
443
+ emit('update:visible', false)
444
+ }
445
+ </script>
446
+
447
+ <template>
448
+ <Teleport to="body">
449
+ <div v-if="visible" class="aimd-dialog-overlay" @click.self="close">
450
+ <div class="aimd-dialog">
451
+ <div class="aimd-dialog-header">
452
+ <span class="aimd-dialog-title">
453
+ <span class="aimd-dialog-icon" :style="{ color: currentType.color }" v-html="currentType.svgIcon" />
454
+ {{ props.messages.dialog.title(currentType.label) }}
455
+ </span>
456
+ <button class="aimd-dialog-close" @click="close">&times;</button>
457
+ </div>
458
+
459
+ <!-- Type selector tabs -->
460
+ <div class="aimd-dialog-type-tabs">
461
+ <button
462
+ v-for="ft in localizedFieldTypes"
463
+ :key="ft.type"
464
+ :class="['aimd-type-tab', { active: dialogType === ft.type }]"
465
+ :style="dialogType === ft.type ? { borderColor: ft.color, color: ft.color, background: `${ft.color}14` } : {}"
466
+ @click="switchType(ft.type)"
467
+ >
468
+ <span v-html="ft.svgIcon" class="aimd-type-tab-icon" /> {{ ft.label }}
469
+ </button>
470
+ </div>
471
+
472
+ <div class="aimd-dialog-body">
473
+ <!-- var fields -->
474
+ <template v-if="dialogType === 'var'">
475
+ <label class="aimd-field-row">
476
+ <span class="aimd-field-label">{{ props.messages.dialog.variableId }} <em>*</em></span>
477
+ <input v-model="fields.name" :placeholder="props.messages.placeholders.variableId" class="aimd-field-input" />
478
+ </label>
479
+ <div class="aimd-field-row">
480
+ <span class="aimd-field-label">{{ props.messages.dialog.typePresetLabel }}</span>
481
+ <span class="aimd-field-hint">{{ props.messages.dialog.typeHint }}</span>
482
+ <div class="aimd-var-type-grid">
483
+ <button
484
+ v-for="preset in varTypePresets"
485
+ :key="preset.key"
486
+ type="button"
487
+ :class="['aimd-var-type-card', { active: isVarTypePresetActive(preset.value) }]"
488
+ @click="selectVarTypePreset(preset.value)"
489
+ >
490
+ <span class="aimd-var-type-card-title">{{ preset.label }}</span>
491
+ <span class="aimd-var-type-card-desc">{{ preset.desc }}</span>
492
+ </button>
493
+ </div>
494
+ </div>
495
+ <label class="aimd-field-row">
496
+ <span class="aimd-field-label">{{ props.messages.dialog.customType }}</span>
497
+ <input
498
+ v-model="fields.type"
499
+ :placeholder="props.messages.placeholders.type"
500
+ class="aimd-field-input"
501
+ />
502
+ <span class="aimd-field-hint">{{ props.messages.dialog.customTypeHint }}</span>
503
+ </label>
504
+ <label class="aimd-field-row">
505
+ <span class="aimd-field-label">{{ props.messages.dialog.defaultValue }}</span>
506
+ <input v-model="fields.default" :placeholder="props.messages.placeholders.defaultValue" class="aimd-field-input" />
507
+ </label>
508
+ <label class="aimd-field-row">
509
+ <span class="aimd-field-label">{{ props.messages.dialog.titleLabel }}</span>
510
+ <input v-model="fields.title" :placeholder="props.messages.placeholders.title" class="aimd-field-input" />
511
+ </label>
512
+ </template>
513
+
514
+ <!-- var_table fields -->
515
+ <template v-if="dialogType === 'var_table'">
516
+ <label class="aimd-field-row">
517
+ <span class="aimd-field-label">{{ props.messages.dialog.tableId }} <em>*</em></span>
518
+ <input v-model="fields.name" :placeholder="props.messages.placeholders.tableId" class="aimd-field-input" />
519
+ </label>
520
+ <label class="aimd-field-row">
521
+ <span class="aimd-field-label">{{ props.messages.dialog.subVariableColumns }}</span>
522
+ <input v-model="fields.subvars" :placeholder="props.messages.placeholders.subVariableColumns" class="aimd-field-input" />
523
+ <span class="aimd-field-hint">{{ props.messages.dialog.subVariableColumnsHint }}</span>
524
+ </label>
525
+ </template>
526
+
527
+ <!-- step fields -->
528
+ <template v-if="dialogType === 'step'">
529
+ <label class="aimd-field-row">
530
+ <span class="aimd-field-label">{{ props.messages.dialog.stepId }} <em>*</em></span>
531
+ <input v-model="fields.name" :placeholder="props.messages.placeholders.stepId" class="aimd-field-input" />
532
+ </label>
533
+ <label class="aimd-field-row">
534
+ <span class="aimd-field-label">{{ props.messages.dialog.level }}</span>
535
+ <select v-model="fields.level" class="aimd-field-input">
536
+ <option value="1">{{ props.messages.dialog.level1 }}</option>
537
+ <option value="2">{{ props.messages.dialog.level2 }}</option>
538
+ <option value="3">{{ props.messages.dialog.level3 }}</option>
539
+ </select>
540
+ </label>
541
+ </template>
542
+
543
+ <!-- quiz fields -->
544
+ <template v-if="dialogType === 'quiz'">
545
+ <label class="aimd-field-row">
546
+ <span class="aimd-field-label">{{ props.messages.dialog.quizId }} <em>*</em></span>
547
+ <input v-model="fields.id" :placeholder="props.messages.placeholders.quizId" class="aimd-field-input" />
548
+ </label>
549
+
550
+ <label class="aimd-field-row">
551
+ <span class="aimd-field-label">{{ props.messages.dialog.questionType }}</span>
552
+ <select v-model="fields.quizType" class="aimd-field-input">
553
+ <option value="choice">{{ props.messages.quiz.types.choice }}</option>
554
+ <option value="blank">{{ props.messages.quiz.types.blank }}</option>
555
+ <option value="open">{{ props.messages.quiz.types.open }}</option>
556
+ </select>
557
+ </label>
558
+
559
+ <label class="aimd-field-row">
560
+ <span class="aimd-field-label">{{ props.messages.dialog.score }}</span>
561
+ <input v-model="fields.score" :placeholder="props.messages.placeholders.score" class="aimd-field-input" />
562
+ </label>
563
+
564
+ <label class="aimd-field-row">
565
+ <span class="aimd-field-label">{{ props.messages.dialog.stem }} <em>*</em></span>
566
+ <textarea v-model="fields.stem" :placeholder="props.messages.placeholders.stem" class="aimd-field-input aimd-field-textarea" />
567
+ <span v-if="fields.quizType === 'blank'" class="aimd-field-hint">
568
+ {{ props.messages.dialog.blankStemHint }}
569
+ </span>
570
+ </label>
571
+
572
+ <template v-if="fields.quizType === 'choice'">
573
+ <label class="aimd-field-row">
574
+ <span class="aimd-field-label">{{ props.messages.dialog.mode }}</span>
575
+ <select v-model="fields.mode" class="aimd-field-input">
576
+ <option value="single">{{ props.messages.quiz.modes.single }}</option>
577
+ <option value="multiple">{{ props.messages.quiz.modes.multiple }}</option>
578
+ </select>
579
+ </label>
580
+
581
+ <div class="aimd-field-row">
582
+ <span class="aimd-field-label">{{ props.messages.dialog.options }}</span>
583
+ <div class="aimd-collection-editor">
584
+ <div
585
+ v-for="(option, index) in quizChoiceOptions"
586
+ :key="`choice-option-${index}`"
587
+ class="aimd-collection-row aimd-option-row"
588
+ :class="{ 'aimd-option-row-dragover': dragOverChoiceIndex === index && draggingChoiceIndex !== null && draggingChoiceIndex !== index }"
589
+ @dragover.prevent="onChoiceDragOver(index)"
590
+ @drop.prevent="dropChoiceAt(index)"
591
+ >
592
+ <span
593
+ class="aimd-drag-handle"
594
+ :title="props.messages.dialog.dragToReorder"
595
+ draggable="true"
596
+ @dragstart="startChoiceDrag(index)"
597
+ @dragend="endChoiceDrag"
598
+ >
599
+ ⋮⋮
600
+ </span>
601
+ <label class="aimd-option-answer-toggle">
602
+ <input
603
+ v-if="fields.mode === 'single'"
604
+ v-model="fields.answer"
605
+ type="radio"
606
+ name="aimd-quiz-choice-answer"
607
+ :value="option.key.trim()"
608
+ :disabled="!option.key.trim()"
609
+ />
610
+ <input
611
+ v-else
612
+ v-model="quizMultipleAnswers"
613
+ type="checkbox"
614
+ :value="option.key.trim()"
615
+ :disabled="!option.key.trim()"
616
+ />
617
+ <span>{{ fields.mode === 'single' ? props.messages.dialog.answer : props.messages.dialog.correct }}</span>
618
+ </label>
619
+ <input v-model="option.key" :placeholder="props.messages.placeholders.optionKey" class="aimd-field-input" />
620
+ <input v-model="option.text" :placeholder="props.messages.placeholders.optionText" class="aimd-field-input" />
621
+ <button
622
+ type="button"
623
+ class="aimd-mini-btn"
624
+ :disabled="quizChoiceOptions.length <= 1"
625
+ @click="removeChoiceOption(index)"
626
+ >
627
+ {{ props.messages.common.remove }}
628
+ </button>
629
+ </div>
630
+ <button type="button" class="aimd-mini-btn aimd-mini-btn-add" @click="addChoiceOption">
631
+ {{ props.messages.actions.addOption }}
632
+ </button>
633
+ </div>
634
+ <span class="aimd-field-hint">{{ props.messages.dialog.optionsHint }}</span>
635
+ </div>
636
+ </template>
637
+
638
+ <template v-else-if="fields.quizType === 'blank'">
639
+ <div class="aimd-field-row">
640
+ <span class="aimd-field-label">{{ props.messages.dialog.blanks }}</span>
641
+ <div class="aimd-collection-editor">
642
+ <div v-for="(blank, index) in quizBlankItems" :key="`blank-item-${index}`" class="aimd-collection-row">
643
+ <input v-model="blank.key" :placeholder="props.messages.placeholders.blankKey" class="aimd-field-input" />
644
+ <input v-model="blank.answer" :placeholder="props.messages.placeholders.blankAnswer" class="aimd-field-input" />
645
+ <button
646
+ type="button"
647
+ class="aimd-mini-btn"
648
+ :disabled="quizBlankItems.length <= 1"
649
+ @click="removeBlankItem(index)"
650
+ >
651
+ {{ props.messages.common.remove }}
652
+ </button>
653
+ </div>
654
+ <button type="button" class="aimd-mini-btn aimd-mini-btn-add" @click="addBlankItem">
655
+ {{ props.messages.actions.addBlank }}
656
+ </button>
657
+ </div>
658
+ <span class="aimd-field-hint">{{ props.messages.dialog.blanksHint }}</span>
659
+ </div>
660
+ </template>
661
+
662
+ <template v-else>
663
+ <label class="aimd-field-row">
664
+ <span class="aimd-field-label">{{ props.messages.dialog.rubric }}</span>
665
+ <textarea v-model="fields.rubric" :placeholder="props.messages.placeholders.rubric" class="aimd-field-input aimd-field-textarea" />
666
+ </label>
667
+ </template>
668
+ </template>
669
+
670
+ <!-- check fields -->
671
+ <template v-if="dialogType === 'check'">
672
+ <label class="aimd-field-row">
673
+ <span class="aimd-field-label">{{ props.messages.dialog.checkpointId }} <em>*</em></span>
674
+ <input v-model="fields.name" :placeholder="props.messages.placeholders.checkpointId" class="aimd-field-input" />
675
+ </label>
676
+ </template>
677
+
678
+ <!-- ref_step / ref_var / ref_fig -->
679
+ <template v-if="['ref_step', 'ref_var', 'ref_fig'].includes(dialogType)">
680
+ <label class="aimd-field-row">
681
+ <span class="aimd-field-label">
682
+ {{ referencedLabel }}
683
+ <em>*</em>
684
+ </span>
685
+ <input
686
+ v-model="fields.name"
687
+ :placeholder="referencedPlaceholder"
688
+ class="aimd-field-input"
689
+ list="aimd-ref-suggestions"
690
+ />
691
+ <datalist id="aimd-ref-suggestions">
692
+ <option v-for="s in suggestions" :key="s" :value="s" />
693
+ </datalist>
694
+ <span v-if="suggestions.length" class="aimd-field-hint">
695
+ {{ props.messages.common.available }}: {{ suggestions.join(', ') }}
696
+ </span>
697
+ </label>
698
+ </template>
699
+
700
+ <!-- cite -->
701
+ <template v-if="dialogType === 'cite'">
702
+ <label class="aimd-field-row">
703
+ <span class="aimd-field-label">{{ props.messages.dialog.citationId }} <em>*</em></span>
704
+ <input v-model="fields.refs" :placeholder="props.messages.placeholders.citationIds" class="aimd-field-input" />
705
+ <span class="aimd-field-hint">{{ props.messages.dialog.citationHint }}</span>
706
+ </label>
707
+ </template>
708
+
709
+ <!-- Preview -->
710
+ <div class="aimd-dialog-preview">
711
+ <div class="aimd-preview-header">{{ props.messages.common.preview }}</div>
712
+ <pre class="aimd-preview-panel"><code class="aimd-preview-code">{{ preview }}</code></pre>
713
+ </div>
714
+
715
+ <div v-if="formError" class="aimd-dialog-error">
716
+ {{ formError }}
717
+ </div>
718
+ </div>
719
+
720
+ <div class="aimd-dialog-footer">
721
+ <button class="aimd-btn aimd-btn-cancel" @click="close">{{ props.messages.common.cancel }}</button>
722
+ <button class="aimd-btn aimd-btn-primary" @click="doInsert">{{ props.messages.common.insert }}</button>
723
+ </div>
724
+ </div>
725
+ </div>
726
+ </Teleport>
727
+ </template>
728
+
729
+ <style>
730
+ .aimd-dialog-overlay {
731
+ position: fixed;
732
+ inset: 0;
733
+ background: rgba(0,0,0,0.35);
734
+ display: flex;
735
+ align-items: center;
736
+ justify-content: center;
737
+ z-index: 10000;
738
+ backdrop-filter: blur(2px);
739
+ }
740
+
741
+ .aimd-dialog {
742
+ background: #fff;
743
+ border-radius: 12px;
744
+ box-shadow: 0 20px 60px rgba(0,0,0,0.2);
745
+ width: 520px;
746
+ max-width: 90vw;
747
+ max-height: 85vh;
748
+ display: flex;
749
+ flex-direction: column;
750
+ overflow: hidden;
751
+ }
752
+
753
+ .aimd-dialog-header {
754
+ display: flex;
755
+ align-items: center;
756
+ justify-content: space-between;
757
+ padding: 16px 20px;
758
+ border-bottom: 1px solid #f0f0f0;
759
+ }
760
+
761
+ .aimd-dialog-title {
762
+ font-size: 16px;
763
+ font-weight: 600;
764
+ color: #1a1a2e;
765
+ display: flex;
766
+ align-items: center;
767
+ gap: 8px;
768
+ }
769
+
770
+ .aimd-dialog-icon {
771
+ display: flex;
772
+ align-items: center;
773
+ }
774
+
775
+ .aimd-dialog-icon svg {
776
+ width: 20px;
777
+ height: 20px;
778
+ }
779
+
780
+ .aimd-dialog-close {
781
+ width: 32px;
782
+ height: 32px;
783
+ border: none;
784
+ background: transparent;
785
+ cursor: pointer;
786
+ font-size: 22px;
787
+ color: #999;
788
+ border-radius: 6px;
789
+ display: flex;
790
+ align-items: center;
791
+ justify-content: center;
792
+ }
793
+
794
+ .aimd-dialog-close:hover {
795
+ background: #f0f2f5;
796
+ color: #333;
797
+ }
798
+
799
+ .aimd-dialog-type-tabs {
800
+ display: flex;
801
+ flex-wrap: wrap;
802
+ align-items: center;
803
+ border-bottom: 1px solid #f0f0f0;
804
+ padding: 10px 12px;
805
+ gap: 8px;
806
+ }
807
+
808
+ .aimd-type-tab {
809
+ display: inline-flex;
810
+ align-items: center;
811
+ gap: 6px;
812
+ padding: 7px 10px;
813
+ border: 1px solid #e5e7eb;
814
+ border-radius: 999px;
815
+ background: #fff;
816
+ cursor: pointer;
817
+ font-size: 12px;
818
+ color: #6b7280;
819
+ white-space: nowrap;
820
+ transition: all 0.15s;
821
+ }
822
+
823
+ .aimd-type-tab:hover {
824
+ color: #374151;
825
+ border-color: #d1d5db;
826
+ background: #f8fafc;
827
+ }
828
+
829
+ .aimd-type-tab-icon {
830
+ display: inline-flex;
831
+ align-items: center;
832
+ vertical-align: middle;
833
+ }
834
+
835
+ .aimd-type-tab-icon svg {
836
+ width: 14px;
837
+ height: 14px;
838
+ }
839
+
840
+ .aimd-type-tab.active {
841
+ font-weight: 600;
842
+ }
843
+
844
+ .aimd-dialog-body {
845
+ padding: 20px;
846
+ display: flex;
847
+ flex-direction: column;
848
+ gap: 14px;
849
+ overflow-y: auto;
850
+ }
851
+
852
+ .aimd-field-row {
853
+ display: flex;
854
+ flex-direction: column;
855
+ gap: 4px;
856
+ }
857
+
858
+ .aimd-field-label {
859
+ font-size: 13px;
860
+ font-weight: 500;
861
+ color: #444;
862
+ }
863
+
864
+ .aimd-field-label em {
865
+ color: #dc2626;
866
+ font-style: normal;
867
+ }
868
+
869
+ .aimd-field-input {
870
+ padding: 8px 12px;
871
+ border: 1px solid #e0e0e0;
872
+ border-radius: 6px;
873
+ font-size: 14px;
874
+ outline: none;
875
+ transition: border-color 0.2s;
876
+ font-family: inherit;
877
+ }
878
+
879
+ .aimd-field-input:focus {
880
+ border-color: #1a73e8;
881
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
882
+ }
883
+
884
+ .aimd-field-textarea {
885
+ min-height: 72px;
886
+ resize: vertical;
887
+ }
888
+
889
+ .aimd-field-hint {
890
+ font-size: 11px;
891
+ color: #999;
892
+ }
893
+
894
+ .aimd-var-type-grid {
895
+ display: grid;
896
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
897
+ gap: 8px;
898
+ }
899
+
900
+ .aimd-var-type-card {
901
+ display: flex;
902
+ flex-direction: column;
903
+ align-items: flex-start;
904
+ gap: 4px;
905
+ padding: 10px 12px;
906
+ border: 1px solid #e5e7eb;
907
+ border-radius: 10px;
908
+ background: #fff;
909
+ cursor: pointer;
910
+ text-align: left;
911
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s, transform 0.15s;
912
+ }
913
+
914
+ .aimd-var-type-card:hover {
915
+ border-color: #93c5fd;
916
+ background: #f8fbff;
917
+ transform: translateY(-1px);
918
+ }
919
+
920
+ .aimd-var-type-card:focus-visible {
921
+ outline: none;
922
+ border-color: #2563eb;
923
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
924
+ }
925
+
926
+ .aimd-var-type-card.active {
927
+ border-color: #2563eb;
928
+ background: #eff6ff;
929
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
930
+ }
931
+
932
+ .aimd-var-type-card-title {
933
+ font-size: 13px;
934
+ font-weight: 600;
935
+ color: #1f2937;
936
+ }
937
+
938
+ .aimd-var-type-card-desc {
939
+ font-size: 12px;
940
+ line-height: 1.4;
941
+ color: #6b7280;
942
+ }
943
+
944
+ .aimd-collection-editor {
945
+ display: flex;
946
+ flex-direction: column;
947
+ gap: 8px;
948
+ }
949
+
950
+ .aimd-collection-row {
951
+ display: grid;
952
+ grid-template-columns: 90px 1fr auto;
953
+ gap: 8px;
954
+ align-items: center;
955
+ }
956
+
957
+ .aimd-option-row {
958
+ grid-template-columns: 26px 96px 90px 1fr auto;
959
+ }
960
+
961
+ .aimd-option-row-dragover {
962
+ background: #eff6ff;
963
+ border-radius: 6px;
964
+ }
965
+
966
+ .aimd-option-answer-toggle {
967
+ display: flex;
968
+ align-items: center;
969
+ gap: 6px;
970
+ color: #4b5563;
971
+ font-size: 12px;
972
+ user-select: none;
973
+ }
974
+
975
+ .aimd-option-answer-toggle input {
976
+ margin: 0;
977
+ }
978
+
979
+ .aimd-drag-handle {
980
+ color: #9ca3af;
981
+ cursor: grab;
982
+ user-select: none;
983
+ text-align: center;
984
+ font-size: 14px;
985
+ line-height: 1;
986
+ }
987
+
988
+ .aimd-drag-handle:active {
989
+ cursor: grabbing;
990
+ }
991
+
992
+ .aimd-mini-btn {
993
+ border: 1px solid #d8dee8;
994
+ background: #fff;
995
+ color: #4b5563;
996
+ border-radius: 6px;
997
+ padding: 6px 10px;
998
+ font-size: 12px;
999
+ cursor: pointer;
1000
+ }
1001
+
1002
+ .aimd-mini-btn:hover {
1003
+ background: #f8fafc;
1004
+ }
1005
+
1006
+ .aimd-mini-btn:disabled {
1007
+ opacity: 0.5;
1008
+ cursor: not-allowed;
1009
+ }
1010
+
1011
+ .aimd-mini-btn-add {
1012
+ align-self: flex-start;
1013
+ }
1014
+
1015
+ .aimd-dialog-preview {
1016
+ padding: 12px 14px;
1017
+ background: #f8f9fa;
1018
+ border-radius: 6px;
1019
+ border: 1px solid #e8e8e8;
1020
+ display: flex;
1021
+ flex-direction: column;
1022
+ align-items: stretch;
1023
+ gap: 10px;
1024
+ }
1025
+
1026
+ .aimd-preview-header {
1027
+ display: block;
1028
+ font-size: 12px;
1029
+ color: #6b7280;
1030
+ font-weight: 600;
1031
+ letter-spacing: 0.04em;
1032
+ }
1033
+
1034
+ .aimd-preview-panel {
1035
+ display: block;
1036
+ width: 100%;
1037
+ margin: 0;
1038
+ padding: 10px 12px;
1039
+ border-radius: 6px;
1040
+ border: 1px solid #dbe3ef;
1041
+ background: #fff;
1042
+ overflow: auto;
1043
+ max-height: 280px;
1044
+ line-height: 1.5;
1045
+ }
1046
+
1047
+ .aimd-preview-code {
1048
+ display: block;
1049
+ font-family: 'SF Mono', 'Fira Code', monospace;
1050
+ font-size: 13px;
1051
+ color: #2563eb;
1052
+ word-break: break-word;
1053
+ white-space: pre-wrap;
1054
+ }
1055
+
1056
+ .aimd-dialog-error {
1057
+ padding: 10px 12px;
1058
+ border: 1px solid #fecaca;
1059
+ background: #fef2f2;
1060
+ color: #b91c1c;
1061
+ border-radius: 6px;
1062
+ font-size: 12px;
1063
+ line-height: 1.5;
1064
+ }
1065
+
1066
+ .aimd-dialog-footer {
1067
+ display: flex;
1068
+ justify-content: flex-end;
1069
+ gap: 8px;
1070
+ padding: 14px 20px;
1071
+ border-top: 1px solid #f0f0f0;
1072
+ }
1073
+
1074
+ .aimd-btn {
1075
+ padding: 8px 20px;
1076
+ border-radius: 6px;
1077
+ font-size: 13px;
1078
+ font-weight: 500;
1079
+ cursor: pointer;
1080
+ transition: all 0.15s;
1081
+ }
1082
+
1083
+ .aimd-btn-cancel {
1084
+ border: 1px solid #e0e0e0;
1085
+ background: #fff;
1086
+ color: #666;
1087
+ }
1088
+
1089
+ .aimd-btn-cancel:hover {
1090
+ background: #f5f5f5;
1091
+ }
1092
+
1093
+ .aimd-btn-primary {
1094
+ border: none;
1095
+ background: #1a73e8;
1096
+ color: #fff;
1097
+ }
1098
+
1099
+ .aimd-btn-primary:hover {
1100
+ background: #1557b0;
1101
+ }
1102
+ </style>