@plutocms/docs 0.0.0-stage → 0.16.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 (65) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +186 -0
  4. package/FEATURES.md +34 -0
  5. package/README.md +40 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +165 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +161 -0
  10. package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
  11. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
  12. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
  13. package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +86 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +232 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +269 -0
  16. package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
  17. package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
  18. package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
  19. package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
  20. package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
  21. package/app/components/PlutoContentPreview.vue +35 -0
  22. package/app/composables/pluto-block-editor.ts +459 -0
  23. package/app/composables/pluto-content-edit-link.ts +19 -0
  24. package/app/composables/pluto-content-preview.ts +29 -0
  25. package/app/composables/pluto-content-runtime.ts +237 -0
  26. package/app/pages/_pluto/preview.vue +555 -0
  27. package/app/plugins/pluto-docs.ts +50 -0
  28. package/i18n/locales/en.json +253 -0
  29. package/i18n/locales/pt-BR.json +289 -0
  30. package/modules/pluto-docs/blocks.ts +217 -0
  31. package/modules/pluto-docs/index.ts +230 -0
  32. package/modules/pluto-docs/manifest.ts +159 -0
  33. package/modules/pluto-docs/options.ts +258 -0
  34. package/nuxt.config.ts +36 -0
  35. package/package.json +56 -4
  36. package/server/handlers/navigation.get.ts +7 -0
  37. package/server/handlers/page.get.ts +7 -0
  38. package/server/handlers/surround.get.ts +7 -0
  39. package/server/plugins/pluto-docs.ts +25 -0
  40. package/server/utils/pluto-docs-runtime.ts +504 -0
  41. package/shared/types/docs.d.ts +40 -0
  42. package/shared/types/pluto-blocks.d.ts +10 -0
  43. package/shared/types/pluto-manifest.d.ts +6 -0
  44. package/shared/utils/blocks/component-meta.ts +285 -0
  45. package/shared/utils/blocks/define.ts +15 -0
  46. package/shared/utils/blocks/editor.ts +357 -0
  47. package/shared/utils/blocks/history.ts +94 -0
  48. package/shared/utils/blocks/mdc-props.ts +321 -0
  49. package/shared/utils/blocks/operations.ts +692 -0
  50. package/shared/utils/blocks/parse.ts +444 -0
  51. package/shared/utils/blocks/presets.ts +384 -0
  52. package/shared/utils/blocks/preview-marks.ts +98 -0
  53. package/shared/utils/blocks/preview-protocol.ts +195 -0
  54. package/shared/utils/blocks/registry.ts +295 -0
  55. package/shared/utils/blocks/serialize.ts +107 -0
  56. package/shared/utils/blocks/tree.ts +85 -0
  57. package/shared/utils/blocks/types.ts +195 -0
  58. package/shared/utils/content-route.ts +40 -0
  59. package/shared/utils/docs-type.ts +290 -0
  60. package/shared/utils/mdc-snippets.ts +114 -0
  61. package/shared/utils/navigation-config.ts +19 -0
  62. package/shared/utils/runtime-content.ts +403 -0
  63. package/tailwind.lint.css +10 -0
  64. package/tsconfig.json +18 -0
  65. package/vitest.config.ts +7 -0
