@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,357 @@
|
|
|
1
|
+
// Pure helpers for the block editor UI: the label, icon and excerpt of a block,
|
|
2
|
+
// the Markdown of a slot, the blocks sent to the preview, and the palette
|
|
3
|
+
// entries. No Nuxt import, so plain Vitest tests them.
|
|
4
|
+
|
|
5
|
+
import type { PlutoPreviewBlock, PlutoPreviewChild } from './preview-protocol'
|
|
6
|
+
import type {
|
|
7
|
+
PlutoBlock,
|
|
8
|
+
PlutoBlockDefinition,
|
|
9
|
+
PlutoBlockDoc,
|
|
10
|
+
PlutoBlocksConfig,
|
|
11
|
+
PlutoComponentBlock,
|
|
12
|
+
} from './types'
|
|
13
|
+
import { blockMarkdown } from './operations'
|
|
14
|
+
import { BLOCK_KIND_LABELS } from './presets'
|
|
15
|
+
import { listPaletteBlocks, resolveBlockDefinition } from './registry'
|
|
16
|
+
import { renderBlock } from './serialize'
|
|
17
|
+
import { findBlock, flatChildren, pathOf } from './tree'
|
|
18
|
+
|
|
19
|
+
export interface PlutoBlockInfo {
|
|
20
|
+
/** A plain text or an i18n key. */
|
|
21
|
+
label: string
|
|
22
|
+
icon: string
|
|
23
|
+
/** A short plain text from the content of the block. */
|
|
24
|
+
excerpt: string
|
|
25
|
+
/** The name of the component, for a component block. */
|
|
26
|
+
name?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HEADING = /^#{1,6} +(\S.*)$/m
|
|
30
|
+
|
|
31
|
+
/** Removes the Markdown marks of a short text, so it reads as plain text in a list. */
|
|
32
|
+
export function plainExcerpt(markdown: string, max = 48): string {
|
|
33
|
+
const text = markdown
|
|
34
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
35
|
+
.replace(/[`*_~>#]/g, '')
|
|
36
|
+
.replace(/\{[^}]*\}/g, '')
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textExcerpt(markdown: string): string {
|
|
43
|
+
const heading = HEADING.exec(markdown)
|
|
44
|
+
if (heading) {
|
|
45
|
+
return plainExcerpt(heading[1]!.replace(/ +#+$/, ''))
|
|
46
|
+
}
|
|
47
|
+
const first = markdown
|
|
48
|
+
.split('\n')
|
|
49
|
+
.find((line) => line.trim() !== '' && !/^\s*(?:---|:::?)/.test(line))
|
|
50
|
+
return plainExcerpt(first ?? '')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The first line of text in a component, for the excerpt of a block that has no title. */
|
|
54
|
+
function firstText(component: PlutoComponentBlock): string {
|
|
55
|
+
for (const child of flatChildren(component)) {
|
|
56
|
+
if (child.kind === 'text') {
|
|
57
|
+
return textExcerpt(child.markdown)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The label, the icon and the excerpt of a block in the outline. */
|
|
64
|
+
export function describeBlock(
|
|
65
|
+
block: PlutoBlock,
|
|
66
|
+
overrides: PlutoBlocksConfig = {}
|
|
67
|
+
): PlutoBlockInfo {
|
|
68
|
+
switch (block.kind) {
|
|
69
|
+
case 'text': {
|
|
70
|
+
const heading = HEADING.test(block.markdown)
|
|
71
|
+
return {
|
|
72
|
+
label: BLOCK_KIND_LABELS.text,
|
|
73
|
+
icon: heading ? 'i-lucide-heading' : 'i-lucide-pilcrow',
|
|
74
|
+
excerpt: textExcerpt(block.markdown),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
case 'code':
|
|
78
|
+
return {
|
|
79
|
+
label: BLOCK_KIND_LABELS.code,
|
|
80
|
+
icon: 'i-lucide-code-xml',
|
|
81
|
+
excerpt: block.filename || block.lang || '',
|
|
82
|
+
}
|
|
83
|
+
case 'raw':
|
|
84
|
+
return {
|
|
85
|
+
label:
|
|
86
|
+
block.reason === 'table'
|
|
87
|
+
? 'docs.blocks.kinds.table'
|
|
88
|
+
: BLOCK_KIND_LABELS.raw,
|
|
89
|
+
icon:
|
|
90
|
+
block.reason === 'table' ? 'i-lucide-table' : 'i-lucide-file-text',
|
|
91
|
+
excerpt: block.reason === 'table' ? '' : textExcerpt(block.markdown),
|
|
92
|
+
}
|
|
93
|
+
case 'component': {
|
|
94
|
+
const definition = resolveBlockDefinition(block.name, { overrides })
|
|
95
|
+
const title =
|
|
96
|
+
typeof block.props.title === 'string'
|
|
97
|
+
? block.props.title
|
|
98
|
+
: typeof block.props.label === 'string'
|
|
99
|
+
? block.props.label
|
|
100
|
+
: ''
|
|
101
|
+
return {
|
|
102
|
+
label: definition.label,
|
|
103
|
+
icon: definition.icon,
|
|
104
|
+
excerpt: plainExcerpt(title || firstText(block)),
|
|
105
|
+
name: block.name,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The Markdown of the children of one slot, without the white space of the parent. */
|
|
112
|
+
export function slotMarkdown(
|
|
113
|
+
component: PlutoComponentBlock,
|
|
114
|
+
slot: string
|
|
115
|
+
): string {
|
|
116
|
+
return (component.slots[slot] ?? [])
|
|
117
|
+
.map((child) => blockMarkdown(child))
|
|
118
|
+
.join('\n\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type PlutoSlotMode = 'rich' | 'inline' | 'blocks' | 'markdown'
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The editor for a slot. A `rich` or `inline` slot falls back when its content
|
|
125
|
+
* has a block or a syntax that the text editor would lose: it then shows the
|
|
126
|
+
* blocks, or a Markdown box.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveSlotMode(
|
|
129
|
+
component: PlutoComponentBlock,
|
|
130
|
+
slot: string,
|
|
131
|
+
wanted: 'rich' | 'inline' | 'blocks' | undefined
|
|
132
|
+
): PlutoSlotMode {
|
|
133
|
+
const children = component.slots[slot] ?? []
|
|
134
|
+
if (wanted === 'blocks') {
|
|
135
|
+
return 'blocks'
|
|
136
|
+
}
|
|
137
|
+
// A component or a code block inside the slot needs the list of blocks.
|
|
138
|
+
if (
|
|
139
|
+
!children.every((child) => child.kind === 'text' || child.kind === 'raw')
|
|
140
|
+
) {
|
|
141
|
+
return 'blocks'
|
|
142
|
+
}
|
|
143
|
+
// The parser turns prose that the text editor would lose into a raw block. Markdown keeps it as it is.
|
|
144
|
+
if (children.some((child) => child.kind === 'raw')) {
|
|
145
|
+
return 'markdown'
|
|
146
|
+
}
|
|
147
|
+
const markdown = slotMarkdown(component, slot)
|
|
148
|
+
if (wanted === 'inline') {
|
|
149
|
+
return markdown.includes('\n') ? 'markdown' : 'inline'
|
|
150
|
+
}
|
|
151
|
+
return 'rich'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The block that the preview shows as selected: the deepest component on the
|
|
156
|
+
* path of the selected block, else its top-level block. The preview marks only
|
|
157
|
+
* the top-level blocks and the inner components, so a text inside a card shows
|
|
158
|
+
* the card.
|
|
159
|
+
*/
|
|
160
|
+
export function previewSelectionId(
|
|
161
|
+
doc: PlutoBlockDoc,
|
|
162
|
+
id: string | null
|
|
163
|
+
): string | null {
|
|
164
|
+
if (!id) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const path = pathOf(doc, id)
|
|
168
|
+
for (let index = path.length - 1; index > 0; index--) {
|
|
169
|
+
if (findBlock(doc, path[index]!)?.block.kind === 'component') {
|
|
170
|
+
return path[index]!
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return path[0] ?? null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The top-level blocks as the preview frame wants them. */
|
|
177
|
+
export function toPreviewBlocks(
|
|
178
|
+
blocks: PlutoBlock[],
|
|
179
|
+
overrides: PlutoBlocksConfig = {}
|
|
180
|
+
): PlutoPreviewBlock[] {
|
|
181
|
+
return blocks.map((block) => {
|
|
182
|
+
const markdown = renderBlock(block)
|
|
183
|
+
if (block.kind !== 'component') {
|
|
184
|
+
return { id: block.id, markdown }
|
|
185
|
+
}
|
|
186
|
+
const definition = resolveBlockDefinition(block.name, { overrides })
|
|
187
|
+
const children = previewChildren(block, overrides)
|
|
188
|
+
return {
|
|
189
|
+
id: block.id,
|
|
190
|
+
markdown,
|
|
191
|
+
label: definition.label,
|
|
192
|
+
...(definition.preview === false ? { preview: false } : {}),
|
|
193
|
+
...(children.length ? { children } : {}),
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The inner components of a component, in source order over all slots, for
|
|
200
|
+
* the marks of the preview (a click selects an inner block). Text, code and raw
|
|
201
|
+
* children are not here: a click on them selects the nearest component.
|
|
202
|
+
*/
|
|
203
|
+
export function previewChildren(
|
|
204
|
+
component: PlutoComponentBlock,
|
|
205
|
+
overrides: PlutoBlocksConfig = {}
|
|
206
|
+
): PlutoPreviewChild[] {
|
|
207
|
+
return flatChildren(component)
|
|
208
|
+
.filter((child): child is PlutoComponentBlock => child.kind === 'component')
|
|
209
|
+
.map((child) => {
|
|
210
|
+
const children = previewChildren(child, overrides)
|
|
211
|
+
return {
|
|
212
|
+
id: child.id,
|
|
213
|
+
name: child.name,
|
|
214
|
+
label: resolveBlockDefinition(child.name, { overrides, block: child })
|
|
215
|
+
.label,
|
|
216
|
+
...(children.length ? { children } : {}),
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The component names that a slot accepts, or `undefined` for any. */
|
|
222
|
+
export function acceptedNames(
|
|
223
|
+
definition: PlutoBlockDefinition,
|
|
224
|
+
slot: string
|
|
225
|
+
): string[] | undefined {
|
|
226
|
+
return definition.slots?.[slot]?.accepts
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type PlutoPaletteEntry =
|
|
230
|
+
| {
|
|
231
|
+
id: string
|
|
232
|
+
kind: 'text'
|
|
233
|
+
group: string
|
|
234
|
+
label: string
|
|
235
|
+
description: string
|
|
236
|
+
icon: string
|
|
237
|
+
markdown: string
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
id: string
|
|
241
|
+
kind: 'code'
|
|
242
|
+
group: string
|
|
243
|
+
label: string
|
|
244
|
+
description: string
|
|
245
|
+
icon: string
|
|
246
|
+
markdown: string
|
|
247
|
+
}
|
|
248
|
+
| {
|
|
249
|
+
id: string
|
|
250
|
+
kind: 'table'
|
|
251
|
+
group: string
|
|
252
|
+
label: string
|
|
253
|
+
description: string
|
|
254
|
+
icon: string
|
|
255
|
+
markdown: string
|
|
256
|
+
}
|
|
257
|
+
| {
|
|
258
|
+
id: string
|
|
259
|
+
kind: 'component'
|
|
260
|
+
group: string
|
|
261
|
+
label: string
|
|
262
|
+
description: string
|
|
263
|
+
icon: string
|
|
264
|
+
name: string
|
|
265
|
+
markdown: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface PlutoPaletteGroup {
|
|
269
|
+
group: string
|
|
270
|
+
entries: PlutoPaletteEntry[]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const TABLE_MARKDOWN =
|
|
274
|
+
'| Column | Column |\n| ------ | ------ |\n| Cell | Cell |'
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The palette: the built-in blocks (text, code, table) first, then the
|
|
278
|
+
* component groups. `accepts` limits the list to some component names, for a
|
|
279
|
+
* target inside a component that takes only some children.
|
|
280
|
+
*/
|
|
281
|
+
export function buildPalette(
|
|
282
|
+
overrides: PlutoBlocksConfig = {},
|
|
283
|
+
accepts?: string[]
|
|
284
|
+
): PlutoPaletteGroup[] {
|
|
285
|
+
const groups: PlutoPaletteGroup[] = []
|
|
286
|
+
if (!accepts) {
|
|
287
|
+
groups.push({
|
|
288
|
+
group: 'text',
|
|
289
|
+
entries: [
|
|
290
|
+
{
|
|
291
|
+
id: 'builtin:text',
|
|
292
|
+
kind: 'text',
|
|
293
|
+
group: 'text',
|
|
294
|
+
label: BLOCK_KIND_LABELS.text,
|
|
295
|
+
description: 'docs.blocks.kinds.textDescription',
|
|
296
|
+
icon: 'i-lucide-pilcrow',
|
|
297
|
+
markdown: '## Title\n\nWrite your text here.',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'builtin:table',
|
|
301
|
+
kind: 'table',
|
|
302
|
+
group: 'text',
|
|
303
|
+
label: 'docs.blocks.kinds.table',
|
|
304
|
+
description: 'docs.blocks.kinds.tableDescription',
|
|
305
|
+
icon: 'i-lucide-table',
|
|
306
|
+
markdown: TABLE_MARKDOWN,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
groups.push({
|
|
311
|
+
group: 'code',
|
|
312
|
+
entries: [
|
|
313
|
+
{
|
|
314
|
+
id: 'builtin:code',
|
|
315
|
+
kind: 'code',
|
|
316
|
+
group: 'code',
|
|
317
|
+
label: BLOCK_KIND_LABELS.code,
|
|
318
|
+
description: 'docs.blocks.kinds.codeDescription',
|
|
319
|
+
icon: 'i-lucide-code-xml',
|
|
320
|
+
markdown: "```ts\nconst hello = 'world'\n```",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
for (const { group, items } of listPaletteBlocks(overrides)) {
|
|
326
|
+
const entries = items
|
|
327
|
+
.filter((item) => !accepts || accepts.includes(item.name))
|
|
328
|
+
.map((item): PlutoPaletteEntry => ({
|
|
329
|
+
id: `component:${item.name}`,
|
|
330
|
+
kind: 'component',
|
|
331
|
+
group,
|
|
332
|
+
label: item.label,
|
|
333
|
+
description: item.description ?? '',
|
|
334
|
+
icon: item.icon,
|
|
335
|
+
name: item.name,
|
|
336
|
+
markdown: item.insert ?? `::${item.name}\n::`,
|
|
337
|
+
}))
|
|
338
|
+
if (entries.length === 0) {
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
const existing = groups.find((candidate) => candidate.group === group)
|
|
342
|
+
if (existing) {
|
|
343
|
+
existing.entries.push(...entries)
|
|
344
|
+
} else {
|
|
345
|
+
groups.push({ group, entries })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return groups
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** How many blocks a block holds, itself included. */
|
|
352
|
+
export function countBlocks(block: PlutoBlock): number {
|
|
353
|
+
return block.kind === 'component'
|
|
354
|
+
? 1 +
|
|
355
|
+
flatChildren(block).reduce((sum, child) => sum + countBlocks(child), 0)
|
|
356
|
+
: 1
|
|
357
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Undo and redo for the block doc. The history keeps snapshots of the doc.
|
|
2
|
+
// A snapshot is immutable, because every operation returns a new doc. The
|
|
3
|
+
// clock is a parameter, so a test needs no timers.
|
|
4
|
+
|
|
5
|
+
export const HISTORY_LIMIT = 100
|
|
6
|
+
|
|
7
|
+
/** Edits of the same key within this time (ms) join in one step. */
|
|
8
|
+
export const HISTORY_MERGE_MS = 600
|
|
9
|
+
|
|
10
|
+
export interface PlutoHistory<T> {
|
|
11
|
+
/** Oldest first. */
|
|
12
|
+
past: T[]
|
|
13
|
+
present: T
|
|
14
|
+
future: T[]
|
|
15
|
+
/** The key and the time of the last push, to join typing in one block. */
|
|
16
|
+
last?: { key: string; at: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PlutoHistoryOptions {
|
|
20
|
+
limit?: number
|
|
21
|
+
mergeMs?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createHistory<T>(initial: T): PlutoHistory<T> {
|
|
25
|
+
return { past: [], present: initial, future: [] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function canUndo(history: PlutoHistory<unknown>): boolean {
|
|
29
|
+
return history.past.length > 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canRedo(history: PlutoHistory<unknown>): boolean {
|
|
33
|
+
return history.future.length > 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds a new state. A state equal (`===`) to the present is ignored. With a
|
|
38
|
+
* `key` (for example the id of a block), a push with the same key within
|
|
39
|
+
* `mergeMs` of the last push replaces the present, so it is one undo step.
|
|
40
|
+
*/
|
|
41
|
+
export function pushHistory<T>(
|
|
42
|
+
history: PlutoHistory<T>,
|
|
43
|
+
state: T,
|
|
44
|
+
now: number,
|
|
45
|
+
key?: string,
|
|
46
|
+
options: PlutoHistoryOptions = {}
|
|
47
|
+
): PlutoHistory<T> {
|
|
48
|
+
if (state === history.present) {
|
|
49
|
+
return history
|
|
50
|
+
}
|
|
51
|
+
const limit = options.limit ?? HISTORY_LIMIT
|
|
52
|
+
const mergeMs = options.mergeMs ?? HISTORY_MERGE_MS
|
|
53
|
+
const merge =
|
|
54
|
+
key !== undefined &&
|
|
55
|
+
history.last?.key === key &&
|
|
56
|
+
now - history.last.at <= mergeMs &&
|
|
57
|
+
history.past.length > 0 &&
|
|
58
|
+
history.future.length === 0
|
|
59
|
+
const last = key === undefined ? undefined : { key, at: now }
|
|
60
|
+
if (merge) {
|
|
61
|
+
return { ...history, present: state, last }
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
past: [...history.past, history.present].slice(-limit),
|
|
65
|
+
present: state,
|
|
66
|
+
future: [],
|
|
67
|
+
last,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function undo<T>(history: PlutoHistory<T>): PlutoHistory<T> {
|
|
72
|
+
if (!canUndo(history)) {
|
|
73
|
+
return history
|
|
74
|
+
}
|
|
75
|
+
const past = history.past.slice(0, -1)
|
|
76
|
+
return {
|
|
77
|
+
past,
|
|
78
|
+
present: history.past.at(-1)!,
|
|
79
|
+
future: [history.present, ...history.future],
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function redo<T>(history: PlutoHistory<T>): PlutoHistory<T> {
|
|
84
|
+
if (!canRedo(history)) {
|
|
85
|
+
return history
|
|
86
|
+
}
|
|
87
|
+
const [present, ...future] = history.future
|
|
88
|
+
return { past: [...history.past, history.present], present: present!, future }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Starts again from a state, for example after a switch to the Markdown mode. */
|
|
92
|
+
export function resetHistory<T>(state: T): PlutoHistory<T> {
|
|
93
|
+
return createHistory(state)
|
|
94
|
+
}
|