@plutocms/docs 0.0.0-stage → 0.15.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 (61) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +178 -0
  4. package/FEATURES.md +30 -0
  5. package/README.md +39 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -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 +85 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -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 +443 -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/index.ts +123 -0
  31. package/modules/pluto-docs/manifest.ts +159 -0
  32. package/modules/pluto-docs/options.ts +224 -0
  33. package/nuxt.config.ts +36 -0
  34. package/package.json +55 -4
  35. package/server/handlers/navigation.get.ts +7 -0
  36. package/server/handlers/page.get.ts +7 -0
  37. package/server/handlers/surround.get.ts +7 -0
  38. package/server/plugins/pluto-docs.ts +25 -0
  39. package/server/utils/pluto-docs-runtime.ts +504 -0
  40. package/shared/types/docs.d.ts +40 -0
  41. package/shared/types/pluto-manifest.d.ts +6 -0
  42. package/shared/utils/blocks/editor.ts +357 -0
  43. package/shared/utils/blocks/history.ts +94 -0
  44. package/shared/utils/blocks/mdc-props.ts +321 -0
  45. package/shared/utils/blocks/operations.ts +692 -0
  46. package/shared/utils/blocks/parse.ts +444 -0
  47. package/shared/utils/blocks/presets.ts +383 -0
  48. package/shared/utils/blocks/preview-marks.ts +98 -0
  49. package/shared/utils/blocks/preview-protocol.ts +195 -0
  50. package/shared/utils/blocks/registry.ts +163 -0
  51. package/shared/utils/blocks/serialize.ts +107 -0
  52. package/shared/utils/blocks/tree.ts +85 -0
  53. package/shared/utils/blocks/types.ts +165 -0
  54. package/shared/utils/content-route.ts +40 -0
  55. package/shared/utils/docs-type.ts +290 -0
  56. package/shared/utils/mdc-snippets.ts +114 -0
  57. package/shared/utils/navigation-config.ts +19 -0
  58. package/shared/utils/runtime-content.ts +403 -0
  59. package/tailwind.lint.css +10 -0
  60. package/tsconfig.json +18 -0
  61. package/vitest.config.ts +7 -0
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ // The switch between the Visual and the Markdown editor of the page body.
3
+ const props = defineProps<{
4
+ mode: 'visual' | 'markdown'
5
+ disabled?: boolean
6
+ /** Visual is off, for example when the layout has no canvas. */
7
+ visualDisabled?: boolean
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ 'update:mode': [mode: 'visual' | 'markdown']
12
+ }>()
13
+
14
+ const { t } = usePlutoI18n()
15
+
16
+ function pick(mode: 'visual' | 'markdown') {
17
+ if (mode !== props.mode) {
18
+ emit('update:mode', mode)
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div :aria-label="t('docs.editor.mode')" role="group" class="flex gap-1">
25
+ <UButton
26
+ :variant="mode === 'visual' ? 'solid' : 'outline'"
27
+ :aria-pressed="mode === 'visual'"
28
+ :disabled="disabled || visualDisabled"
29
+ :label="t('docs.editor.visual')"
30
+ size="xs"
31
+ color="neutral"
32
+ icon="i-lucide-layout-panel-left"
33
+ @click="pick('visual')"
34
+ />
35
+ <UButton
36
+ :variant="mode === 'markdown' ? 'solid' : 'outline'"
37
+ :aria-pressed="mode === 'markdown'"
38
+ :disabled="disabled"
39
+ :label="t('docs.editor.markdown')"
40
+ size="xs"
41
+ color="neutral"
42
+ icon="i-lucide-file-code"
43
+ @click="pick('markdown')"
44
+ />
45
+ </div>
46
+ </template>
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ // The left panel of the editor: the blocks of the page as a list. A header
3
+ // with Undo, Redo and Add. The list has the drag handle, the block menu, and
4
+ // the blocks inside a container. The mode switch (Visual or Markdown) sits at
5
+ // the bottom. The list scrolls in a Reka UI scroll area, with a thin scrollbar
6
+ // that shows on hover. The panel is white in light mode and #0b0b0b in dark.
7
+ import {
8
+ ScrollAreaRoot,
9
+ ScrollAreaScrollbar,
10
+ ScrollAreaThumb,
11
+ ScrollAreaViewport,
12
+ } from 'reka-ui'
13
+ import PlutoBlockModeSwitch from './PlutoBlockModeSwitch.vue'
14
+ import PlutoBlockOutlineList from './PlutoBlockOutlineList.vue'
15
+
16
+ defineProps<{
17
+ mode: 'visual' | 'markdown'
18
+ disabled?: boolean
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ 'update:mode': [mode: 'visual' | 'markdown']
23
+ }>()
24
+
25
+ const editor = usePlutoBlockEditor()
26
+ const { t } = usePlutoI18n()
27
+
28
+ // A block selected in the preview: scroll the list to its row. The editor opens the collapsed
29
+ // parents first, so the watch runs after the render (`flush: 'post'`).
30
+ watch(
31
+ () => editor.selection.value,
32
+ (selection) => {
33
+ if (selection?.source !== 'preview') {
34
+ return
35
+ }
36
+ document
37
+ .querySelector<HTMLElement>(
38
+ `[data-block-row="${CSS.escape(selection.id)}"]`
39
+ )
40
+ ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
41
+ },
42
+ { flush: 'post' }
43
+ )
44
+ </script>
45
+
46
+ <template>
47
+ <div
48
+ :aria-label="t('docs.editor.blocks')"
49
+ class="flex min-h-0 flex-1 flex-col bg-white dark:bg-[#0b0b0b]"
50
+ role="region"
51
+ data-block-outline
52
+ data-pluto-block-ui
53
+ >
54
+ <div
55
+ class="flex h-12 shrink-0 items-center gap-1 border-b border-default px-3"
56
+ >
57
+ <h2
58
+ class="min-w-0 truncate text-sm font-semibold text-highlighted max-lg:hidden"
59
+ >
60
+ {{ t('docs.editor.blocks') }}
61
+ </h2>
62
+ <div class="ms-auto flex items-center gap-1">
63
+ <UTooltip :text="t('docs.editor.undo')" :kbds="['meta', 'Z']">
64
+ <UButton
65
+ :disabled="disabled || !editor.canUndo.value"
66
+ :aria-label="t('docs.editor.undo')"
67
+ icon="i-lucide-undo-2"
68
+ color="neutral"
69
+ variant="ghost"
70
+ size="xs"
71
+ @click="editor.undo()"
72
+ />
73
+ </UTooltip>
74
+ <UTooltip :text="t('docs.editor.redo')" :kbds="['meta', 'shift', 'Z']">
75
+ <UButton
76
+ :disabled="disabled || !editor.canRedo.value"
77
+ :aria-label="t('docs.editor.redo')"
78
+ icon="i-lucide-redo-2"
79
+ color="neutral"
80
+ variant="ghost"
81
+ size="xs"
82
+ @click="editor.redo()"
83
+ />
84
+ </UTooltip>
85
+ <UButton
86
+ :label="t('docs.editor.add')"
87
+ :disabled="disabled"
88
+ :aria-label="t('docs.editor.addBlock')"
89
+ icon="i-lucide-plus"
90
+ color="primary"
91
+ variant="soft"
92
+ size="xs"
93
+ @click="editor.openPalette()"
94
+ />
95
+ <!-- Hides this panel (desktop). The core toolbar shows the toggle to open it again. -->
96
+ <PlutoViewLeftCollapse />
97
+ </div>
98
+ </div>
99
+
100
+ <ScrollAreaRoot
101
+ type="hover"
102
+ class="relative min-h-0 flex-1 overflow-hidden"
103
+ >
104
+ <ScrollAreaViewport class="size-full">
105
+ <div class="p-2">
106
+ <PlutoBlockOutlineList
107
+ v-if="editor.doc.value.blocks.length"
108
+ :blocks="editor.doc.value.blocks"
109
+ />
110
+ <UEmpty
111
+ v-else
112
+ :title="t('docs.editor.emptyTitle')"
113
+ :description="t('docs.editor.emptyDescription')"
114
+ :actions="[
115
+ {
116
+ label: t('docs.editor.addBlock'),
117
+ icon: 'i-lucide-plus',
118
+ onClick: () => editor.openPalette(),
119
+ },
120
+ ]"
121
+ icon="i-lucide-blocks"
122
+ variant="naked"
123
+ />
124
+ </div>
125
+ </ScrollAreaViewport>
126
+ <ScrollAreaScrollbar
127
+ orientation="vertical"
128
+ class="flex w-2.5 touch-none p-0.5 select-none"
129
+ >
130
+ <ScrollAreaThumb
131
+ class="relative flex-1 rounded-full bg-(--ui-border-accented) hover:bg-(--ui-text-dimmed)"
132
+ />
133
+ </ScrollAreaScrollbar>
134
+ </ScrollAreaRoot>
135
+
136
+ <div
137
+ class="flex shrink-0 items-center justify-between gap-2 border-t border-default px-3 py-2"
138
+ >
139
+ <PlutoBlockModeSwitch
140
+ :mode="mode"
141
+ :disabled="disabled"
142
+ @update:mode="emit('update:mode', $event)"
143
+ />
144
+ </div>
145
+ </div>
146
+ </template>
@@ -0,0 +1,347 @@
1
+ <script setup lang="ts">
2
+ // One list of the outline: the blocks of the page, or the children of one
3
+ // slot of a component. It nests itself for the children. Each list is its own
4
+ // SortableJS group, so a block moves inside its list only (same level).
5
+ // Drag is for the mouse and touch. The keyboard uses Alt+Up and Alt+Down, and
6
+ // the menu of the row.
7
+ import type { PlutoBlock } from '../../../../../shared/utils/blocks/types'
8
+ import { resolveSlotMode } from '../../../../../shared/utils/blocks/editor'
9
+
10
+ const props = defineProps<{
11
+ blocks: PlutoBlock[]
12
+ parentId?: string
13
+ slotName?: string
14
+ level?: number
15
+ }>()
16
+
17
+ const editor = usePlutoBlockEditor()
18
+ const { t, tx } = usePlutoI18n()
19
+ const { slotLabel, addLabel } = usePlutoBlockLabels()
20
+
21
+ const list = useTemplateRef<HTMLElement>('list')
22
+ const level = computed(() => props.level ?? 1)
23
+
24
+ function info(block: PlutoBlock) {
25
+ const described = editor.describe(block)
26
+ return { ...described, label: tx(described.label) }
27
+ }
28
+
29
+ /** One slot of a component, with its children, in the outline. */
30
+ interface PlutoBlockChildList {
31
+ /** The slot name, for example `default`. */
32
+ slot: string
33
+ blocks: PlutoBlock[]
34
+ /** The label of the slot. */
35
+ title: string
36
+ /** The label of the "Add" button of the slot. */
37
+ add: string
38
+ }
39
+
40
+ /** The slots of a component that show their children in the outline. */
41
+ // A row keeps a column for the chevron only when a block of this list has
42
+ // children. A list of leaves (often the cards of a slot) has no empty column,
43
+ // so its titles do not move to the right for nothing.
44
+ const hasChevrons = computed(() =>
45
+ props.blocks.some((block) => childLists(block).length > 0)
46
+ )
47
+
48
+ function childLists(block: PlutoBlock): PlutoBlockChildList[] {
49
+ if (block.kind !== 'component') {
50
+ return []
51
+ }
52
+ const definition = editor.definitionOf(block)
53
+ return Object.entries(block.slots)
54
+ .filter(
55
+ ([slot]) =>
56
+ resolveSlotMode(block, slot, definition?.slots?.[slot]?.content) ===
57
+ 'blocks'
58
+ )
59
+ .map(([slot, blocks]) => ({
60
+ slot,
61
+ blocks,
62
+ title: slotLabel(definition, slot),
63
+ add: addLabel(definition, slot),
64
+ }))
65
+ .filter((entry) => entry.blocks.length > 0)
66
+ }
67
+
68
+ function menu(block: PlutoBlock, index: number) {
69
+ const siblings = editor.siblings(block.id)
70
+ return [
71
+ [
72
+ {
73
+ label: t('docs.editor.moveUp'),
74
+ icon: 'i-lucide-arrow-up',
75
+ disabled: index === 0,
76
+ onSelect: () => editor.move(block.id, -1),
77
+ },
78
+ {
79
+ label: t('docs.editor.moveDown'),
80
+ icon: 'i-lucide-arrow-down',
81
+ disabled: index >= siblings.length - 1,
82
+ onSelect: () => editor.move(block.id, 1),
83
+ },
84
+ {
85
+ label: t('docs.editor.addBelow'),
86
+ icon: 'i-lucide-plus',
87
+ onSelect: () =>
88
+ editor.openPalette({
89
+ parentId: props.parentId,
90
+ slot: props.slotName,
91
+ index: index + 1,
92
+ }),
93
+ },
94
+ {
95
+ label: t('docs.editor.duplicate'),
96
+ icon: 'i-lucide-copy',
97
+ onSelect: () => editor.duplicate(block.id),
98
+ },
99
+ ],
100
+ [
101
+ {
102
+ label: t('docs.editor.delete'),
103
+ icon: 'i-lucide-trash-2',
104
+ color: 'error' as const,
105
+ onSelect: () => editor.remove(block.id),
106
+ },
107
+ ],
108
+ ]
109
+ }
110
+
111
+ function focusRow(from: HTMLElement, delta: number) {
112
+ const root = from.closest<HTMLElement>('[data-block-outline]')
113
+ const rows = [
114
+ ...(root?.querySelectorAll<HTMLElement>('[data-block-row]') ?? []),
115
+ ]
116
+ rows[rows.indexOf(from) + delta]?.focus()
117
+ }
118
+
119
+ function onKeydown(event: KeyboardEvent, block: PlutoBlock) {
120
+ const target = event.currentTarget as HTMLElement
121
+ if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
122
+ event.preventDefault()
123
+ editor.move(block.id, event.key === 'ArrowUp' ? -1 : 1)
124
+ // The row renders in its new place. Keep the focus on it.
125
+ nextTick(() =>
126
+ document
127
+ .querySelector<HTMLElement>(
128
+ `[data-block-row="${CSS.escape(block.id)}"]`
129
+ )
130
+ ?.focus()
131
+ )
132
+ return
133
+ }
134
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
135
+ event.preventDefault()
136
+ focusRow(target, event.key === 'ArrowUp' ? -1 : 1)
137
+ } else if (
138
+ (event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
139
+ childLists(block).length
140
+ ) {
141
+ // Like a tree: Left closes the children, Right opens them.
142
+ const collapsed = editor.collapsedIds.value.has(block.id)
143
+ if (collapsed === (event.key === 'ArrowRight')) {
144
+ event.preventDefault()
145
+ editor.toggleCollapsed(block.id)
146
+ }
147
+ } else if (event.key === 'Delete') {
148
+ event.preventDefault()
149
+ editor.remove(block.id)
150
+ // The row is gone. The focus goes to the block that the editor selects next, so Undo keeps working.
151
+ nextTick(() => {
152
+ const next = editor.selectedId.value
153
+ document
154
+ .querySelector<HTMLElement>(
155
+ next ? `[data-block-row="${CSS.escape(next)}"]` : '[data-block-row]'
156
+ )
157
+ ?.focus()
158
+ })
159
+ }
160
+ }
161
+
162
+ let sortable: { destroy: () => void } | undefined
163
+
164
+ onMounted(async () => {
165
+ const { default: Sortable } = await import('sortablejs')
166
+ if (!list.value) {
167
+ return
168
+ }
169
+ sortable = Sortable.create(list.value, {
170
+ handle: '[data-block-handle]',
171
+ draggable: 'li',
172
+ animation: 150,
173
+ ghostClass: 'opacity-40',
174
+ // Mouse events instead of the browser drag and drop: the same on every browser and on touch.
175
+ forceFallback: true,
176
+ fallbackOnBody: true,
177
+ // A list is its own group, so a block cannot leave it.
178
+ group: `blocks:${props.parentId ?? 'root'}:${props.slotName ?? 'default'}`,
179
+ onEnd(event) {
180
+ const { oldIndex, newIndex, item, from } = event
181
+ if (
182
+ oldIndex === undefined ||
183
+ newIndex === undefined ||
184
+ oldIndex === newIndex
185
+ ) {
186
+ return
187
+ }
188
+ // SortableJS moved the DOM node. Put it back and let Vue render the new order.
189
+ item.remove()
190
+ from.insertBefore(item, from.children[oldIndex] ?? null)
191
+ const block = props.blocks[oldIndex]
192
+ if (block) {
193
+ editor.moveTo(block.id, {
194
+ parentId: props.parentId,
195
+ slot: props.slotName,
196
+ index: newIndex,
197
+ })
198
+ }
199
+ },
200
+ })
201
+ })
202
+
203
+ onBeforeUnmount(() => sortable?.destroy())
204
+ </script>
205
+
206
+ <template>
207
+ <!--
208
+ A row: the drag handle (16px), the chevron (16px, only in a list with children), the icon,
209
+ the title, the dots menu. The vertical line of a child list sits under the center of the
210
+ chevron: 2px row padding + 16px handle + 2px gap + 8px = 28px (`ms-7`).
211
+
212
+ The drag handle and the dots menu show on row hover. They keep their place (opacity only), so
213
+ the rows do not move. A device without hover, the keyboard focus and an open menu keep them
214
+ visible.
215
+ -->
216
+ <ul ref="list" :data-level="level" class="flex flex-col gap-0.5">
217
+ <li v-for="(block, index) in blocks" :key="block.id">
218
+ <div
219
+ :class="
220
+ editor.selectedId.value === block.id
221
+ ? 'bg-primary/10 ring-1 ring-inset ring-primary/40'
222
+ : editor.hoverId.value === block.id
223
+ ? 'bg-elevated'
224
+ : 'hover:bg-elevated/60'
225
+ "
226
+ class="group flex items-center gap-0.5 rounded-md pe-0.5 ps-0.5 transition-colors"
227
+ >
228
+ <span
229
+ class="flex h-8 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded text-dimmed opacity-60 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 active:cursor-grabbing [@media(hover:hover)]:opacity-0"
230
+ aria-hidden="true"
231
+ data-block-handle
232
+ >
233
+ <UIcon name="i-lucide-grip-vertical" class="size-4" />
234
+ </span>
235
+
236
+ <!-- Opens and closes the children of a component. A block with no children keeps the space. -->
237
+ <button
238
+ v-if="childLists(block).length"
239
+ :aria-expanded="!editor.collapsedIds.value.has(block.id)"
240
+ :aria-label="
241
+ t(
242
+ editor.collapsedIds.value.has(block.id)
243
+ ? 'docs.editor.expand'
244
+ : 'docs.editor.collapse',
245
+ { name: info(block).label }
246
+ )
247
+ "
248
+ type="button"
249
+ class="flex size-4 shrink-0 items-center justify-center rounded text-muted hover:text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
250
+ @click="editor.toggleCollapsed(block.id)"
251
+ >
252
+ <UIcon
253
+ :class="editor.collapsedIds.value.has(block.id) ? '' : 'rotate-90'"
254
+ name="i-lucide-chevron-right"
255
+ class="size-4 transition-transform"
256
+ />
257
+ </button>
258
+ <span
259
+ v-else-if="hasChevrons"
260
+ class="size-4 shrink-0"
261
+ aria-hidden="true"
262
+ />
263
+
264
+ <button
265
+ :data-block-row="block.id"
266
+ :aria-current="
267
+ editor.selectedId.value === block.id ? 'true' : undefined
268
+ "
269
+ type="button"
270
+ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown ArrowLeft ArrowRight Delete"
271
+ class="flex min-h-8 min-w-0 flex-1 items-center gap-2 rounded p-1 text-start text-sm focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
272
+ @click="editor.select(block.id)"
273
+ @keydown="onKeydown($event, block)"
274
+ >
275
+ <UIcon :name="info(block).icon" class="size-4 shrink-0 text-muted" />
276
+ <span class="min-w-0 flex-1 truncate">
277
+ <span class="text-highlighted">{{ info(block).label }}</span>
278
+ <span v-if="info(block).excerpt" class="text-muted">
279
+ : {{ info(block).excerpt }}
280
+ </span>
281
+ </span>
282
+ <span
283
+ v-if="editor.editedIds.value.has(block.id)"
284
+ :aria-label="t('docs.editor.edited')"
285
+ :title="t('docs.editor.edited')"
286
+ class="size-1.5 shrink-0 rounded-full bg-primary"
287
+ role="img"
288
+ />
289
+ </button>
290
+
291
+ <UDropdownMenu :items="menu(block, index)" :content="{ align: 'end' }">
292
+ <UButton
293
+ :aria-label="
294
+ t('docs.editor.blockActions', { name: info(block).label })
295
+ "
296
+ icon="i-lucide-ellipsis"
297
+ color="neutral"
298
+ variant="ghost"
299
+ size="xs"
300
+ class="shrink-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100 [@media(hover:hover)]:opacity-0"
301
+ />
302
+ </UDropdownMenu>
303
+ </div>
304
+
305
+ <div
306
+ v-if="
307
+ childLists(block).length && !editor.collapsedIds.value.has(block.id)
308
+ "
309
+ class="ms-7 border-s border-default ps-1"
310
+ >
311
+ <div
312
+ v-for="child in childLists(block)"
313
+ :key="child.slot"
314
+ class="pt-0.5"
315
+ >
316
+ <p
317
+ v-if="childLists(block).length > 1"
318
+ class="px-1 pt-1 text-xs font-medium text-muted"
319
+ >
320
+ {{ child.title }}
321
+ </p>
322
+ <PlutoBlockOutlineList
323
+ :blocks="child.blocks"
324
+ :parent-id="block.id"
325
+ :slot-name="child.slot"
326
+ :level="level + 1"
327
+ />
328
+ <UButton
329
+ :label="child.add"
330
+ icon="i-lucide-plus"
331
+ color="neutral"
332
+ variant="link"
333
+ size="xs"
334
+ class="ms-5"
335
+ @click="
336
+ editor.openPalette({
337
+ parentId: block.id,
338
+ slot: child.slot,
339
+ index: child.blocks.length,
340
+ })
341
+ "
342
+ />
343
+ </div>
344
+ </div>
345
+ </li>
346
+ </ul>
347
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ // The palette: "Add a block". A search box and the blocks in groups, each with
3
+ // an icon, a name and a one-line description. A choice inserts the block with
4
+ // friendly content and selects it. The editor state holds the open flag and
5
+ // the target, so the outline, the inspector and a shortcut can open it.
6
+ import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
7
+ import {
8
+ acceptedNames,
9
+ buildPalette,
10
+ } from '../../../../../shared/utils/blocks/editor'
11
+ import { groupLabelKey } from '../../../../../shared/utils/blocks/presets'
12
+ import { findBlock } from '../../../../../shared/utils/blocks/tree'
13
+
14
+ const editor = usePlutoBlockEditor()
15
+ const { t, tx } = usePlutoI18n()
16
+ const blocksConfig = usePlutoBlocksConfig()
17
+
18
+ const open = computed({
19
+ get: () => editor.palette.value.open,
20
+ set: (value) => {
21
+ editor.palette.value = { ...editor.palette.value, open: value }
22
+ },
23
+ })
24
+
25
+ /** A target inside a component that takes only some children shows only those blocks. */
26
+ const accepts = computed(() => {
27
+ const target = editor.palette.value.target
28
+ if (target?.parentId === undefined) {
29
+ return undefined
30
+ }
31
+ const parent = editor.doc.value.blocks.length
32
+ ? findBlockById(target.parentId)
33
+ : undefined
34
+ const definition = parent ? editor.definitionOf(parent) : undefined
35
+ return definition
36
+ ? acceptedNames(definition, target.slot ?? 'default')
37
+ : undefined
38
+ })
39
+
40
+ function findBlockById(id: string) {
41
+ const found = findBlock(editor.doc.value, id)
42
+ return found?.block
43
+ }
44
+
45
+ const groups = computed<CommandPaletteGroup[]>(() =>
46
+ buildPalette(blocksConfig(), accepts.value).map(({ group, entries }) => ({
47
+ id: group,
48
+ label: tx(groupLabelKey(group)),
49
+ items: entries.map((entry): CommandPaletteItem => ({
50
+ label: tx(entry.label),
51
+ suffix: entry.description ? tx(entry.description) : undefined,
52
+ icon: entry.icon,
53
+ onSelect: () => {
54
+ editor.insert(entry, editor.palette.value.target)
55
+ open.value = false
56
+ },
57
+ })),
58
+ }))
59
+ )
60
+ </script>
61
+
62
+ <template>
63
+ <UModal
64
+ v-model:open="open"
65
+ :title="t('docs.editor.addBlock')"
66
+ :description="t('docs.editor.addBlockDescription')"
67
+ :ui="{ content: 'max-w-xl', header: 'sr-only' }"
68
+ >
69
+ <template #content>
70
+ <UCommandPalette
71
+ :groups="groups"
72
+ :placeholder="t('docs.editor.searchBlocks')"
73
+ :close="true"
74
+ class="h-112 max-h-[80dvh]"
75
+ @update:open="open = $event"
76
+ >
77
+ <template #empty>
78
+ <p class="px-4 py-6 text-center text-sm text-muted">
79
+ {{ t('docs.editor.noBlocks') }}
80
+ </p>
81
+ </template>
82
+ </UCommandPalette>
83
+ </template>
84
+ </UModal>
85
+ </template>