@@ -0,0 +1,519 @@
1
+ <script setup lang="ts">
2
+ // The right panel (the Block tab): the controls of the selected block. A Text
3
+ // block has a rich text box. A Code block has a language, a file name and a
4
+ // code box. A component has its content (one control for each slot) and its
5
+ // settings. Unknown settings are in the Advanced section. Nothing here shows
6
+ // raw keys or YAML, except in Advanced and in "Edit as Markdown".
7
+ import type {
8
+ PlutoBlock,
9
+ PlutoComponentBlock,
10
+ PlutoPropValue,
11
+ } from '../../../../../shared/utils/blocks/types'
12
+ import {
13
+ resolveSlotMode,
14
+ slotMarkdown,
15
+ } from '../../../../../shared/utils/blocks/editor'
16
+ import { blockMarkdown } from '../../../../../shared/utils/blocks/operations'
17
+ import PlutoBlockPropControl from './PlutoBlockPropControl.vue'
18
+ import PlutoBlockRawEditor from './PlutoBlockRawEditor.vue'
19
+ import PlutoBlockTextEditor from './PlutoBlockTextEditor.vue'
20
+
21
+ defineProps<{
22
+ disabled?: boolean
23
+ }>()
24
+
25
+ const editor = usePlutoBlockEditor()
26
+ const { t, tx } = usePlutoI18n()
27
+ const { slotLabel, addLabel } = usePlutoBlockLabels()
28
+
29
+ const block = computed(() => editor.selected.value?.block)
30
+ const info = computed(() =>
31
+ block.value ? editor.describe(block.value) : undefined
32
+ )
33
+ const definition = computed(() =>
34
+ block.value ? editor.definitionOf(block.value) : undefined
35
+ )
36
+ const parent = computed(() => editor.selected.value?.parent)
37
+
38
+ // "Edit as Markdown" shows the Markdown of the block in a box. It resets with the selection.
39
+ const rawOpen = ref(false)
40
+ watch(
41
+ () => block.value?.id,
42
+ () => {
43
+ rawOpen.value = false
44
+ }
45
+ )
46
+
47
+ const description = computed(() => {
48
+ const text = definition.value?.description
49
+ return text ? tx(text) : undefined
50
+ })
51
+
52
+ const LANGUAGES = [
53
+ 'bash',
54
+ 'css',
55
+ 'diff',
56
+ 'html',
57
+ 'js',
58
+ 'json',
59
+ 'md',
60
+ 'sh',
61
+ 'sql',
62
+ 'ts',
63
+ 'tsx',
64
+ 'vue',
65
+ 'yaml',
66
+ ]
67
+
68
+ // --- Component settings ---------------------------------------------------------
69
+
70
+ const settings = computed(() => {
71
+ const props = definition.value?.props ?? {}
72
+ return Object.entries(props).filter(([, prop]) => !prop.advanced)
73
+ })
74
+
75
+ const advanced = computed(() => {
76
+ const props = definition.value?.props ?? {}
77
+ return Object.entries(props).filter(([, prop]) => prop.advanced)
78
+ })
79
+
80
+ // A prop with a `slot` (the label of a button) is one field for the prop and the slot. The slot
81
+ // does not show in Content.
82
+ const coveredSlots = computed(
83
+ () =>
84
+ new Set(
85
+ Object.values(definition.value?.props ?? {}).flatMap((prop) =>
86
+ prop.slot ? [prop.slot] : []
87
+ )
88
+ )
89
+ )
90
+
91
+ // The field edits the slot when the block has text in it, else the prop. It is chosen when the
92
+ // block is selected, so clearing the field does not move the text to the other place while typing.
93
+ const slotBacked = ref(new Set<string>())
94
+ watch(
95
+ () => block.value?.id,
96
+ () => {
97
+ const current = block.value
98
+ slotBacked.value = new Set(
99
+ current?.kind === 'component'
100
+ ? Object.entries(definition.value?.props ?? {}).flatMap(
101
+ ([name, prop]) =>
102
+ prop.slot && slotMarkdown(current, prop.slot).trim() ? [name] : []
103
+ )
104
+ : []
105
+ )
106
+ },
107
+ { immediate: true }
108
+ )
109
+
110
+ function propValue(name: string): PlutoPropValue | undefined {
111
+ const slot = definition.value?.props?.[name]?.slot
112
+ return slot && slotBacked.value.has(name)
113
+ ? slotMarkdown(component(), slot)
114
+ : component().props[name]
115
+ }
116
+
117
+ function setPropValue(name: string, value: PlutoPropValue) {
118
+ const slot = definition.value?.props?.[name]?.slot
119
+ if (slot && slotBacked.value.has(name)) {
120
+ editor.setSlot(component().id, slot, String(value ?? ''))
121
+ } else {
122
+ editor.setProp(component().id, name, value)
123
+ }
124
+ }
125
+
126
+ const slots = computed(() => {
127
+ const current = block.value
128
+ if (current?.kind !== 'component') {
129
+ return []
130
+ }
131
+ return Object.entries(definition.value?.slots ?? {})
132
+ .filter(([name]) => !coveredSlots.value.has(name))
133
+ .map(([name, slot]) => ({
134
+ name,
135
+ label: slotLabel(definition.value, name),
136
+ add: addLabel(definition.value, name),
137
+ mode: resolveSlotMode(
138
+ current,
139
+ name,
140
+ slot.content === 'rich' ||
141
+ slot.content === 'inline' ||
142
+ slot.content === 'blocks'
143
+ ? slot.content
144
+ : undefined
145
+ ),
146
+ children: current.slots[name] ?? [],
147
+ accepts: slot.accepts,
148
+ }))
149
+ })
150
+
151
+ function component(): PlutoComponentBlock {
152
+ return block.value as PlutoComponentBlock
153
+ }
154
+
155
+ function childInfo(child: PlutoBlock) {
156
+ const described = editor.describe(child)
157
+ return { ...described, label: tx(described.label) }
158
+ }
159
+
160
+ function addTo(name: string) {
161
+ editor.openPalette({
162
+ parentId: component().id,
163
+ slot: name,
164
+ index: (component().slots[name] ?? []).length,
165
+ })
166
+ }
167
+
168
+ function rawMarkdown(): string {
169
+ return block.value ? blockMarkdown(block.value) : ''
170
+ }
171
+
172
+ function applyRaw(markdown: string) {
173
+ if (!block.value) {
174
+ return
175
+ }
176
+ if (block.value.kind === 'raw' || block.value.kind === 'text') {
177
+ editor.updateText(block.value.id, markdown)
178
+ } else {
179
+ editor.replaceMarkdown(block.value.id, markdown)
180
+ rawOpen.value = false
181
+ }
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <div
187
+ :aria-label="t('docs.editor.blockPanel')"
188
+ class="flex flex-col gap-5"
189
+ role="region"
190
+ data-pluto-block-ui
191
+ >
192
+ <UEmpty
193
+ v-if="!block || !info"
194
+ :title="t('docs.editor.nothingSelectedTitle')"
195
+ :description="t('docs.editor.nothingSelected')"
196
+ :actions="[
197
+ {
198
+ label: t('docs.editor.addBlock'),
199
+ icon: 'i-lucide-plus',
200
+ color: 'primary',
201
+ variant: 'soft',
202
+ onClick: () => editor.openPalette(),
203
+ },
204
+ ]"
205
+ icon="i-lucide-mouse-pointer-click"
206
+ variant="naked"
207
+ />
208
+
209
+ <template v-else>
210
+ <header class="flex items-start gap-3">
211
+ <span
212
+ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
213
+ >
214
+ <UIcon :name="info.icon" class="size-5" />
215
+ </span>
216
+ <div class="min-w-0 flex-1">
217
+ <h3 class="truncate text-base font-semibold text-highlighted">
218
+ {{ tx(info.label) }}
219
+ </h3>
220
+ <p v-if="description" class="text-sm text-muted">
221
+ {{ description }}
222
+ </p>
223
+ </div>
224
+ </header>
225
+
226
+ <UButton
227
+ v-if="parent"
228
+ :label="
229
+ t('docs.editor.selectParent', {
230
+ name: tx(editor.describe(parent).label),
231
+ })
232
+ "
233
+ icon="i-lucide-corner-left-up"
234
+ color="neutral"
235
+ variant="link"
236
+ size="xs"
237
+ class="-mt-3 self-start p-0"
238
+ @click="editor.select(parent.id)"
239
+ />
240
+
241
+ <!-- Edit as Markdown -->
242
+ <section v-if="rawOpen" class="flex flex-col gap-2">
243
+ <p class="text-sm text-muted">
244
+ {{ t('docs.editor.rawNote') }}
245
+ </p>
246
+ <PlutoBlockRawEditor
247
+ :model-value="rawMarkdown()"
248
+ :disabled="disabled"
249
+ :label="t('docs.editor.rawLabel')"
250
+ :live="block.kind === 'text' || block.kind === 'raw'"
251
+ :rows="block.kind === 'component' ? 10 : 8"
252
+ @update:model-value="applyRaw"
253
+ />
254
+ </section>
255
+
256
+ <!-- Text -->
257
+ <template v-else-if="block.kind === 'text'">
258
+ <PlutoBlockTextEditor
259
+ :key="block.id"
260
+ :model-value="block.markdown"
261
+ :disabled="disabled"
262
+ :label="t('docs.editor.textLabel')"
263
+ @update:model-value="editor.updateText(block.id, $event)"
264
+ />
265
+ </template>
266
+
267
+ <!-- Code -->
268
+ <template v-else-if="block.kind === 'code'">
269
+ <div class="grid grid-cols-2 gap-3">
270
+ <UFormField
271
+ :label="t('docs.editor.language')"
272
+ :help="t('docs.editor.languageHelp')"
273
+ >
274
+ <UInput
275
+ :model-value="block.lang"
276
+ :disabled="disabled"
277
+ list="pluto-block-languages"
278
+ placeholder="ts"
279
+ autocomplete="off"
280
+ spellcheck="false"
281
+ class="w-full"
282
+ @update:model-value="
283
+ editor.updateCode(block.id, { lang: String($event ?? '') })
284
+ "
285
+ />
286
+ <datalist id="pluto-block-languages">
287
+ <option
288
+ v-for="language in LANGUAGES"
289
+ :key="language"
290
+ :value="language"
291
+ />
292
+ </datalist>
293
+ </UFormField>
294
+ <UFormField
295
+ :label="t('docs.editor.fileName')"
296
+ :help="t('docs.editor.fileNameHelp')"
297
+ >
298
+ <UInput
299
+ :model-value="block.filename ?? ''"
300
+ :disabled="disabled"
301
+ placeholder="app.ts"
302
+ autocomplete="off"
303
+ spellcheck="false"
304
+ class="w-full"
305
+ @update:model-value="
306
+ editor.updateCode(block.id, {
307
+ filename: String($event ?? '') || undefined,
308
+ })
309
+ "
310
+ />
311
+ </UFormField>
312
+ </div>
313
+ <UFormField :label="t('docs.editor.code')">
314
+ <UTextarea
315
+ :model-value="block.code"
316
+ :disabled="disabled"
317
+ :rows="10"
318
+ :maxrows="28"
319
+ :ui="{ base: 'font-mono text-sm/6' }"
320
+ spellcheck="false"
321
+ autocomplete="off"
322
+ autocapitalize="off"
323
+ class="w-full"
324
+ autoresize
325
+ @update:model-value="
326
+ editor.updateCode(block.id, { code: String($event ?? '') })
327
+ "
328
+ />
329
+ </UFormField>
330
+ </template>
331
+
332
+ <!-- Markdown (raw) -->
333
+ <template v-else-if="block.kind === 'raw'">
334
+ <UAlert
335
+ :description="
336
+ block.reason === 'table'
337
+ ? t('docs.editor.tableNote')
338
+ : t('docs.editor.rawBlockNote')
339
+ "
340
+ color="neutral"
341
+ variant="subtle"
342
+ icon="i-lucide-info"
343
+ />
344
+ <PlutoBlockRawEditor
345
+ :model-value="block.markdown"
346
+ :disabled="disabled"
347
+ :label="t('docs.editor.rawLabel')"
348
+ :rows="12"
349
+ live
350
+ @update:model-value="editor.updateText(block.id, $event)"
351
+ />
352
+ </template>
353
+
354
+ <!-- Component -->
355
+ <template v-else>
356
+ <section v-if="slots.length" class="flex flex-col gap-4">
357
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
358
+ {{ t('docs.editor.content') }}
359
+ </h4>
360
+
361
+ <UFormField
362
+ v-for="slot in slots"
363
+ :key="slot.name"
364
+ :label="slots.length > 1 ? slot.label : undefined"
365
+ >
366
+ <PlutoBlockTextEditor
367
+ v-if="slot.mode === 'rich'"
368
+ :model-value="slotMarkdown(component(), slot.name)"
369
+ :disabled="disabled"
370
+ :label="slot.label"
371
+ compact
372
+ @update:model-value="editor.setSlot(block.id, slot.name, $event)"
373
+ />
374
+
375
+ <UInput
376
+ v-else-if="slot.mode === 'inline'"
377
+ :model-value="slotMarkdown(component(), slot.name)"
378
+ :disabled="disabled"
379
+ :aria-label="slot.label"
380
+ class="w-full"
381
+ @update:model-value="
382
+ editor.setSlot(block.id, slot.name, String($event ?? ''))
383
+ "
384
+ />
385
+
386
+ <PlutoBlockRawEditor
387
+ v-else-if="slot.mode === 'markdown'"
388
+ :model-value="slotMarkdown(component(), slot.name)"
389
+ :disabled="disabled"
390
+ :label="slot.label"
391
+ :rows="5"
392
+ live
393
+ @update:model-value="editor.setSlot(block.id, slot.name, $event)"
394
+ />
395
+
396
+ <div v-else class="flex flex-col gap-1">
397
+ <ul v-if="slot.children.length" class="flex flex-col gap-1">
398
+ <li v-for="child in slot.children" :key="child.id">
399
+ <button
400
+ type="button"
401
+ class="flex w-full items-center gap-2 rounded-md border border-default px-2 py-1.5 text-start text-sm hover:bg-elevated focus-visible:outline-2 focus-visible:outline-primary"
402
+ @click="editor.select(child.id)"
403
+ >
404
+ <UIcon
405
+ :name="childInfo(child).icon"
406
+ class="size-4 shrink-0 text-muted"
407
+ />
408
+ <span class="min-w-0 flex-1 truncate">
409
+ <span class="font-medium text-highlighted">{{
410
+ childInfo(child).label
411
+ }}</span>
412
+ <span v-if="childInfo(child).excerpt" class="text-muted"
413
+ >: {{ childInfo(child).excerpt }}</span
414
+ >
415
+ </span>
416
+ <UIcon
417
+ name="i-lucide-chevron-right"
418
+ class="size-4 shrink-0 text-dimmed"
419
+ />
420
+ </button>
421
+ </li>
422
+ </ul>
423
+ <p v-else class="text-sm text-muted">
424
+ {{ t('docs.editor.slotEmpty') }}
425
+ </p>
426
+ <UButton
427
+ :label="slot.add"
428
+ :disabled="disabled"
429
+ icon="i-lucide-plus"
430
+ color="neutral"
431
+ variant="outline"
432
+ size="xs"
433
+ class="self-start"
434
+ @click="addTo(slot.name)"
435
+ />
436
+ </div>
437
+ </UFormField>
438
+ </section>
439
+
440
+ <section v-if="settings.length" class="flex flex-col gap-4">
441
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
442
+ {{ t('docs.editor.settings') }}
443
+ </h4>
444
+ <PlutoBlockPropControl
445
+ v-for="[name, prop] in settings"
446
+ :key="name"
447
+ :name="name"
448
+ :definition="prop"
449
+ :model-value="propValue(name)"
450
+ :disabled="disabled"
451
+ @update:model-value="setPropValue(name, $event)"
452
+ />
453
+ </section>
454
+
455
+ <details
456
+ v-if="advanced.length"
457
+ class="group rounded-lg border border-default"
458
+ >
459
+ <summary
460
+ class="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
461
+ >
462
+ <UIcon
463
+ name="i-lucide-chevron-right"
464
+ class="size-4 text-muted transition-transform group-open:rotate-90"
465
+ />
466
+ {{ t('docs.editor.advanced') }}
467
+ </summary>
468
+ <div class="flex flex-col gap-4 border-t border-default p-3">
469
+ <PlutoBlockPropControl
470
+ v-for="[name, prop] in advanced"
471
+ :key="name"
472
+ :name="name"
473
+ :definition="prop"
474
+ :model-value="component().props[name]"
475
+ :disabled="disabled"
476
+ @update:model-value="editor.setProp(block.id, name, $event)"
477
+ />
478
+ </div>
479
+ </details>
480
+ </template>
481
+
482
+ <!-- Actions -->
483
+ <div class="flex flex-wrap gap-2 border-t border-default pt-4">
484
+ <UButton
485
+ :label="t('docs.editor.duplicate')"
486
+ :disabled="disabled"
487
+ icon="i-lucide-copy"
488
+ color="neutral"
489
+ variant="outline"
490
+ size="sm"
491
+ @click="editor.duplicate(block.id)"
492
+ />
493
+ <UButton
494
+ :label="
495
+ rawOpen
496
+ ? t('docs.editor.backToControls')
497
+ : t('docs.editor.editAsMarkdown')
498
+ "
499
+ :icon="rawOpen ? 'i-lucide-sliders-horizontal' : 'i-lucide-file-code'"
500
+ :disabled="disabled || block.kind === 'raw'"
501
+ color="neutral"
502
+ variant="outline"
503
+ size="sm"
504
+ @click="rawOpen = !rawOpen"
505
+ />
506
+ <UButton
507
+ :label="t('docs.editor.delete')"
508
+ :disabled="disabled"
509
+ icon="i-lucide-trash-2"
510
+ color="error"
511
+ variant="soft"
512
+ size="sm"
513
+ class="ms-auto"
514
+ @click="editor.remove(block.id)"
515
+ />
516
+ </div>
517
+ </template>
518
+ </div>
519
+ </template>
@@ -0,0 +1,161 @@
1
+ <script setup lang="ts">
2
+ // The Markdown mode of the `mdc` widget in a canvas form: the Markdown at the
3
+ // left and the live preview at the right, side by side (stacked on a narrow
4
+ // screen). The handle between them resizes them, and a cookie keeps the size.
5
+ // The preview is the frame of the visual editor, so it renders with the site
6
+ // parser. A click on a block in the preview selects the first line of that
7
+ // block in the Markdown and scrolls to it. The Markdown editor is
8
+ // PlutoMarkdownCodeEditor of core (CodeMirror). The Markdown is the only editor
9
+ // here: the block model only feeds the preview and never writes.
10
+ import type { SplitterItem } from '@nuxt/ui'
11
+ import type { PlutoMarkdownCodeEditor as CodeEditor } from '#components'
12
+ // The component layer. Only the two editor roots import it, so it stays out of the public pages.
13
+ import componentBlocks from '#pluto-docs/blocks'
14
+ import { findBlock } from '../../../../../shared/utils/blocks/tree'
15
+ import PlutoMdcInsertMenu from '../PlutoMdcInsertMenu.vue'
16
+ import PlutoBlockModeSwitch from './PlutoBlockModeSwitch.vue'
17
+ import PlutoBlockPreviewFrame from './PlutoBlockPreviewFrame.vue'
18
+
19
+ const props = defineProps<{
20
+ modelValue: string
21
+ disabled?: boolean
22
+ mode: 'visual' | 'markdown'
23
+ placeholder?: string
24
+ label?: string
25
+ }>()
26
+
27
+ const emit = defineEmits<{
28
+ 'update:modelValue': [value: string]
29
+ 'update:mode': [mode: 'visual' | 'markdown']
30
+ }>()
31
+
32
+ const form = usePlutoContentForm()
33
+ const blockSources = usePlutoBlockSources(componentBlocks)
34
+
35
+ const editor = createPlutoBlockEditor({
36
+ source: () => props.modelValue,
37
+ emit: () => {},
38
+ sources: blockSources,
39
+ })
40
+ provide(plutoBlockEditorKey, editor)
41
+
42
+ // The front matter for the preview header: the item without the body.
43
+ const frontmatter = computed<Record<string, unknown>>(() => {
44
+ const item = form?.item.value ?? {}
45
+ return Object.fromEntries(
46
+ Object.entries(item).filter(([key]) => key !== 'body')
47
+ )
48
+ })
49
+
50
+ const code = useTemplateRef<InstanceType<typeof CodeEditor>>('code')
51
+
52
+ // --- The split ---------------------------------------------------------------------
53
+
54
+ const isDesktop = useMediaQuery('(min-width: 64rem)')
55
+ // The size of the Markdown pane, in percent of the row (desktop) or of the column (narrow).
56
+ const splitSize = useCookie<number>('pluto-mdc-split', {
57
+ default: () => 50,
58
+ maxAge: 60 * 60 * 24 * 365,
59
+ sameSite: 'lax',
60
+ })
61
+ const MIN = 25
62
+ const MAX = 75
63
+ // Read once: a reactive `defaultSize` would rebuild the panels on each save of the size.
64
+ const initialSize = Math.min(MAX, Math.max(MIN, Number(splitSize.value) || 50))
65
+ const items: SplitterItem[] = [
66
+ {
67
+ id: 'pluto-mdc-source',
68
+ slot: 'source',
69
+ defaultSize: initialSize,
70
+ minSize: MIN,
71
+ maxSize: MAX,
72
+ },
73
+ { id: 'pluto-mdc-preview', slot: 'preview' },
74
+ ]
75
+
76
+ // The pointer over the iframe goes to the frame, and the drag stops. So the preview takes no
77
+ // pointer events while the handle moves. The size is saved when the drag ends.
78
+ const dragging = ref(false)
79
+ let lastLayout: number[] = []
80
+ function onLayout(sizes: number[]) {
81
+ lastLayout = sizes
82
+ }
83
+ function onDragging(_index: number, value: boolean) {
84
+ dragging.value = value
85
+ if (!value && lastLayout[0]) {
86
+ splitSize.value = Math.round(lastLayout[0])
87
+ }
88
+ }
89
+
90
+ // A click on a block in the preview: go to that block in the Markdown.
91
+ watch(
92
+ () => editor.selection.value,
93
+ (selection) => {
94
+ if (selection?.source !== 'preview') {
95
+ return
96
+ }
97
+ const original = findBlock(editor.doc.value, selection.id)?.block.original
98
+ if (original) {
99
+ code.value?.reveal(original.start, original.end)
100
+ }
101
+ }
102
+ )
103
+ </script>
104
+
105
+ <template>
106
+ <USplitter
107
+ id="pluto-mdc-split"
108
+ :items="items"
109
+ :orientation="isDesktop ? 'horizontal' : 'vertical'"
110
+ :ui="{
111
+ root: 'h-full min-h-0',
112
+ // A 1px line. A `::before` 2.5px wider on each side is the 6px grab area,
113
+ // and the 6px haze-400 bar: it fades in after 300ms of hover, and shows at
114
+ // once while dragging. In both directions. The `!` keeps the duration and
115
+ // the delay against the no-transition rule of the admin (core pluto-layer.css).
116
+ handle:
117
+ 'bg-(--ui-border) data-[orientation=horizontal]:w-px data-[orientation=vertical]:h-px before:absolute before:z-10 before:transition-colors before:duration-150! hover:before:bg-haze-400 hover:before:delay-300! data-[resize-handle-state=drag]:before:bg-haze-400 data-[resize-handle-state=drag]:before:delay-0! data-[orientation=horizontal]:before:inset-y-0 data-[orientation=horizontal]:before:inset-x-[-2.5px] data-[orientation=vertical]:before:inset-x-0 data-[orientation=vertical]:before:inset-y-[-2.5px]',
118
+ }"
119
+ data-block-canvas
120
+ @layout="onLayout"
121
+ @dragging="onDragging"
122
+ >
123
+ <!-- The source pane: the code editor, and the mode switch in a fixed footer, as in the Visual mode. -->
124
+ <template #source>
125
+ <div class="flex size-full min-h-0 flex-col">
126
+ <div class="min-h-0 flex-1 p-4">
127
+ <PlutoMarkdownCodeEditor
128
+ ref="code"
129
+ :model-value="modelValue"
130
+ :disabled="disabled"
131
+ :placeholder="placeholder"
132
+ :aria-label="label"
133
+ fill
134
+ @update:model-value="emit('update:modelValue', $event)"
135
+ >
136
+ <template #toolbar="{ insert }">
137
+ <PlutoMdcInsertMenu :insert="insert" :disabled="disabled" />
138
+ </template>
139
+ </PlutoMarkdownCodeEditor>
140
+ </div>
141
+ <div
142
+ class="flex shrink-0 items-center justify-between gap-2 border-t border-default px-3 py-2"
143
+ >
144
+ <PlutoBlockModeSwitch
145
+ :mode="mode"
146
+ :disabled="disabled"
147
+ @update:mode="emit('update:mode', $event)"
148
+ />
149
+ </div>
150
+ </div>
151
+ </template>
152
+
153
+ <template #preview>
154
+ <PlutoBlockPreviewFrame
155
+ :frontmatter="frontmatter"
156
+ :class="{ 'pointer-events-none': dragging }"
157
+ class="size-full"
158
+ />
159
+ </template>
160
+ </USplitter>
161
+ </template>