@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.
- package/.gitattributes +1 -0
- package/.prettierignore +5 -0
- package/CHANGELOG.md +186 -0
- package/FEATURES.md +34 -0
- package/README.md +40 -2
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +165 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +161 -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 +86 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +232 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +269 -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 +459 -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/blocks.ts +217 -0
- package/modules/pluto-docs/index.ts +230 -0
- package/modules/pluto-docs/manifest.ts +159 -0
- package/modules/pluto-docs/options.ts +258 -0
- package/nuxt.config.ts +36 -0
- package/package.json +56 -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-blocks.d.ts +10 -0
- package/shared/types/pluto-manifest.d.ts +6 -0
- package/shared/utils/blocks/component-meta.ts +285 -0
- package/shared/utils/blocks/define.ts +15 -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 +384 -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 +295 -0
- package/shared/utils/blocks/serialize.ts +107 -0
- package/shared/utils/blocks/tree.ts +85 -0
- package/shared/utils/blocks/types.ts +195 -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,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>
|