@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,232 @@
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
+
26
+ const DEBOUNCE_MS = 150
27
+ const LOAD_TIMEOUT_MS = 12000
28
+
29
+ const frame = useTemplateRef<HTMLIFrameElement>('frame')
30
+ const ready = ref(false)
31
+ const timedOut = ref(false)
32
+ const frameKey = ref(0)
33
+ const frameError = ref<string | null>(null)
34
+
35
+ const src = computed(() => router.resolve(PREVIEW_PATH).href)
36
+ // The preview marks the top-level blocks and their inner components. So the selection it shows
37
+ // is the deepest component on the path of the selected block (a text inside a card shows the card).
38
+ const shownId = computed(() =>
39
+ previewSelectionId(editor.doc.value, editor.selectedId.value)
40
+ )
41
+ const blocks = computed(() =>
42
+ toPreviewBlocks(editor.doc.value.blocks, editor.sources.value)
43
+ )
44
+
45
+ function send(message: ReturnType<typeof previewMessage>) {
46
+ frame.value?.contentWindow?.postMessage(message, window.location.origin)
47
+ }
48
+
49
+ /** The inner components with a readable label. */
50
+ function translateChildren(
51
+ children: PlutoPreviewChild[] | undefined
52
+ ): PlutoPreviewChild[] | undefined {
53
+ return children?.map((child) => ({
54
+ ...child,
55
+ label: child.label ? tx(child.label) : '',
56
+ children: translateChildren(child.children),
57
+ }))
58
+ }
59
+
60
+ /** The blocks with a readable label: the label of a component is an i18n key or a plain text. */
61
+ function messageBlocks() {
62
+ return blocks.value.map((block) => {
63
+ const source = editor.doc.value.blocks.find(
64
+ (candidate) => candidate.id === block.id
65
+ )
66
+ return {
67
+ ...block,
68
+ label: block.label
69
+ ? tx(block.label)
70
+ : source
71
+ ? tx(editor.describe(source).label)
72
+ : '',
73
+ ...(block.children
74
+ ? { children: translateChildren(block.children) }
75
+ : {}),
76
+ }
77
+ })
78
+ }
79
+
80
+ function sendRender() {
81
+ // A nested value of the form is a reactive proxy, and `postMessage` cannot clone a proxy.
82
+ // The front matter is YAML data, so a JSON copy is a plain copy.
83
+ const frontmatter = JSON.parse(JSON.stringify(props.frontmatter)) as Record<
84
+ string,
85
+ unknown
86
+ >
87
+ send(
88
+ previewMessage('render', {
89
+ frontmatter,
90
+ blocks: messageBlocks(),
91
+ selectedId: shownId.value,
92
+ })
93
+ )
94
+ }
95
+
96
+ let timer: ReturnType<typeof setTimeout> | undefined
97
+ function scheduleRender() {
98
+ clearTimeout(timer)
99
+ timer = setTimeout(() => {
100
+ if (ready.value) {
101
+ sendRender()
102
+ }
103
+ }, DEBOUNCE_MS)
104
+ }
105
+
106
+ watch([blocks, () => props.frontmatter], scheduleRender, { deep: false })
107
+
108
+ // The selection comes from the outline or the panel: draw it and scroll to it.
109
+ watch(shownId, (id) => {
110
+ if (!ready.value) {
111
+ return
112
+ }
113
+ send(previewMessage('select', { id }))
114
+ if (id && editor.selection.value?.source === 'ui') {
115
+ send(previewMessage('scroll-to', { id }))
116
+ }
117
+ })
118
+
119
+ function onMessage(event: MessageEvent) {
120
+ if (
121
+ event.source !== frame.value?.contentWindow ||
122
+ !isTrustedPreviewOrigin(event.origin, window.location.origin)
123
+ ) {
124
+ return
125
+ }
126
+ const message = parsePreviewMessage(event.data, 'from-frame')
127
+ if (!message) {
128
+ return
129
+ }
130
+ switch (message.type) {
131
+ case 'ready':
132
+ ready.value = true
133
+ timedOut.value = false
134
+ frameError.value = null
135
+ // The first render goes at once.
136
+ sendRender()
137
+ break
138
+ case 'select':
139
+ editor.select(message.id, 'preview')
140
+ break
141
+ case 'hover':
142
+ editor.hoverId.value = message.id
143
+ break
144
+ case 'error':
145
+ frameError.value = message.message
146
+ break
147
+ }
148
+ }
149
+
150
+ let loadTimer: ReturnType<typeof setTimeout> | undefined
151
+ function watchLoad() {
152
+ clearTimeout(loadTimer)
153
+ ready.value = false
154
+ timedOut.value = false
155
+ loadTimer = setTimeout(() => {
156
+ if (!ready.value) {
157
+ timedOut.value = true
158
+ }
159
+ }, LOAD_TIMEOUT_MS)
160
+ }
161
+
162
+ function reload() {
163
+ frameKey.value++
164
+ watchLoad()
165
+ }
166
+
167
+ onMounted(() => {
168
+ window.addEventListener('message', onMessage)
169
+ watchLoad()
170
+ })
171
+
172
+ onBeforeUnmount(() => {
173
+ window.removeEventListener('message', onMessage)
174
+ clearTimeout(timer)
175
+ clearTimeout(loadTimer)
176
+ })
177
+
178
+ defineExpose({ reload })
179
+ </script>
180
+
181
+ <template>
182
+ <div class="relative size-full min-h-0 bg-muted">
183
+ <iframe
184
+ ref="frame"
185
+ :key="frameKey"
186
+ :src="src"
187
+ :title="t('docs.editor.previewTitle')"
188
+ class="size-full border-0 bg-default"
189
+ loading="eager"
190
+ />
191
+
192
+ <!--
193
+ While the preview loads: the shape of a page (core's canvas skeleton) and a small status
194
+ pill at the bottom, so the frame does not flash empty.
195
+ -->
196
+ <div
197
+ v-if="!ready && !timedOut"
198
+ class="pointer-events-none absolute inset-0 flex flex-col bg-default"
199
+ role="status"
200
+ >
201
+ <PlutoSkeletonCanvas />
202
+ <div class="absolute inset-x-0 bottom-6 flex justify-center">
203
+ <div
204
+ 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"
205
+ >
206
+ <UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
207
+ {{ t('docs.editor.previewLoading') }}
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <div
213
+ v-if="timedOut"
214
+ class="absolute inset-0 flex items-center justify-center bg-default p-6"
215
+ >
216
+ <UEmpty
217
+ :title="t('docs.editor.previewFailedTitle')"
218
+ :description="t('docs.editor.previewFailed')"
219
+ :actions="[
220
+ {
221
+ label: t('docs.editor.previewRetry'),
222
+ icon: 'i-lucide-refresh-cw',
223
+ color: 'neutral',
224
+ variant: 'outline',
225
+ onClick: reload,
226
+ },
227
+ ]"
228
+ icon="i-lucide-triangle-alert"
229
+ />
230
+ </div>
231
+ </div>
232
+ </template>
@@ -0,0 +1,269 @@
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
+ // The default of the component, as a text for a placeholder.
66
+ const defaultText = computed(() =>
67
+ props.definition.default === undefined
68
+ ? undefined
69
+ : String(props.definition.default)
70
+ )
71
+
72
+ const switchOn = computed(() =>
73
+ props.modelValue === undefined
74
+ ? props.definition.default === true
75
+ : props.modelValue === true || props.modelValue === 'true'
76
+ )
77
+
78
+ function setBoolean(value: boolean) {
79
+ // The state of the default removes the prop. An explicit value in the source stays explicit.
80
+ const fallback = props.definition.default === true
81
+ if (value === fallback) {
82
+ emit('update:modelValue', props.modelValue === value ? value : undefined)
83
+ } else {
84
+ emit('update:modelValue', value)
85
+ }
86
+ }
87
+
88
+ const numberValue = computed(() => {
89
+ const value =
90
+ typeof props.modelValue === 'number'
91
+ ? props.modelValue
92
+ : Number(props.modelValue)
93
+ return props.modelValue === undefined || Number.isNaN(value)
94
+ ? undefined
95
+ : value
96
+ })
97
+
98
+ const selectItems = computed(() => {
99
+ const items = (props.definition.options ?? []).map((option) => {
100
+ const value = typeof option === 'string' ? option : option.value
101
+ const text = typeof option === 'string' ? option : option.label
102
+ return { value, label: text ? tx(text) : value }
103
+ })
104
+ const current = text.value
105
+ if (current && !items.some((item) => item.value === current)) {
106
+ items.push({ value: current, label: current })
107
+ }
108
+ // "Default (slow)": the item shows the value that the component uses.
109
+ const fallback = items.find((item) => item.value === defaultText.value)
110
+ const defaultLabel = defaultText.value
111
+ ? `${t('docs.editor.default')} (${fallback?.label ?? defaultText.value})`
112
+ : t('docs.editor.default')
113
+ return [{ value: DEFAULT, label: defaultLabel }, ...items]
114
+ })
115
+
116
+ const colorItems = computed(() =>
117
+ selectItems.value.map((item) =>
118
+ COLOR_CHIPS[item.value]
119
+ ? { ...item, chip: { color: COLOR_CHIPS[item.value] } }
120
+ : item
121
+ )
122
+ )
123
+
124
+ function setSelect(value: string) {
125
+ emit('update:modelValue', value === DEFAULT ? undefined : value)
126
+ }
127
+
128
+ // YAML for an object or a list. The text applies when the box loses the focus and the YAML is valid.
129
+ const yamlDraft = ref(
130
+ isStructured.value ? stringify(props.modelValue).trimEnd() : ''
131
+ )
132
+ const yamlError = ref<string | null>(null)
133
+
134
+ watch(
135
+ () => props.modelValue,
136
+ (value) => {
137
+ if (typeof value === 'object' && value !== null) {
138
+ try {
139
+ if (JSON.stringify(parse(yamlDraft.value)) === JSON.stringify(value)) {
140
+ return
141
+ }
142
+ } catch {
143
+ return
144
+ }
145
+ yamlDraft.value = stringify(value).trimEnd()
146
+ }
147
+ }
148
+ )
149
+
150
+ function applyYaml() {
151
+ try {
152
+ const value =
153
+ yamlDraft.value.trim() === '' ? undefined : parse(yamlDraft.value)
154
+ yamlError.value = null
155
+ emit('update:modelValue', value)
156
+ } catch (error) {
157
+ yamlError.value =
158
+ error instanceof Error ? error.message.split('\n')[0]! : String(error)
159
+ }
160
+ }
161
+
162
+ const iconPreview = computed(() =>
163
+ text.value.startsWith('i-') || text.value.includes(':')
164
+ ? text.value
165
+ : undefined
166
+ )
167
+ </script>
168
+
169
+ <template>
170
+ <UFormField
171
+ :label="label"
172
+ :description="description"
173
+ :error="yamlError ?? undefined"
174
+ :help="type === 'icon' ? t('docs.editor.iconHelp') : undefined"
175
+ class="w-full"
176
+ >
177
+ <USwitch
178
+ v-if="type === 'boolean'"
179
+ :model-value="switchOn"
180
+ :disabled="disabled"
181
+ :aria-label="label"
182
+ @update:model-value="setBoolean($event)"
183
+ />
184
+
185
+ <UInputNumber
186
+ v-else-if="type === 'number'"
187
+ :model-value="numberValue"
188
+ :placeholder="defaultText"
189
+ :disabled="disabled"
190
+ class="w-full"
191
+ @update:model-value="emit('update:modelValue', $event ?? undefined)"
192
+ />
193
+
194
+ <USelect
195
+ v-else-if="type === 'select'"
196
+ :model-value="text || DEFAULT"
197
+ :items="selectItems"
198
+ :disabled="disabled"
199
+ value-key="value"
200
+ class="w-full"
201
+ @update:model-value="setSelect($event as string)"
202
+ />
203
+
204
+ <USelect
205
+ v-else-if="type === 'color'"
206
+ :model-value="text || DEFAULT"
207
+ :items="colorItems"
208
+ :disabled="disabled"
209
+ value-key="value"
210
+ class="w-full"
211
+ @update:model-value="setSelect($event as string)"
212
+ />
213
+
214
+ <UTextarea
215
+ v-else-if="type === 'text'"
216
+ :model-value="text"
217
+ :placeholder="defaultText"
218
+ :disabled="disabled"
219
+ :rows="2"
220
+ class="w-full"
221
+ autoresize
222
+ @update:model-value="setText($event as string)"
223
+ />
224
+
225
+ <UTextarea
226
+ v-else-if="type === 'yaml'"
227
+ v-model="yamlDraft"
228
+ :disabled="disabled"
229
+ :rows="4"
230
+ :ui="{ base: 'font-mono text-xs' }"
231
+ class="w-full"
232
+ spellcheck="false"
233
+ autoresize
234
+ @blur="applyYaml"
235
+ />
236
+
237
+ <UInput
238
+ v-else-if="type === 'icon'"
239
+ :model-value="text"
240
+ :disabled="disabled"
241
+ :icon="iconPreview"
242
+ :placeholder="defaultText ?? 'i-lucide-rocket'"
243
+ spellcheck="false"
244
+ autocomplete="off"
245
+ class="w-full"
246
+ @update:model-value="setText($event as string)"
247
+ />
248
+
249
+ <UInput
250
+ v-else-if="type === 'link'"
251
+ :model-value="text"
252
+ :disabled="disabled"
253
+ :placeholder="defaultText ?? t('docs.editor.linkPlaceholder')"
254
+ icon="i-lucide-link"
255
+ autocomplete="off"
256
+ class="w-full"
257
+ @update:model-value="setText($event as string)"
258
+ />
259
+
260
+ <UInput
261
+ v-else
262
+ :model-value="text"
263
+ :placeholder="defaultText"
264
+ :disabled="disabled"
265
+ class="w-full"
266
+ @update:model-value="setText($event as string)"
267
+ />
268
+ </UFormField>
269
+ </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>