@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.
- package/.gitattributes +1 -0
- package/.prettierignore +5 -0
- package/CHANGELOG.md +178 -0
- package/FEATURES.md +30 -0
- package/README.md +39 -2
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +85 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
- package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
- package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
- package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
- package/app/components/PlutoContentPreview.vue +35 -0
- package/app/composables/pluto-block-editor.ts +443 -0
- package/app/composables/pluto-content-edit-link.ts +19 -0
- package/app/composables/pluto-content-preview.ts +29 -0
- package/app/composables/pluto-content-runtime.ts +237 -0
- package/app/pages/_pluto/preview.vue +555 -0
- package/app/plugins/pluto-docs.ts +50 -0
- package/i18n/locales/en.json +253 -0
- package/i18n/locales/pt-BR.json +289 -0
- package/modules/pluto-docs/index.ts +123 -0
- package/modules/pluto-docs/manifest.ts +159 -0
- package/modules/pluto-docs/options.ts +224 -0
- package/nuxt.config.ts +36 -0
- package/package.json +55 -4
- package/server/handlers/navigation.get.ts +7 -0
- package/server/handlers/page.get.ts +7 -0
- package/server/handlers/surround.get.ts +7 -0
- package/server/plugins/pluto-docs.ts +25 -0
- package/server/utils/pluto-docs-runtime.ts +504 -0
- package/shared/types/docs.d.ts +40 -0
- package/shared/types/pluto-manifest.d.ts +6 -0
- package/shared/utils/blocks/editor.ts +357 -0
- package/shared/utils/blocks/history.ts +94 -0
- package/shared/utils/blocks/mdc-props.ts +321 -0
- package/shared/utils/blocks/operations.ts +692 -0
- package/shared/utils/blocks/parse.ts +444 -0
- package/shared/utils/blocks/presets.ts +383 -0
- package/shared/utils/blocks/preview-marks.ts +98 -0
- package/shared/utils/blocks/preview-protocol.ts +195 -0
- package/shared/utils/blocks/registry.ts +163 -0
- package/shared/utils/blocks/serialize.ts +107 -0
- package/shared/utils/blocks/tree.ts +85 -0
- package/shared/utils/blocks/types.ts +165 -0
- package/shared/utils/content-route.ts +40 -0
- package/shared/utils/docs-type.ts +290 -0
- package/shared/utils/mdc-snippets.ts +114 -0
- package/shared/utils/navigation-config.ts +19 -0
- package/shared/utils/runtime-content.ts +403 -0
- package/tailwind.lint.css +10 -0
- package/tsconfig.json +18 -0
- 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>
|