@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,555 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MDCElement, MDCNode, MDCRoot } from '@nuxtjs/mdc'
|
|
3
|
+
// The preview page of the visual block editor. The editor shows it in an
|
|
4
|
+
// iframe. It fetches nothing. The editor sends the blocks (Markdown) with
|
|
5
|
+
// `postMessage`. This page parses each changed block with the site parser
|
|
6
|
+
// (`parseMarkdown` of @nuxtjs/mdc) and renders it with `ContentRenderer`, so
|
|
7
|
+
// the page looks as it does on the site. A click on a block tells the editor
|
|
8
|
+
// which block it is. See the block-editor skill.
|
|
9
|
+
//
|
|
10
|
+
// Security: same origin only. This page ignores a message from another
|
|
11
|
+
// origin or from a window that is not its parent. No remote data loads here.
|
|
12
|
+
import type { CSSProperties } from 'vue'
|
|
13
|
+
import type { PlutoPreviewBlock as PreviewBlockData } from '../../../shared/utils/blocks/preview-protocol'
|
|
14
|
+
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
|
|
15
|
+
import {
|
|
16
|
+
markPreviewChildren,
|
|
17
|
+
PREVIEW_BLOCK_ATTRIBUTE,
|
|
18
|
+
previewParents,
|
|
19
|
+
} from '../../../shared/utils/blocks/preview-marks'
|
|
20
|
+
import {
|
|
21
|
+
isTrustedPreviewOrigin,
|
|
22
|
+
parsePreviewMessage,
|
|
23
|
+
previewMessage,
|
|
24
|
+
} from '../../../shared/utils/blocks/preview-protocol'
|
|
25
|
+
import PlutoPreviewBlock from '../../components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue'
|
|
26
|
+
|
|
27
|
+
definePageMeta({ layout: false, plutoAdminBar: false })
|
|
28
|
+
|
|
29
|
+
const { t } = usePlutoI18n()
|
|
30
|
+
|
|
31
|
+
useHead({
|
|
32
|
+
title: () => t('docs.preview.title'),
|
|
33
|
+
meta: [{ name: 'robots', content: 'noindex, nofollow' }],
|
|
34
|
+
// The preview is for choosing blocks, not for reading: no text selection, no link or image
|
|
35
|
+
// drag, and the default cursor everywhere (not the hand of a link or a button).
|
|
36
|
+
style: [
|
|
37
|
+
{
|
|
38
|
+
textContent:
|
|
39
|
+
'*, *::before, *::after { cursor: default !important; user-select: none !important; -webkit-user-select: none !important; -webkit-user-drag: none !important; }',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// A thin scrollbar that fits the editor, in both color modes: a gray thumb on no track.
|
|
43
|
+
textContent: [
|
|
44
|
+
'html { scrollbar-width: thin; scrollbar-color: rgb(128 128 128 / 0.45) transparent; }',
|
|
45
|
+
'html::-webkit-scrollbar { width: 10px; }',
|
|
46
|
+
'html::-webkit-scrollbar-track { background: transparent; }',
|
|
47
|
+
'html::-webkit-scrollbar-thumb { background: rgb(128 128 128 / 0.45); border: 3px solid transparent; border-radius: 9999px; background-clip: content-box; }',
|
|
48
|
+
'html::-webkit-scrollbar-thumb:hover { background-color: rgb(128 128 128 / 0.7); }',
|
|
49
|
+
].join('\n'),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Each block renders with `tag` false, so no wrapper element sits between the blocks (the prose
|
|
55
|
+
// styles read the first and last child). Vue warns in dev about the type of that prop, and about
|
|
56
|
+
// the debug attribute that ContentRenderer adds. The preview app is its own app, so this
|
|
57
|
+
// handler hides only those two messages here.
|
|
58
|
+
if (import.meta.dev) {
|
|
59
|
+
const config = useNuxtApp().vueApp.config
|
|
60
|
+
const previous = config.warnHandler
|
|
61
|
+
config.warnHandler = (message, instance, trace) => {
|
|
62
|
+
if (message.includes('prop "tag"') || message.includes('data-content-id')) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (previous) {
|
|
66
|
+
previous(message, instance, trace)
|
|
67
|
+
} else {
|
|
68
|
+
console.warn(`[Vue warn]: ${message}`, trace)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RenderedBlock {
|
|
74
|
+
id: string
|
|
75
|
+
label: string
|
|
76
|
+
body?: MDCRoot
|
|
77
|
+
error?: string
|
|
78
|
+
/** The inner components, for the labels and the parents. */
|
|
79
|
+
children?: PreviewBlockData['children']
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const frontmatter = ref<Record<string, unknown>>({})
|
|
83
|
+
const blocks = shallowRef<RenderedBlock[]>([])
|
|
84
|
+
const selectedId = ref<string | null>(null)
|
|
85
|
+
|
|
86
|
+
// The parsed body of a block, by id and text. The same text gives the same
|
|
87
|
+
// object, so Vue does not render a block that did not change.
|
|
88
|
+
const cache = new Map<string, MDCRoot>()
|
|
89
|
+
const CACHE_LIMIT = 300
|
|
90
|
+
let run = 0
|
|
91
|
+
// A scroll that came before the block was on screen (a new block): do it after the next render.
|
|
92
|
+
let wantedScroll: string | null = null
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Puts the id on each element at the top of a body, and the ids of the inner
|
|
96
|
+
* components on their elements. A component passes the id to its root element.
|
|
97
|
+
* The parse is new, so the inner marks change it in place.
|
|
98
|
+
*/
|
|
99
|
+
function mark(root: MDCRoot, block: PreviewBlockData): MDCRoot {
|
|
100
|
+
const children = root.children.map((child): MDCNode => {
|
|
101
|
+
if (child.type !== 'element') {
|
|
102
|
+
return child
|
|
103
|
+
}
|
|
104
|
+
const element = child as MDCElement
|
|
105
|
+
if (block.children) {
|
|
106
|
+
markPreviewChildren(element, block.children)
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
...element,
|
|
110
|
+
props: { ...element.props, [PREVIEW_BLOCK_ATTRIBUTE]: block.id },
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return { ...root, children }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The ids of the inner components, for the cache key: the same text with other ids marks other elements. */
|
|
117
|
+
function childIds(children: PreviewBlockData['children']): string {
|
|
118
|
+
return (children ?? [])
|
|
119
|
+
.map((child) => `${child.id}(${childIds(child.children)})`)
|
|
120
|
+
.join(',')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function bodyOf(
|
|
124
|
+
block: PreviewBlockData
|
|
125
|
+
): Promise<{ body?: MDCRoot; error?: string }> {
|
|
126
|
+
const key = `${block.id}\u0000${block.markdown}\u0000${childIds(block.children)}`
|
|
127
|
+
const cached = cache.get(key)
|
|
128
|
+
if (cached) {
|
|
129
|
+
return { body: cached }
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const parsed = await parseMarkdown(block.markdown)
|
|
133
|
+
const body = mark(parsed.body, block)
|
|
134
|
+
if (cache.size >= CACHE_LIMIT) {
|
|
135
|
+
cache.delete(cache.keys().next().value!)
|
|
136
|
+
}
|
|
137
|
+
cache.set(key, body)
|
|
138
|
+
return { body }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function render(message: {
|
|
145
|
+
frontmatter: Record<string, unknown>
|
|
146
|
+
blocks: PreviewBlockData[]
|
|
147
|
+
selectedId?: string | null
|
|
148
|
+
}) {
|
|
149
|
+
const current = ++run
|
|
150
|
+
const next = await Promise.all(
|
|
151
|
+
message.blocks.map(async (block): Promise<RenderedBlock> => {
|
|
152
|
+
const label = block.label ?? ''
|
|
153
|
+
if (block.preview === false) {
|
|
154
|
+
return { id: block.id, label }
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
id: block.id,
|
|
158
|
+
label,
|
|
159
|
+
children: block.children,
|
|
160
|
+
...(await bodyOf(block)),
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
// A newer message came while the parser worked.
|
|
165
|
+
if (current !== run) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
frontmatter.value = message.frontmatter
|
|
169
|
+
blocks.value = next
|
|
170
|
+
if (message.selectedId !== undefined) {
|
|
171
|
+
selectedId.value = message.selectedId
|
|
172
|
+
}
|
|
173
|
+
await nextTick()
|
|
174
|
+
refreshOverlay()
|
|
175
|
+
if (wantedScroll) {
|
|
176
|
+
// The components of a block can mount a moment after the render.
|
|
177
|
+
setTimeout(() => wantedScroll && scrollToBlock(wantedScroll), 80)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Talk to the editor ---------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
function post(message: ReturnType<typeof previewMessage>) {
|
|
184
|
+
window.parent?.postMessage(message, window.location.origin)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onMessage(event: MessageEvent) {
|
|
188
|
+
if (
|
|
189
|
+
event.source !== window.parent ||
|
|
190
|
+
!isTrustedPreviewOrigin(event.origin, window.location.origin)
|
|
191
|
+
) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const message = parsePreviewMessage(event.data, 'to-frame')
|
|
195
|
+
if (!message) {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
switch (message.type) {
|
|
199
|
+
case 'render':
|
|
200
|
+
void render(message)
|
|
201
|
+
break
|
|
202
|
+
case 'select':
|
|
203
|
+
selectedId.value = message.id
|
|
204
|
+
nextTick(refreshOverlay)
|
|
205
|
+
break
|
|
206
|
+
case 'scroll-to':
|
|
207
|
+
scrollToBlock(message.id)
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Outlines -------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
const hoverId = ref<string | null>(null)
|
|
215
|
+
const overlay = ref<{ selected: DOMRect | null; hover: DOMRect | null }>({
|
|
216
|
+
selected: null,
|
|
217
|
+
hover: null,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
/** The elements of a block. A style or a script has no box. */
|
|
221
|
+
function elementsOf(id: string): HTMLElement[] {
|
|
222
|
+
return [
|
|
223
|
+
...document.querySelectorAll<HTMLElement>(
|
|
224
|
+
`[${PREVIEW_BLOCK_ATTRIBUTE}="${CSS.escape(id)}"]`
|
|
225
|
+
),
|
|
226
|
+
].filter(
|
|
227
|
+
(element) => element.tagName !== 'STYLE' && element.tagName !== 'SCRIPT'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The parent of each inner component: an inner component or a top-level block. */
|
|
232
|
+
const parents = computed(() => {
|
|
233
|
+
const map = new Map<string, string>()
|
|
234
|
+
for (const block of blocks.value) {
|
|
235
|
+
previewParents(block.id, block.children, map)
|
|
236
|
+
}
|
|
237
|
+
return map
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The block that shows for an id: the block itself, or the nearest parent with
|
|
242
|
+
* elements. A component with more than one root element drops the mark, and a
|
|
243
|
+
* text block inside a component has no mark of its own.
|
|
244
|
+
*/
|
|
245
|
+
function shownId(id: string | null): string | null {
|
|
246
|
+
let current = id
|
|
247
|
+
while (current && !elementsOf(current).length) {
|
|
248
|
+
current = parents.value.get(current) ?? null
|
|
249
|
+
}
|
|
250
|
+
return current
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The box around two boxes. */
|
|
254
|
+
function union(a: DOMRect | null, b: DOMRect | null): DOMRect | null {
|
|
255
|
+
if (!a || !b) {
|
|
256
|
+
return a ?? b
|
|
257
|
+
}
|
|
258
|
+
const left = Math.min(a.left, b.left)
|
|
259
|
+
const top = Math.min(a.top, b.top)
|
|
260
|
+
return new DOMRect(
|
|
261
|
+
left,
|
|
262
|
+
top,
|
|
263
|
+
Math.max(a.right, b.right) - left,
|
|
264
|
+
Math.max(a.bottom, b.bottom) - top
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The box of an element. An element with no area of its own gives the box of
|
|
270
|
+
* its children: a linked card of Nuxt UI (`UPageCard` with `to`) puts the
|
|
271
|
+
* attributes on an inline link of 0 px width, and a child of that link covers
|
|
272
|
+
* the whole card.
|
|
273
|
+
*/
|
|
274
|
+
function elementBox(element: Element, depth = 0): DOMRect | null {
|
|
275
|
+
const rect = element.getBoundingClientRect()
|
|
276
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
277
|
+
return rect
|
|
278
|
+
}
|
|
279
|
+
if (depth > 3) {
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
let box: DOMRect | null = null
|
|
283
|
+
for (const child of element.children) {
|
|
284
|
+
box = union(box, elementBox(child, depth + 1))
|
|
285
|
+
}
|
|
286
|
+
return box
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The box around all elements of a block, in page coordinates. */
|
|
290
|
+
function boxOf(id: string | null): DOMRect | null {
|
|
291
|
+
const shown = shownId(id)
|
|
292
|
+
if (!shown) {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
let box: DOMRect | null = null
|
|
296
|
+
for (const element of elementsOf(shown)) {
|
|
297
|
+
box = union(box, elementBox(element))
|
|
298
|
+
}
|
|
299
|
+
return box
|
|
300
|
+
? new DOMRect(
|
|
301
|
+
box.left + window.scrollX,
|
|
302
|
+
box.top + window.scrollY,
|
|
303
|
+
box.width,
|
|
304
|
+
box.height
|
|
305
|
+
)
|
|
306
|
+
: null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let frame = 0
|
|
310
|
+
function refreshOverlay() {
|
|
311
|
+
cancelAnimationFrame(frame)
|
|
312
|
+
frame = requestAnimationFrame(() => {
|
|
313
|
+
overlay.value = {
|
|
314
|
+
selected: boxOf(selectedId.value),
|
|
315
|
+
hover: hoverId.value === selectedId.value ? null : boxOf(hoverId.value),
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function scrollToBlock(id: string) {
|
|
321
|
+
const box = boxOf(id)
|
|
322
|
+
if (!box) {
|
|
323
|
+
wantedScroll = id
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
wantedScroll = null
|
|
327
|
+
const top = box.top - window.scrollY
|
|
328
|
+
const margin = 24
|
|
329
|
+
if (top < margin || top + box.height > window.innerHeight - margin) {
|
|
330
|
+
window.scrollTo({ top: Math.max(0, box.top - margin), behavior: 'smooth' })
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The label of each block, the inner components too. */
|
|
335
|
+
const labels = computed(() => {
|
|
336
|
+
const map = new Map<string, string>()
|
|
337
|
+
const add = (children: RenderedBlock['children']) => {
|
|
338
|
+
for (const child of children ?? []) {
|
|
339
|
+
map.set(child.id, child.label ?? '')
|
|
340
|
+
add(child.children)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const block of blocks.value) {
|
|
344
|
+
map.set(block.id, block.label)
|
|
345
|
+
add(block.children)
|
|
346
|
+
}
|
|
347
|
+
return map
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
/** The innermost block under an element: an inner component, else its top-level block. */
|
|
351
|
+
function blockIdAt(target: EventTarget | null): string | null {
|
|
352
|
+
return target instanceof Element
|
|
353
|
+
? (target
|
|
354
|
+
.closest<HTMLElement>(`[${PREVIEW_BLOCK_ATTRIBUTE}]`)
|
|
355
|
+
?.getAttribute(PREVIEW_BLOCK_ATTRIBUTE) ?? null)
|
|
356
|
+
: null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function selectBlock(id: string | null) {
|
|
360
|
+
selectedId.value = id
|
|
361
|
+
if (id) {
|
|
362
|
+
post(previewMessage('select', { id }))
|
|
363
|
+
}
|
|
364
|
+
refreshOverlay()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// A click selects the block. The page does not follow links or press buttons.
|
|
368
|
+
function onClick(event: MouseEvent) {
|
|
369
|
+
event.preventDefault()
|
|
370
|
+
event.stopPropagation()
|
|
371
|
+
selectBlock(blockIdAt(event.target))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A middle click or a Ctrl/Cmd+click (`auxclick`) does not open a link in a new tab, a drag does
|
|
375
|
+
// not take a link or an image, and the text does not select.
|
|
376
|
+
function stopEvent(event: Event) {
|
|
377
|
+
event.preventDefault()
|
|
378
|
+
event.stopPropagation()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function onPointerMove(event: PointerEvent) {
|
|
382
|
+
const id = blockIdAt(event.target)
|
|
383
|
+
if (id !== hoverId.value) {
|
|
384
|
+
hoverId.value = id
|
|
385
|
+
post(previewMessage('hover', { id }))
|
|
386
|
+
refreshOverlay()
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function onPointerLeave() {
|
|
391
|
+
if (hoverId.value !== null) {
|
|
392
|
+
hoverId.value = null
|
|
393
|
+
post(previewMessage('hover', { id: null }))
|
|
394
|
+
refreshOverlay()
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Keyboard: Enter on a focused block selects it, so the preview works without a mouse too. Esc
|
|
399
|
+
// selects the block around the selected inner block.
|
|
400
|
+
function onKeydown(event: KeyboardEvent) {
|
|
401
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
402
|
+
// Enter on a focused link must not follow it. The preview has nothing to type into.
|
|
403
|
+
event.preventDefault()
|
|
404
|
+
const id = blockIdAt(event.target)
|
|
405
|
+
if (id) {
|
|
406
|
+
selectBlock(id)
|
|
407
|
+
}
|
|
408
|
+
} else if (event.key === 'Escape' && selectedId.value) {
|
|
409
|
+
const parent = parents.value.get(selectedId.value)
|
|
410
|
+
if (parent) {
|
|
411
|
+
event.preventDefault()
|
|
412
|
+
selectBlock(parent)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function onBlockError(id: string, message: string) {
|
|
418
|
+
post(previewMessage('error', { id, message }))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let observer: ResizeObserver | undefined
|
|
422
|
+
let mutations: MutationObserver | undefined
|
|
423
|
+
|
|
424
|
+
onMounted(() => {
|
|
425
|
+
window.addEventListener('message', onMessage)
|
|
426
|
+
window.addEventListener('scroll', refreshOverlay, { passive: true })
|
|
427
|
+
window.addEventListener('resize', refreshOverlay)
|
|
428
|
+
document.addEventListener('click', onClick, true)
|
|
429
|
+
document.addEventListener('pointermove', onPointerMove, { passive: true })
|
|
430
|
+
document.addEventListener('pointerleave', onPointerLeave)
|
|
431
|
+
document.addEventListener('keydown', onKeydown, true)
|
|
432
|
+
document.addEventListener('auxclick', stopEvent, true)
|
|
433
|
+
document.addEventListener('dragstart', stopEvent, true)
|
|
434
|
+
document.addEventListener('selectstart', stopEvent, true)
|
|
435
|
+
observer = new ResizeObserver(refreshOverlay)
|
|
436
|
+
observer.observe(document.body)
|
|
437
|
+
// Images, fonts and lazy components change the layout after the render.
|
|
438
|
+
mutations = new MutationObserver(refreshOverlay)
|
|
439
|
+
mutations.observe(document.body, {
|
|
440
|
+
childList: true,
|
|
441
|
+
subtree: true,
|
|
442
|
+
attributes: true,
|
|
443
|
+
attributeFilter: ['class', 'style', 'open'],
|
|
444
|
+
})
|
|
445
|
+
post(previewMessage('ready', {}))
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
onBeforeUnmount(() => {
|
|
449
|
+
window.removeEventListener('message', onMessage)
|
|
450
|
+
window.removeEventListener('scroll', refreshOverlay)
|
|
451
|
+
window.removeEventListener('resize', refreshOverlay)
|
|
452
|
+
document.removeEventListener('click', onClick, true)
|
|
453
|
+
document.removeEventListener('pointermove', onPointerMove)
|
|
454
|
+
document.removeEventListener('pointerleave', onPointerLeave)
|
|
455
|
+
document.removeEventListener('keydown', onKeydown, true)
|
|
456
|
+
document.removeEventListener('auxclick', stopEvent, true)
|
|
457
|
+
document.removeEventListener('dragstart', stopEvent, true)
|
|
458
|
+
document.removeEventListener('selectstart', stopEvent, true)
|
|
459
|
+
observer?.disconnect()
|
|
460
|
+
mutations?.disconnect()
|
|
461
|
+
cancelAnimationFrame(frame)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const hoverLabel = computed(
|
|
465
|
+
() => (hoverId.value ? labels.value.get(hoverId.value) : '') ?? ''
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
function boxStyle(
|
|
469
|
+
rect: DOMRect | null,
|
|
470
|
+
kind: 'selected' | 'hover'
|
|
471
|
+
): CSSProperties {
|
|
472
|
+
if (!rect) {
|
|
473
|
+
return { display: 'none' }
|
|
474
|
+
}
|
|
475
|
+
// The box has the size of the block, and the ring is an inset box shadow. So a full-width block
|
|
476
|
+
// (a hero, a page section) gives no horizontal scroll (a border on a larger box did), and the
|
|
477
|
+
// four sides of the ring stay on screen.
|
|
478
|
+
const color = 'var(--ui-primary, #6366f1)'
|
|
479
|
+
return {
|
|
480
|
+
position: 'absolute',
|
|
481
|
+
left: `${rect.left}px`,
|
|
482
|
+
top: `${rect.top}px`,
|
|
483
|
+
width: `${rect.width}px`,
|
|
484
|
+
height: `${rect.height}px`,
|
|
485
|
+
pointerEvents: 'none',
|
|
486
|
+
zIndex: '2147483000',
|
|
487
|
+
borderRadius: '4px',
|
|
488
|
+
boxShadow:
|
|
489
|
+
kind === 'selected'
|
|
490
|
+
? `inset 0 0 0 2px ${color}`
|
|
491
|
+
: `inset 0 0 0 1px color-mix(in oklab, ${color} 70%, transparent)`,
|
|
492
|
+
background:
|
|
493
|
+
kind === 'selected'
|
|
494
|
+
? `color-mix(in oklab, ${color} 8%, transparent)`
|
|
495
|
+
: 'transparent',
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const chipStyle: CSSProperties = {
|
|
500
|
+
position: 'absolute',
|
|
501
|
+
top: '-22px',
|
|
502
|
+
left: '0',
|
|
503
|
+
padding: '1px 8px',
|
|
504
|
+
borderRadius: '6px 6px 0 0',
|
|
505
|
+
fontSize: '11px',
|
|
506
|
+
lineHeight: '20px',
|
|
507
|
+
fontFamily: 'system-ui, sans-serif',
|
|
508
|
+
background: 'var(--ui-primary, #6366f1)',
|
|
509
|
+
color: 'var(--ui-bg, #fff)',
|
|
510
|
+
whiteSpace: 'nowrap',
|
|
511
|
+
}
|
|
512
|
+
</script>
|
|
513
|
+
|
|
514
|
+
<template>
|
|
515
|
+
<div>
|
|
516
|
+
<PlutoContentPreview :frontmatter="frontmatter">
|
|
517
|
+
<PlutoPreviewBlock
|
|
518
|
+
v-for="block in blocks"
|
|
519
|
+
:id="block.id"
|
|
520
|
+
:key="block.id"
|
|
521
|
+
:body="block.body"
|
|
522
|
+
:label="block.label"
|
|
523
|
+
:error="block.error"
|
|
524
|
+
@error="onBlockError"
|
|
525
|
+
/>
|
|
526
|
+
</PlutoContentPreview>
|
|
527
|
+
|
|
528
|
+
<!-- The outlines use inline styles only, so they do not depend on the site CSS. -->
|
|
529
|
+
<div
|
|
530
|
+
aria-hidden="true"
|
|
531
|
+
style="
|
|
532
|
+
position: absolute;
|
|
533
|
+
inset: 0;
|
|
534
|
+
pointer-events: none;
|
|
535
|
+
overflow: visible;
|
|
536
|
+
height: 0;
|
|
537
|
+
"
|
|
538
|
+
>
|
|
539
|
+
<div :style="boxStyle(overlay.hover, 'hover')">
|
|
540
|
+
<span
|
|
541
|
+
v-if="overlay.hover && hoverLabel"
|
|
542
|
+
:style="{ ...chipStyle, opacity: '0.85' }"
|
|
543
|
+
>{{ hoverLabel }}</span
|
|
544
|
+
>
|
|
545
|
+
</div>
|
|
546
|
+
<div :style="boxStyle(overlay.selected, 'selected')">
|
|
547
|
+
<span
|
|
548
|
+
v-if="overlay.selected && selectedId && labels.get(selectedId)"
|
|
549
|
+
:style="chipStyle"
|
|
550
|
+
>{{ labels.get(selectedId) }}</span
|
|
551
|
+
>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PlutoDocsTypeOptions } from '../../modules/pluto-docs/options'
|
|
2
|
+
import { defineAsyncComponent } from 'vue'
|
|
3
|
+
|
|
4
|
+
// The field widget loads on demand and stays out of the entry chunk. Core shows a
|
|
5
|
+
// skeleton while the field chunk loads.
|
|
6
|
+
const PlutoFieldMdc = defineAsyncComponent(
|
|
7
|
+
() => import('../components/Pluto/Docs/PlutoFieldMdc.vue')
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Registers the types, the capabilities and the `mdc` field widget. The types set
|
|
11
|
+
// `adminBar`, so core shows "New page" and "Edit page" in the admin bar. The module
|
|
12
|
+
// sets the config only when the CMS is on. Without it, the layer adds nothing to the admin.
|
|
13
|
+
export default defineNuxtPlugin(() => {
|
|
14
|
+
const config = useRuntimeConfig().public.plutoDocs as unknown as
|
|
15
|
+
| {
|
|
16
|
+
types: PlutoDocsTypeOptions[]
|
|
17
|
+
render?: string
|
|
18
|
+
}
|
|
19
|
+
| undefined
|
|
20
|
+
if (!config) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const types = config.types.map((entry) =>
|
|
25
|
+
createDocsType({
|
|
26
|
+
...entry,
|
|
27
|
+
liveOnSave: config.render === 'hybrid' || config.render === 'runtime',
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
definePlutoExtension({
|
|
32
|
+
id: 'docs',
|
|
33
|
+
contentTypes: types.map((type) => ({ id: type.name, type })),
|
|
34
|
+
capabilities: [
|
|
35
|
+
{
|
|
36
|
+
id: 'docs-write',
|
|
37
|
+
key: DOCS_CAPABILITIES.write,
|
|
38
|
+
label: 'docs.capabilities.write',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'docs-delete',
|
|
42
|
+
key: DOCS_CAPABILITIES.delete,
|
|
43
|
+
label: 'docs.capabilities.delete',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
contentFieldWidgets: [
|
|
47
|
+
{ id: 'mdc', widget: 'mdc', component: PlutoFieldMdc },
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
})
|