@rimelight/cms 0.0.1
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/README.md +19 -0
- package/package.json +69 -0
- package/src/astro/BlockRenderer.astro +111 -0
- package/src/astro/PageRenderer.astro +23 -0
- package/src/astro/blocks/CalloutBlock.astro +78 -0
- package/src/astro/blocks/CardBlock.astro +43 -0
- package/src/astro/blocks/CardsBlock.astro +24 -0
- package/src/astro/blocks/CodeBlock.astro +16 -0
- package/src/astro/blocks/DialogueBlock.astro +23 -0
- package/src/astro/blocks/FileTreeBlock.astro +45 -0
- package/src/astro/blocks/ImageBlock.astro +17 -0
- package/src/astro/blocks/OrderedListBlock.astro +55 -0
- package/src/astro/blocks/ParagraphBlock.astro +65 -0
- package/src/astro/blocks/SceneBlock.astro +65 -0
- package/src/astro/blocks/ScriptBlock.astro +59 -0
- package/src/astro/blocks/SectionBlock.astro +46 -0
- package/src/astro/blocks/StepItemBlock.astro +25 -0
- package/src/astro/blocks/StepsBlock.astro +14 -0
- package/src/astro/blocks/TabItemBlock.astro +19 -0
- package/src/astro/blocks/TableBlock.astro +49 -0
- package/src/astro/blocks/TabsBlock.astro +67 -0
- package/src/astro/blocks/UnorderedListBlock.astro +55 -0
- package/src/auth/editor-guards.ts +148 -0
- package/src/auth/filter-blocks.ts +44 -0
- package/src/auth/filter-templates.ts +81 -0
- package/src/auth/permissions.ts +40 -0
- package/src/cache/purge.ts +37 -0
- package/src/client/components/RimelightBlock.tsx +487 -0
- package/src/client/components/RimelightBlockPicker.tsx +305 -0
- package/src/client/components/RimelightEditor.tsx +131 -0
- package/src/client/components/RimelightEditorLayout.tsx +143 -0
- package/src/client/components/RimelightPreview.tsx +354 -0
- package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
- package/src/client/components/RimelightTemplateEditor.tsx +245 -0
- package/src/client/components/editors/CalloutEditor.tsx +185 -0
- package/src/client/components/editors/CardEditor.tsx +60 -0
- package/src/client/components/editors/CardsEditor.tsx +132 -0
- package/src/client/components/editors/CodeEditor.tsx +101 -0
- package/src/client/components/editors/DialogueEditor.tsx +52 -0
- package/src/client/components/editors/FileTreeEditor.tsx +65 -0
- package/src/client/components/editors/ImageEditor.tsx +70 -0
- package/src/client/components/editors/ParagraphEditor.tsx +314 -0
- package/src/client/components/editors/SceneEditor.tsx +245 -0
- package/src/client/components/editors/ScriptEditor.tsx +245 -0
- package/src/client/components/editors/SectionEditor.tsx +139 -0
- package/src/client/components/editors/StepItemEditor.tsx +117 -0
- package/src/client/components/editors/StepsEditor.tsx +105 -0
- package/src/client/components/editors/TabItemEditor.tsx +113 -0
- package/src/client/components/editors/TableEditor.tsx +181 -0
- package/src/client/components/editors/TabsEditor.tsx +105 -0
- package/src/client/components/index.ts +7 -0
- package/src/client/loader.ts +19 -0
- package/src/client/state/autosave.ts +122 -0
- package/src/client/state/editor-store.ts +411 -0
- package/src/client/state/history.ts +251 -0
- package/src/client/state/lock-manager.ts +89 -0
- package/src/client/styles/cms-editor.css +653 -0
- package/src/client/toast.ts +12 -0
- package/src/client/utils/sortable.ts +184 -0
- package/src/client/utils/splitpane.ts +87 -0
- package/src/client/utils/tree-sanitizer.ts +33 -0
- package/src/core/page-definitions.ts +502 -0
- package/src/core/registry.ts +99 -0
- package/src/core/template-sync.ts +75 -0
- package/src/core/types/blocks.ts +367 -0
- package/src/core/types/inline.ts +23 -0
- package/src/core/types/pages.ts +36 -0
- package/src/core/types/templates.ts +32 -0
- package/src/core/types/versioning.ts +44 -0
- package/src/core/validator.ts +28 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +47 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/parser.ts +347 -0
- package/src/markdown/serializer.ts +219 -0
- package/src/migration.ts +155 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/page_draft_locks.ts +13 -0
- package/src/schema/page_drafts.ts +18 -0
- package/src/schema/page_templates.ts +27 -0
- package/src/schema/page_version_approvals.ts +12 -0
- package/src/schema/page_version_comments.ts +14 -0
- package/src/schema/page_versions.ts +34 -0
- package/src/schema/pages.ts +42 -0
- package/src/schema/search.ts +23 -0
- package/src/search/query.ts +10 -0
- package/src/search/sync.ts +32 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import { createStore, reconcile } from "solid-js/store"
|
|
3
|
+
import { type BaseBlock, MAX_SECTION_DEPTH } from "../../core/types/blocks"
|
|
4
|
+
import { HistoryStack, createDefaultBlock } from "./history"
|
|
5
|
+
import { AutosaveController, type AutosaveStatus } from "./autosave"
|
|
6
|
+
import { sanitizeBlockTree } from "../utils/tree-sanitizer"
|
|
7
|
+
import { toast } from "../toast"
|
|
8
|
+
|
|
9
|
+
const cloneBlocks = (b: BaseBlock[]): BaseBlock[] => JSON.parse(JSON.stringify(b))
|
|
10
|
+
|
|
11
|
+
function findBlockInfo(
|
|
12
|
+
list: BaseBlock[],
|
|
13
|
+
targetId: string,
|
|
14
|
+
currentDepth: number = 0
|
|
15
|
+
): { block: BaseBlock; depth: number; parentId: string | null } | null {
|
|
16
|
+
for (const b of list) {
|
|
17
|
+
if (!b) continue
|
|
18
|
+
if (b.id === targetId) {
|
|
19
|
+
return { block: b, depth: currentDepth, parentId: null }
|
|
20
|
+
}
|
|
21
|
+
const isSec = b.type === "SectionBlock"
|
|
22
|
+
const nextDepth = isSec ? currentDepth + 1 : currentDepth
|
|
23
|
+
const children = b.children || b.props?.children
|
|
24
|
+
if (Array.isArray(children)) {
|
|
25
|
+
const found = findBlockInfo(children, targetId, nextDepth)
|
|
26
|
+
if (found) {
|
|
27
|
+
if (!found.parentId && b.id) {
|
|
28
|
+
found.parentId = b.id
|
|
29
|
+
}
|
|
30
|
+
return found
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getMaxSubtreeSectionHeight(b: BaseBlock | null): number {
|
|
38
|
+
if (!b) return 0
|
|
39
|
+
const isSec = b.type === "SectionBlock"
|
|
40
|
+
const children = b.children || b.props?.children
|
|
41
|
+
let maxChildHeight = 0
|
|
42
|
+
if (Array.isArray(children)) {
|
|
43
|
+
for (const c of children) {
|
|
44
|
+
const h = getMaxSubtreeSectionHeight(c)
|
|
45
|
+
if (h > maxChildHeight) maxChildHeight = h
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return isSec ? 1 + maxChildHeight : maxChildHeight
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createEditorState(
|
|
52
|
+
initialBlocks: BaseBlock[] = [],
|
|
53
|
+
pageId: string = "default-page"
|
|
54
|
+
) {
|
|
55
|
+
const [blocks, setBlocksStore] = createStore<BaseBlock[]>(
|
|
56
|
+
JSON.parse(JSON.stringify(initialBlocks))
|
|
57
|
+
)
|
|
58
|
+
const [status, setStatus] = createSignal<AutosaveStatus>("saved")
|
|
59
|
+
const [previewVisible, setPreviewVisible] = createSignal<boolean>(false)
|
|
60
|
+
const [pickerTarget, setPickerTarget] = createSignal<{
|
|
61
|
+
open: boolean
|
|
62
|
+
targetId: string | null
|
|
63
|
+
position: "before" | "after"
|
|
64
|
+
sectionDepth: number
|
|
65
|
+
parentId?: string | null
|
|
66
|
+
containerType?: string | null
|
|
67
|
+
}>({
|
|
68
|
+
open: false,
|
|
69
|
+
targetId: null,
|
|
70
|
+
position: "after",
|
|
71
|
+
sectionDepth: 0,
|
|
72
|
+
parentId: null,
|
|
73
|
+
containerType: null
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
let historyStack = new HistoryStack(initialBlocks)
|
|
77
|
+
let autosaveController = new AutosaveController({
|
|
78
|
+
pageId,
|
|
79
|
+
onStatusChange: (newStatus: AutosaveStatus) => {
|
|
80
|
+
setStatus(newStatus)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const notifyChange = (immediate = false) => {
|
|
85
|
+
const rawBlocks = sanitizeBlockTree(JSON.parse(JSON.stringify(blocks)))
|
|
86
|
+
historyStack.push(rawBlocks, immediate)
|
|
87
|
+
if (immediate) {
|
|
88
|
+
void autosaveController.saveImmediate({ blocks: rawBlocks })
|
|
89
|
+
} else {
|
|
90
|
+
autosaveController.notifyChange({ blocks: rawBlocks })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const setBlocks = (newBlocks: BaseBlock[]) => {
|
|
95
|
+
historyStack.pause()
|
|
96
|
+
setBlocksStore(reconcile(JSON.parse(JSON.stringify(newBlocks))))
|
|
97
|
+
historyStack = new HistoryStack(newBlocks)
|
|
98
|
+
historyStack.resume()
|
|
99
|
+
notifyChange(true)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const updateBlockProps = (id: string, newProps: Record<string, any>) => {
|
|
103
|
+
const updated = cloneBlocks(blocks)
|
|
104
|
+
const findAndUpdate = (list: BaseBlock[]): boolean => {
|
|
105
|
+
for (const item of list) {
|
|
106
|
+
if (!item) continue
|
|
107
|
+
if (item.id === id) {
|
|
108
|
+
item.props = { ...item.props, ...newProps }
|
|
109
|
+
if (newProps.children !== undefined) {
|
|
110
|
+
item.children = newProps.children
|
|
111
|
+
item.props.children = newProps.children
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
const childrenList = item.children || item.props?.children
|
|
116
|
+
if (childrenList && findAndUpdate(childrenList)) {
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
if (findAndUpdate(updated)) {
|
|
123
|
+
setBlocksStore(reconcile(updated))
|
|
124
|
+
notifyChange()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const undo = () => {
|
|
129
|
+
const prev = historyStack.undo()
|
|
130
|
+
if (prev) {
|
|
131
|
+
setBlocksStore(reconcile(prev))
|
|
132
|
+
autosaveController.notifyChange({ blocks: prev })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const redo = () => {
|
|
137
|
+
const next = historyStack.redo()
|
|
138
|
+
if (next) {
|
|
139
|
+
setBlocksStore(reconcile(next))
|
|
140
|
+
autosaveController.notifyChange({ blocks: next })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const deleteBlock = (id: string) => {
|
|
145
|
+
const removeFromList = (list: BaseBlock[]): BaseBlock[] => {
|
|
146
|
+
return list.filter((b) => {
|
|
147
|
+
if (b.id === id) return false
|
|
148
|
+
if (b.children) {
|
|
149
|
+
b.children = removeFromList(b.children)
|
|
150
|
+
}
|
|
151
|
+
if (b.props?.children) {
|
|
152
|
+
b.props.children = removeFromList(b.props.children)
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
const updated = removeFromList(JSON.parse(JSON.stringify(blocks)))
|
|
158
|
+
setBlocksStore(reconcile(updated))
|
|
159
|
+
notifyChange(true)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const moveBlock = (id: string, direction: -1 | 1) => {
|
|
163
|
+
const updated = cloneBlocks(blocks)
|
|
164
|
+
const findAndMove = (list: BaseBlock[]): boolean => {
|
|
165
|
+
const idx = list.findIndex((b) => b.id === id)
|
|
166
|
+
const newIdx = idx + direction
|
|
167
|
+
if (idx !== -1 && newIdx >= 0 && newIdx < list.length) {
|
|
168
|
+
const item = list.splice(idx, 1)[0]
|
|
169
|
+
if (item) {
|
|
170
|
+
list.splice(newIdx, 0, item)
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const item of list) {
|
|
175
|
+
const children = item.children || item.props?.children
|
|
176
|
+
if (children && findAndMove(children)) {
|
|
177
|
+
item.children = children
|
|
178
|
+
item.props.children = children
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
findAndMove(updated)
|
|
185
|
+
setBlocksStore(reconcile(updated))
|
|
186
|
+
notifyChange(true)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const duplicateBlock = (id: string) => {
|
|
190
|
+
const updated = cloneBlocks(blocks)
|
|
191
|
+
const findAndDup = (list: BaseBlock[]): boolean => {
|
|
192
|
+
const idx = list.findIndex((b) => b.id === id)
|
|
193
|
+
if (idx !== -1) {
|
|
194
|
+
const clone: BaseBlock = JSON.parse(JSON.stringify(list[idx]))
|
|
195
|
+
clone.id =
|
|
196
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
197
|
+
? crypto.randomUUID()
|
|
198
|
+
: Math.random().toString(36).slice(2)
|
|
199
|
+
list.splice(idx + 1, 0, clone)
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
for (const item of list) {
|
|
203
|
+
const children = item.children || item.props?.children
|
|
204
|
+
if (children && findAndDup(children)) {
|
|
205
|
+
item.children = children
|
|
206
|
+
item.props.children = children
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
findAndDup(updated)
|
|
213
|
+
setBlocksStore(reconcile(updated))
|
|
214
|
+
notifyChange(true)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const insertBlock = (
|
|
218
|
+
type: string,
|
|
219
|
+
targetId: string | null = null,
|
|
220
|
+
position: "before" | "after" = "after",
|
|
221
|
+
parentId: string | null = null
|
|
222
|
+
) => {
|
|
223
|
+
const updated = cloneBlocks(blocks)
|
|
224
|
+
|
|
225
|
+
if (type === "SectionBlock") {
|
|
226
|
+
let targetSectionDepth = 0
|
|
227
|
+
if (parentId) {
|
|
228
|
+
const parentInfo = findBlockInfo(updated, parentId, 0)
|
|
229
|
+
if (parentInfo) {
|
|
230
|
+
const isParentSection = parentInfo.block.type === "SectionBlock"
|
|
231
|
+
targetSectionDepth = isParentSection ? parentInfo.depth + 1 : parentInfo.depth
|
|
232
|
+
}
|
|
233
|
+
} else if (targetId) {
|
|
234
|
+
const targetInfo = findBlockInfo(updated, targetId, 0)
|
|
235
|
+
if (targetInfo) {
|
|
236
|
+
targetSectionDepth = targetInfo.depth
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (targetSectionDepth > MAX_SECTION_DEPTH) {
|
|
241
|
+
toast.error(
|
|
242
|
+
"Invalid Section Nesting",
|
|
243
|
+
"Sections cannot be nested inside an H6 section. H6 is the maximum heading level."
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const newBlock = createDefaultBlock(type)
|
|
250
|
+
|
|
251
|
+
const insertIntoList = (list: BaseBlock[]): boolean => {
|
|
252
|
+
if (targetId) {
|
|
253
|
+
const idx = list.findIndex((b) => b.id === targetId)
|
|
254
|
+
if (idx !== -1) {
|
|
255
|
+
list.splice(position === "after" ? idx + 1 : idx, 0, newBlock)
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (parentId) {
|
|
260
|
+
for (const item of list) {
|
|
261
|
+
if (item.id === parentId) {
|
|
262
|
+
const children = item.children || item.props?.children || []
|
|
263
|
+
if (position === "after") {
|
|
264
|
+
children.push(newBlock)
|
|
265
|
+
} else {
|
|
266
|
+
children.unshift(newBlock)
|
|
267
|
+
}
|
|
268
|
+
item.children = children
|
|
269
|
+
item.props.children = children
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const item of list) {
|
|
275
|
+
const children = item.children || item.props?.children
|
|
276
|
+
if (children && insertIntoList(children)) {
|
|
277
|
+
item.children = children
|
|
278
|
+
item.props.children = children
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!targetId && !parentId) {
|
|
286
|
+
updated.push(newBlock)
|
|
287
|
+
} else {
|
|
288
|
+
const inserted = insertIntoList(updated)
|
|
289
|
+
if (!inserted) updated.push(newBlock)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
setBlocksStore(reconcile(updated))
|
|
293
|
+
notifyChange(true)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const openPicker = (
|
|
297
|
+
targetId: string | null = null,
|
|
298
|
+
position: "before" | "after" = "after",
|
|
299
|
+
sectionDepth = 0,
|
|
300
|
+
parentId: string | null = null,
|
|
301
|
+
containerType: string | null = null
|
|
302
|
+
) => {
|
|
303
|
+
setPickerTarget({ open: true, targetId, position, sectionDepth, parentId, containerType })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const closePicker = () => {
|
|
307
|
+
setPickerTarget((prev) => ({ ...prev, open: false }))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const relocateBlock = (id: string, newIndex: number, targetParentId?: string | null) => {
|
|
311
|
+
const updated = cloneBlocks(blocks)
|
|
312
|
+
let extractedBlock: BaseBlock | null = null
|
|
313
|
+
|
|
314
|
+
const remove = (list: BaseBlock[]): BaseBlock[] => {
|
|
315
|
+
return list.filter((b) => {
|
|
316
|
+
if (b.id === id) {
|
|
317
|
+
extractedBlock = b
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
if (b.children) {
|
|
321
|
+
b.children = remove(b.children)
|
|
322
|
+
if (b.props) b.props.children = b.children
|
|
323
|
+
} else if (b.props?.children) {
|
|
324
|
+
b.props.children = remove(b.props.children)
|
|
325
|
+
b.children = b.props.children
|
|
326
|
+
}
|
|
327
|
+
return true
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const cleanedTree = remove(updated)
|
|
332
|
+
if (!extractedBlock) return
|
|
333
|
+
|
|
334
|
+
const sectionHeight = getMaxSubtreeSectionHeight(extractedBlock)
|
|
335
|
+
if (sectionHeight > 0) {
|
|
336
|
+
let targetBaseSectionDepth = 0
|
|
337
|
+
if (targetParentId) {
|
|
338
|
+
const parentInfo = findBlockInfo(cleanedTree, targetParentId, 0)
|
|
339
|
+
if (parentInfo) {
|
|
340
|
+
const isParentSection = parentInfo.block.type === "SectionBlock"
|
|
341
|
+
targetBaseSectionDepth = isParentSection ? parentInfo.depth + 1 : parentInfo.depth
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const isMovingSection = (extractedBlock as BaseBlock).type === "SectionBlock"
|
|
345
|
+
const maxResultDepth = isMovingSection
|
|
346
|
+
? targetBaseSectionDepth + sectionHeight - 1
|
|
347
|
+
: targetBaseSectionDepth + sectionHeight
|
|
348
|
+
if (maxResultDepth > MAX_SECTION_DEPTH) {
|
|
349
|
+
toast.error(
|
|
350
|
+
"Invalid Section Nesting",
|
|
351
|
+
"Cannot move section here because it would exceed the maximum heading depth (H6)."
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!targetParentId) {
|
|
358
|
+
const idx = Math.min(Math.max(0, newIndex), cleanedTree.length)
|
|
359
|
+
cleanedTree.splice(idx, 0, extractedBlock)
|
|
360
|
+
} else {
|
|
361
|
+
const insertIntoTarget = (list: BaseBlock[]): boolean => {
|
|
362
|
+
for (const item of list) {
|
|
363
|
+
if (item.id === targetParentId) {
|
|
364
|
+
const children = item.children || item.props?.children || []
|
|
365
|
+
const idx = Math.min(Math.max(0, newIndex), children.length)
|
|
366
|
+
children.splice(idx, 0, extractedBlock!)
|
|
367
|
+
item.children = children
|
|
368
|
+
item.props.children = children
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
const sub = item.children || item.props?.children
|
|
372
|
+
if (sub && insertIntoTarget(sub)) return true
|
|
373
|
+
}
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
if (!insertIntoTarget(cleanedTree)) {
|
|
377
|
+
const idx = Math.min(Math.max(0, newIndex), cleanedTree.length)
|
|
378
|
+
cleanedTree.splice(idx, 0, extractedBlock)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
setBlocksStore(reconcile(cleanedTree))
|
|
383
|
+
notifyChange(true)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const saveDraft = () => {
|
|
387
|
+
autosaveController.notifyChange({ blocks: JSON.parse(JSON.stringify(blocks)) })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
blocks,
|
|
392
|
+
status,
|
|
393
|
+
previewVisible,
|
|
394
|
+
setPreviewVisible,
|
|
395
|
+
pickerTarget,
|
|
396
|
+
setBlocks,
|
|
397
|
+
updateBlockProps,
|
|
398
|
+
undo,
|
|
399
|
+
redo,
|
|
400
|
+
deleteBlock,
|
|
401
|
+
moveBlock,
|
|
402
|
+
duplicateBlock,
|
|
403
|
+
insertBlock,
|
|
404
|
+
relocateBlock,
|
|
405
|
+
openPicker,
|
|
406
|
+
closePicker,
|
|
407
|
+
saveDraft
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export type EditorState = ReturnType<typeof createEditorState>
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { BaseBlock } from "../../core/types/blocks"
|
|
2
|
+
|
|
3
|
+
export class HistoryStack {
|
|
4
|
+
private undoStack: BaseBlock[][] = []
|
|
5
|
+
private redoStack: BaseBlock[][] = []
|
|
6
|
+
private debounceTimer: number | null = null
|
|
7
|
+
private maxDepth: number
|
|
8
|
+
private paused = false
|
|
9
|
+
|
|
10
|
+
constructor(initialBlocks: BaseBlock[] = [], maxDepth = 100) {
|
|
11
|
+
this.maxDepth = maxDepth
|
|
12
|
+
this.undoStack.push(JSON.parse(JSON.stringify(initialBlocks)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(blocks: BaseBlock[], immediate = false): void {
|
|
16
|
+
if (this.paused) return
|
|
17
|
+
const clone = JSON.parse(JSON.stringify(blocks))
|
|
18
|
+
|
|
19
|
+
if (immediate) {
|
|
20
|
+
this.doPush(clone)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (this.debounceTimer !== null) {
|
|
25
|
+
clearTimeout(this.debounceTimer)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.debounceTimer = window.setTimeout(() => {
|
|
29
|
+
this.doPush(clone)
|
|
30
|
+
this.debounceTimer = null
|
|
31
|
+
}, 500)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private doPush(snapshot: BaseBlock[]): void {
|
|
35
|
+
if (this.undoStack.length >= this.maxDepth) {
|
|
36
|
+
this.undoStack.shift()
|
|
37
|
+
}
|
|
38
|
+
this.undoStack.push(snapshot)
|
|
39
|
+
this.redoStack = []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
undo(): BaseBlock[] | null {
|
|
43
|
+
if (this.undoStack.length <= 1) return null
|
|
44
|
+
const current = this.undoStack.pop()!
|
|
45
|
+
this.redoStack.push(current)
|
|
46
|
+
const previous = this.undoStack[this.undoStack.length - 1]
|
|
47
|
+
return JSON.parse(JSON.stringify(previous))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
redo(): BaseBlock[] | null {
|
|
51
|
+
if (this.redoStack.length === 0) return null
|
|
52
|
+
const next = this.redoStack.pop()!
|
|
53
|
+
this.undoStack.push(next)
|
|
54
|
+
return JSON.parse(JSON.stringify(next))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Suppress snapshot captures (e.g. during programmatic block loads)
|
|
59
|
+
*/
|
|
60
|
+
pause(): void {
|
|
61
|
+
this.paused = true
|
|
62
|
+
if (this.debounceTimer !== null) {
|
|
63
|
+
clearTimeout(this.debounceTimer)
|
|
64
|
+
this.debounceTimer = null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resume snapshot captures
|
|
70
|
+
*/
|
|
71
|
+
resume(): void {
|
|
72
|
+
this.paused = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns an immutable deep-clone of the current state for persistence
|
|
77
|
+
*/
|
|
78
|
+
commit(blocks: BaseBlock[]): BaseBlock[] {
|
|
79
|
+
return JSON.parse(JSON.stringify(blocks))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
canUndo(): boolean {
|
|
83
|
+
return this.undoStack.length > 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
canRedo(): boolean {
|
|
87
|
+
return this.redoStack.length > 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
reset(blocks: BaseBlock[]): void {
|
|
91
|
+
this.undoStack = [JSON.parse(JSON.stringify(blocks))]
|
|
92
|
+
this.redoStack = []
|
|
93
|
+
this.paused = false
|
|
94
|
+
if (this.debounceTimer !== null) {
|
|
95
|
+
clearTimeout(this.debounceTimer)
|
|
96
|
+
this.debounceTimer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Default block factory (shared across all editors) ────────────────────────
|
|
102
|
+
|
|
103
|
+
export function createDefaultBlock(type: string): BaseBlock {
|
|
104
|
+
const id =
|
|
105
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
106
|
+
? crypto.randomUUID()
|
|
107
|
+
: Math.random().toString(36).slice(2, 11)
|
|
108
|
+
|
|
109
|
+
switch (type) {
|
|
110
|
+
case "SectionBlock":
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
type,
|
|
114
|
+
children: [],
|
|
115
|
+
props: { level: 2, title: "New Section", description: "", children: [] }
|
|
116
|
+
}
|
|
117
|
+
case "ParagraphBlock":
|
|
118
|
+
return { id, type, props: { text: [] } }
|
|
119
|
+
case "CalloutBlock":
|
|
120
|
+
return { id, type, children: [], props: { variant: "info", children: [] } }
|
|
121
|
+
case "ImageBlock":
|
|
122
|
+
return { id, type, props: { src: "", alt: "", caption: "" } }
|
|
123
|
+
case "ScriptBlock":
|
|
124
|
+
return {
|
|
125
|
+
id,
|
|
126
|
+
type,
|
|
127
|
+
children: [],
|
|
128
|
+
props: {
|
|
129
|
+
title: "New Script",
|
|
130
|
+
synopsis: "",
|
|
131
|
+
characters: [],
|
|
132
|
+
aPlot: "",
|
|
133
|
+
bPlot: "",
|
|
134
|
+
cPlot: "",
|
|
135
|
+
stinger: "",
|
|
136
|
+
children: []
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
case "SceneBlock":
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
type,
|
|
143
|
+
children: [],
|
|
144
|
+
props: {
|
|
145
|
+
location: "",
|
|
146
|
+
timeOfDay: "OTHER",
|
|
147
|
+
setting: "OTHER",
|
|
148
|
+
transition: "NONE",
|
|
149
|
+
description: "",
|
|
150
|
+
children: []
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
case "DialogueBlock":
|
|
154
|
+
return { id, type, props: { character: "", parenthetical: "", line: "" } }
|
|
155
|
+
case "CodeBlock":
|
|
156
|
+
return { id, type, props: { code: "", language: "typescript", caption: "" } }
|
|
157
|
+
case "TableBlock":
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
type,
|
|
161
|
+
props: {
|
|
162
|
+
columns: [
|
|
163
|
+
{ key: "col1", header: "Header 1", align: "left" },
|
|
164
|
+
{ key: "col2", header: "Header 2", align: "left" }
|
|
165
|
+
],
|
|
166
|
+
rows: [{ col1: "Cell 1", col2: "Cell 2" }],
|
|
167
|
+
caption: ""
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
case "TabsBlock":
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
type,
|
|
174
|
+
children: [],
|
|
175
|
+
props: {
|
|
176
|
+
defaultValue: "tab-1",
|
|
177
|
+
children: []
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
case "TabItemBlock":
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
type,
|
|
184
|
+
children: [],
|
|
185
|
+
props: {
|
|
186
|
+
label: "Tab 1",
|
|
187
|
+
value: "tab-1",
|
|
188
|
+
children: []
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
case "StepsBlock":
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
type,
|
|
195
|
+
children: [],
|
|
196
|
+
props: {
|
|
197
|
+
children: []
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
case "StepItemBlock":
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
type,
|
|
204
|
+
children: [],
|
|
205
|
+
props: {
|
|
206
|
+
title: "Step 1",
|
|
207
|
+
description: "",
|
|
208
|
+
children: []
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
case "CardsBlock":
|
|
212
|
+
return {
|
|
213
|
+
id,
|
|
214
|
+
type,
|
|
215
|
+
children: [],
|
|
216
|
+
props: {
|
|
217
|
+
columns: 2,
|
|
218
|
+
children: []
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
case "CardBlock":
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
type,
|
|
225
|
+
props: {
|
|
226
|
+
title: "Card Title",
|
|
227
|
+
description: "Card description text",
|
|
228
|
+
icon: "i-lucide-book-open",
|
|
229
|
+
href: "",
|
|
230
|
+
badge: ""
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
case "FileTreeBlock":
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
type,
|
|
237
|
+
props: {
|
|
238
|
+
tree: [
|
|
239
|
+
{
|
|
240
|
+
name: "src",
|
|
241
|
+
isDir: true,
|
|
242
|
+
children: [{ name: "index.ts", isDir: false }]
|
|
243
|
+
},
|
|
244
|
+
{ name: "package.json", isDir: false }
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
default:
|
|
249
|
+
return { id, type, props: { text: [] } }
|
|
250
|
+
}
|
|
251
|
+
}
|