@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,233 @@
1
+ <script setup lang="ts">
2
+ import type { PlutoPreviewChild } from '../../../../../shared/utils/blocks/preview-protocol'
3
+ import {
4
+ previewSelectionId,
5
+ toPreviewBlocks,
6
+ } from '../../../../../shared/utils/blocks/editor'
7
+ // The live preview of the page: an iframe of `/_pluto/preview` on the same
8
+ // origin. This component sends the blocks to the frame (after 150 ms of
9
+ // quiet), and tells the editor when a block is clicked or hovered in the frame.
10
+ // Both sides check the origin and the shape of every message.
11
+ import {
12
+ isTrustedPreviewOrigin,
13
+ parsePreviewMessage,
14
+ PREVIEW_PATH,
15
+ previewMessage,
16
+ } from '../../../../../shared/utils/blocks/preview-protocol'
17
+
18
+ const props = defineProps<{
19
+ frontmatter: Record<string, unknown>
20
+ }>()
21
+
22
+ const editor = usePlutoBlockEditor()
23
+ const { t, tx } = usePlutoI18n()
24
+ const router = useRouter()
25
+ const blocksConfig = usePlutoBlocksConfig()
26
+
27
+ const DEBOUNCE_MS = 150
28
+ const LOAD_TIMEOUT_MS = 12000
29
+
30
+ const frame = useTemplateRef<HTMLIFrameElement>('frame')
31
+ const ready = ref(false)
32
+ const timedOut = ref(false)
33
+ const frameKey = ref(0)
34
+ const frameError = ref<string | null>(null)
35
+
36
+ const src = computed(() => router.resolve(PREVIEW_PATH).href)
37
+ // The preview marks the top-level blocks and their inner components. So the selection it shows
38
+ // is the deepest component on the path of the selected block (a text inside a card shows the card).
39
+ const shownId = computed(() =>
40
+ previewSelectionId(editor.doc.value, editor.selectedId.value)
41
+ )
42
+ const blocks = computed(() =>
43
+ toPreviewBlocks(editor.doc.value.blocks, blocksConfig())
44
+ )
45
+
46
+ function send(message: ReturnType<typeof previewMessage>) {
47
+ frame.value?.contentWindow?.postMessage(message, window.location.origin)
48
+ }
49
+
50
+ /** The inner components with a readable label. */
51
+ function translateChildren(
52
+ children: PlutoPreviewChild[] | undefined
53
+ ): PlutoPreviewChild[] | undefined {
54
+ return children?.map((child) => ({
55
+ ...child,
56
+ label: child.label ? tx(child.label) : '',
57
+ children: translateChildren(child.children),
58
+ }))
59
+ }
60
+
61
+ /** The blocks with a readable label: the label of a component is an i18n key or a plain text. */
62
+ function messageBlocks() {
63
+ return blocks.value.map((block) => {
64
+ const source = editor.doc.value.blocks.find(
65
+ (candidate) => candidate.id === block.id
66
+ )
67
+ return {
68
+ ...block,
69
+ label: block.label
70
+ ? tx(block.label)
71
+ : source
72
+ ? tx(editor.describe(source).label)
73
+ : '',
74
+ ...(block.children
75
+ ? { children: translateChildren(block.children) }
76
+ : {}),
77
+ }
78
+ })
79
+ }
80
+
81
+ function sendRender() {
82
+ // A nested value of the form is a reactive proxy, and `postMessage` cannot clone a proxy.
83
+ // The front matter is YAML data, so a JSON copy is a plain copy.
84
+ const frontmatter = JSON.parse(JSON.stringify(props.frontmatter)) as Record<
85
+ string,
86
+ unknown
87
+ >
88
+ send(
89
+ previewMessage('render', {
90
+ frontmatter,
91
+ blocks: messageBlocks(),
92
+ selectedId: shownId.value,
93
+ })
94
+ )
95
+ }
96
+
97
+ let timer: ReturnType<typeof setTimeout> | undefined
98
+ function scheduleRender() {
99
+ clearTimeout(timer)
100
+ timer = setTimeout(() => {
101
+ if (ready.value) {
102
+ sendRender()
103
+ }
104
+ }, DEBOUNCE_MS)
105
+ }
106
+
107
+ watch([blocks, () => props.frontmatter], scheduleRender, { deep: false })
108
+
109
+ // The selection comes from the outline or the panel: draw it and scroll to it.
110
+ watch(shownId, (id) => {
111
+ if (!ready.value) {
112
+ return
113
+ }
114
+ send(previewMessage('select', { id }))
115
+ if (id && editor.selection.value?.source === 'ui') {
116
+ send(previewMessage('scroll-to', { id }))
117
+ }
118
+ })
119
+
120
+ function onMessage(event: MessageEvent) {
121
+ if (
122
+ event.source !== frame.value?.contentWindow ||
123
+ !isTrustedPreviewOrigin(event.origin, window.location.origin)
124
+ ) {
125
+ return
126
+ }
127
+ const message = parsePreviewMessage(event.data, 'from-frame')
128
+ if (!message) {
129
+ return
130
+ }
131
+ switch (message.type) {
132
+ case 'ready':
133
+ ready.value = true
134
+ timedOut.value = false
135
+ frameError.value = null
136
+ // The first render goes at once.
137
+ sendRender()
138
+ break
139
+ case 'select':
140
+ editor.select(message.id, 'preview')
141
+ break
142
+ case 'hover':
143
+ editor.hoverId.value = message.id
144
+ break
145
+ case 'error':
146
+ frameError.value = message.message
147
+ break
148
+ }
149
+ }
150
+
151
+ let loadTimer: ReturnType<typeof setTimeout> | undefined
152
+ function watchLoad() {
153
+ clearTimeout(loadTimer)
154
+ ready.value = false
155
+ timedOut.value = false
156
+ loadTimer = setTimeout(() => {
157
+ if (!ready.value) {
158
+ timedOut.value = true
159
+ }
160
+ }, LOAD_TIMEOUT_MS)
161
+ }
162
+
163
+ function reload() {
164
+ frameKey.value++
165
+ watchLoad()
166
+ }
167
+
168
+ onMounted(() => {
169
+ window.addEventListener('message', onMessage)
170
+ watchLoad()
171
+ })
172
+
173
+ onBeforeUnmount(() => {
174
+ window.removeEventListener('message', onMessage)
175
+ clearTimeout(timer)
176
+ clearTimeout(loadTimer)
177
+ })
178
+
179
+ defineExpose({ reload })
180
+ </script>
181
+
182
+ <template>
183
+ <div class="relative size-full min-h-0 bg-muted">
184
+ <iframe
185
+ ref="frame"
186
+ :key="frameKey"
187
+ :src="src"
188
+ :title="t('docs.editor.previewTitle')"
189
+ class="size-full border-0 bg-default"
190
+ loading="eager"
191
+ />
192
+
193
+ <!--
194
+ While the preview loads: the shape of a page (core's canvas skeleton) and a small status
195
+ pill at the bottom, so the frame does not flash empty.
196
+ -->
197
+ <div
198
+ v-if="!ready && !timedOut"
199
+ class="pointer-events-none absolute inset-0 flex flex-col bg-default"
200
+ role="status"
201
+ >
202
+ <PlutoSkeletonCanvas />
203
+ <div class="absolute inset-x-0 bottom-6 flex justify-center">
204
+ <div
205
+ class="flex items-center gap-2 rounded-full bg-elevated px-3.5 py-1.5 text-xs font-medium text-muted shadow-sm ring ring-default"
206
+ >
207
+ <UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
208
+ {{ t('docs.editor.previewLoading') }}
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <div
214
+ v-if="timedOut"
215
+ class="absolute inset-0 flex items-center justify-center bg-default p-6"
216
+ >
217
+ <UEmpty
218
+ :title="t('docs.editor.previewFailedTitle')"
219
+ :description="t('docs.editor.previewFailed')"
220
+ :actions="[
221
+ {
222
+ label: t('docs.editor.previewRetry'),
223
+ icon: 'i-lucide-refresh-cw',
224
+ color: 'neutral',
225
+ variant: 'outline',
226
+ onClick: reload,
227
+ },
228
+ ]"
229
+ icon="i-lucide-triangle-alert"
230
+ />
231
+ </div>
232
+ </div>
233
+ </template>
@@ -0,0 +1,246 @@
1
+ <script setup lang="ts">
2
+ // One setting of a component: a field with the right control for the type of
3
+ // the prop (text, long text, switch, number, choice, icon, color, link). A
4
+ // prop with an object or a list shows as YAML. An empty value removes the prop,
5
+ // so the Markdown stays short.
6
+ import type { PlutoBlockPropDefinition } from '../../../../../shared/utils/blocks/types'
7
+ import { parse, stringify } from 'yaml'
8
+ import { humanizeName } from '../../../../../shared/utils/blocks/registry'
9
+
10
+ const props = defineProps<{
11
+ /** The name of the prop in the Markdown. */
12
+ name: string
13
+ definition: PlutoBlockPropDefinition
14
+ modelValue: unknown
15
+ disabled?: boolean
16
+ }>()
17
+
18
+ const emit = defineEmits<{
19
+ /** `undefined` removes the prop. */
20
+ 'update:modelValue': [value: unknown]
21
+ }>()
22
+
23
+ const { t, tx } = usePlutoI18n()
24
+
25
+ const DEFAULT = '__default__'
26
+ type ChipColor =
27
+ 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
28
+ const COLOR_CHIPS: Record<string, ChipColor> = {
29
+ primary: 'primary',
30
+ secondary: 'secondary',
31
+ success: 'success',
32
+ info: 'info',
33
+ warning: 'warning',
34
+ error: 'error',
35
+ neutral: 'neutral',
36
+ }
37
+
38
+ const label = computed(() =>
39
+ props.definition.label ? tx(props.definition.label) : humanizeName(props.name)
40
+ )
41
+ const description = computed(() =>
42
+ props.definition.description ? tx(props.definition.description) : undefined
43
+ )
44
+
45
+ const isStructured = computed(
46
+ () => typeof props.modelValue === 'object' && props.modelValue !== null
47
+ )
48
+ const type = computed(() =>
49
+ isStructured.value ? 'yaml' : props.definition.type
50
+ )
51
+
52
+ const text = computed(() =>
53
+ props.modelValue === undefined || props.modelValue === null
54
+ ? ''
55
+ : String(props.modelValue)
56
+ )
57
+
58
+ function setText(value: string | undefined) {
59
+ emit(
60
+ 'update:modelValue',
61
+ value === undefined || value === '' ? undefined : value
62
+ )
63
+ }
64
+
65
+ function setBoolean(value: boolean) {
66
+ // Off removes the prop. An explicit `false` in the source stays false.
67
+ emit(
68
+ 'update:modelValue',
69
+ value ? true : props.modelValue === false ? false : undefined
70
+ )
71
+ }
72
+
73
+ const numberValue = computed(() => {
74
+ const value =
75
+ typeof props.modelValue === 'number'
76
+ ? props.modelValue
77
+ : Number(props.modelValue)
78
+ return props.modelValue === undefined || Number.isNaN(value)
79
+ ? undefined
80
+ : value
81
+ })
82
+
83
+ const selectItems = computed(() => {
84
+ const items = (props.definition.options ?? []).map((option) => {
85
+ const value = typeof option === 'string' ? option : option.value
86
+ const text = typeof option === 'string' ? option : option.label
87
+ return { value, label: text ? tx(text) : value }
88
+ })
89
+ const current = text.value
90
+ if (current && !items.some((item) => item.value === current)) {
91
+ items.push({ value: current, label: current })
92
+ }
93
+ return [{ value: DEFAULT, label: t('docs.editor.default') }, ...items]
94
+ })
95
+
96
+ const colorItems = computed(() =>
97
+ selectItems.value.map((item) =>
98
+ COLOR_CHIPS[item.value]
99
+ ? { ...item, chip: { color: COLOR_CHIPS[item.value] } }
100
+ : item
101
+ )
102
+ )
103
+
104
+ function setSelect(value: string) {
105
+ emit('update:modelValue', value === DEFAULT ? undefined : value)
106
+ }
107
+
108
+ // YAML for an object or a list. The text applies when the box loses the focus and the YAML is valid.
109
+ const yamlDraft = ref(
110
+ isStructured.value ? stringify(props.modelValue).trimEnd() : ''
111
+ )
112
+ const yamlError = ref<string | null>(null)
113
+
114
+ watch(
115
+ () => props.modelValue,
116
+ (value) => {
117
+ if (typeof value === 'object' && value !== null) {
118
+ try {
119
+ if (JSON.stringify(parse(yamlDraft.value)) === JSON.stringify(value)) {
120
+ return
121
+ }
122
+ } catch {
123
+ return
124
+ }
125
+ yamlDraft.value = stringify(value).trimEnd()
126
+ }
127
+ }
128
+ )
129
+
130
+ function applyYaml() {
131
+ try {
132
+ const value =
133
+ yamlDraft.value.trim() === '' ? undefined : parse(yamlDraft.value)
134
+ yamlError.value = null
135
+ emit('update:modelValue', value)
136
+ } catch (error) {
137
+ yamlError.value =
138
+ error instanceof Error ? error.message.split('\n')[0]! : String(error)
139
+ }
140
+ }
141
+
142
+ const iconPreview = computed(() =>
143
+ text.value.startsWith('i-') || text.value.includes(':')
144
+ ? text.value
145
+ : undefined
146
+ )
147
+ </script>
148
+
149
+ <template>
150
+ <UFormField
151
+ :label="label"
152
+ :description="description"
153
+ :error="yamlError ?? undefined"
154
+ :help="type === 'icon' ? t('docs.editor.iconHelp') : undefined"
155
+ class="w-full"
156
+ >
157
+ <USwitch
158
+ v-if="type === 'boolean'"
159
+ :model-value="modelValue === true || modelValue === 'true'"
160
+ :disabled="disabled"
161
+ :aria-label="label"
162
+ @update:model-value="setBoolean($event)"
163
+ />
164
+
165
+ <UInputNumber
166
+ v-else-if="type === 'number'"
167
+ :model-value="numberValue"
168
+ :disabled="disabled"
169
+ class="w-full"
170
+ @update:model-value="emit('update:modelValue', $event ?? undefined)"
171
+ />
172
+
173
+ <USelect
174
+ v-else-if="type === 'select'"
175
+ :model-value="text || DEFAULT"
176
+ :items="selectItems"
177
+ :disabled="disabled"
178
+ value-key="value"
179
+ class="w-full"
180
+ @update:model-value="setSelect($event as string)"
181
+ />
182
+
183
+ <USelect
184
+ v-else-if="type === 'color'"
185
+ :model-value="text || DEFAULT"
186
+ :items="colorItems"
187
+ :disabled="disabled"
188
+ value-key="value"
189
+ class="w-full"
190
+ @update:model-value="setSelect($event as string)"
191
+ />
192
+
193
+ <UTextarea
194
+ v-else-if="type === 'text'"
195
+ :model-value="text"
196
+ :disabled="disabled"
197
+ :rows="2"
198
+ class="w-full"
199
+ autoresize
200
+ @update:model-value="setText($event as string)"
201
+ />
202
+
203
+ <UTextarea
204
+ v-else-if="type === 'yaml'"
205
+ v-model="yamlDraft"
206
+ :disabled="disabled"
207
+ :rows="4"
208
+ :ui="{ base: 'font-mono text-xs' }"
209
+ class="w-full"
210
+ spellcheck="false"
211
+ autoresize
212
+ @blur="applyYaml"
213
+ />
214
+
215
+ <UInput
216
+ v-else-if="type === 'icon'"
217
+ :model-value="text"
218
+ :disabled="disabled"
219
+ :icon="iconPreview"
220
+ placeholder="i-lucide-rocket"
221
+ spellcheck="false"
222
+ autocomplete="off"
223
+ class="w-full"
224
+ @update:model-value="setText($event as string)"
225
+ />
226
+
227
+ <UInput
228
+ v-else-if="type === 'link'"
229
+ :model-value="text"
230
+ :disabled="disabled"
231
+ :placeholder="t('docs.editor.linkPlaceholder')"
232
+ icon="i-lucide-link"
233
+ autocomplete="off"
234
+ class="w-full"
235
+ @update:model-value="setText($event as string)"
236
+ />
237
+
238
+ <UInput
239
+ v-else
240
+ :model-value="text"
241
+ :disabled="disabled"
242
+ class="w-full"
243
+ @update:model-value="setText($event as string)"
244
+ />
245
+ </UFormField>
246
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ // A Markdown box for one block that the visual editor does not edit as text:
3
+ // a table, HTML, a footnote, or any block that the author wants to edit as
4
+ // Markdown. `live` writes each key press (a Markdown block). Without it, the
5
+ // box has an Apply button, because a block of another kind is parsed again
6
+ // when the text changes.
7
+ const props = defineProps<{
8
+ modelValue: string
9
+ disabled?: boolean
10
+ label: string
11
+ live?: boolean
12
+ rows?: number
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'update:modelValue': [value: string]
17
+ }>()
18
+
19
+ const { t } = usePlutoI18n()
20
+
21
+ const draft = ref(props.modelValue)
22
+
23
+ watch(
24
+ () => props.modelValue,
25
+ (value) => {
26
+ if (props.live || value !== draft.value) {
27
+ draft.value = value
28
+ }
29
+ }
30
+ )
31
+
32
+ function update(value: string) {
33
+ draft.value = value
34
+ if (props.live) {
35
+ emit('update:modelValue', value)
36
+ }
37
+ }
38
+
39
+ const changed = computed(() => draft.value !== props.modelValue)
40
+ </script>
41
+
42
+ <template>
43
+ <div class="flex flex-col gap-2">
44
+ <PlutoMarkdownSource
45
+ :model-value="draft"
46
+ :disabled="disabled"
47
+ :aria-label="label"
48
+ :rows="rows ?? 8"
49
+ @update:model-value="update"
50
+ />
51
+ <div v-if="!live" class="flex justify-end gap-2">
52
+ <UButton
53
+ :label="t('docs.editor.applyMarkdown')"
54
+ :disabled="disabled || !changed"
55
+ size="xs"
56
+ color="primary"
57
+ icon="i-lucide-check"
58
+ @click="emit('update:modelValue', draft)"
59
+ />
60
+ </div>
61
+ </div>
62
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ // A small rich text editor for one Text block, or for one slot of a
3
+ // component. It is the core Nuxt UI editor (Tiptap) with the toolbar of the
4
+ // Pluto Markdown editor (`usePlutoMarkdownToolbar`). The Tiptap history is off:
5
+ // the block editor has one undo stack for the whole page. The value is
6
+ // Markdown. The editor emits only after the author types, so opening a block
7
+ // never rewrites it.
8
+ const props = defineProps<{
9
+ modelValue: string
10
+ disabled?: boolean
11
+ placeholder?: string
12
+ label: string
13
+ /** A shorter box, for a slot such as the text of a note. */
14
+ compact?: boolean
15
+ }>()
16
+
17
+ const emit = defineEmits<{
18
+ 'update:modelValue': [value: string]
19
+ }>()
20
+
21
+ const { t } = usePlutoI18n()
22
+
23
+ // The editor writes the Markdown in its own form when it loads a text (and when the text changes
24
+ // from outside, for example on Undo). That is not an edit. Only an update that follows an action
25
+ // of the author (a key, a click, a paste, a drop) goes up, so opening a block never changes it.
26
+ const touched = ref(false)
27
+ let echo = false
28
+
29
+ watch(
30
+ () => props.modelValue,
31
+ () => {
32
+ echo = true
33
+ nextTick(() => {
34
+ echo = false
35
+ })
36
+ }
37
+ )
38
+
39
+ function onUpdate(value: string) {
40
+ if (touched.value && !echo && value !== props.modelValue) {
41
+ emit('update:modelValue', value)
42
+ }
43
+ }
44
+
45
+ // The first group of the shared toolbar is Undo and Redo. The page has its own.
46
+ const shared = usePlutoMarkdownToolbar({ link: true })
47
+ const items = computed(() => shared.value.slice(1))
48
+ </script>
49
+
50
+ <template>
51
+ <!-- The editor root is a bordered box. A click anywhere in the box puts the cursor in the text. -->
52
+ <div
53
+ class="w-full"
54
+ @focusin="touched = true"
55
+ @keydown.capture="touched = true"
56
+ @pointerdown.capture="touched = true"
57
+ @paste.capture="touched = true"
58
+ @drop.capture="touched = true"
59
+ >
60
+ <UEditor
61
+ v-slot="{ editor }"
62
+ :model-value="modelValue"
63
+ :editable="!disabled"
64
+ :placeholder="placeholder ?? t('docs.editor.textPlaceholder')"
65
+ :starter-kit="{ undoRedo: false }"
66
+ :image="false"
67
+ :mention="false"
68
+ :ui="{
69
+ root: 'w-full',
70
+ content: compact ? 'min-h-24 cursor-text' : 'min-h-48 cursor-text',
71
+ base: compact
72
+ ? 'min-h-24 px-3 py-2 sm:px-3'
73
+ : 'min-h-48 px-3 py-2 sm:px-3',
74
+ }"
75
+ :aria-label="label"
76
+ content-type="markdown"
77
+ class="w-full rounded-md border border-default bg-default"
78
+ @update:model-value="onUpdate($event as string)"
79
+ >
80
+ <UEditorToolbar
81
+ :editor="editor"
82
+ :items="items"
83
+ :aria-label="t('pluto.github.editor.toolbar')"
84
+ class="sticky top-0 z-10 flex-wrap rounded-t-md border-b border-default bg-default px-1.5 py-1"
85
+ >
86
+ <template #link>
87
+ <PlutoGithubEditorLink :editor="editor" />
88
+ </template>
89
+ </UEditorToolbar>
90
+ </UEditor>
91
+ </div>
92
+ </template>