Files
blockflow-workbench/src/app/store/workbenchStore.ts
gamewhale 589ff15213 chore: 初始化 BlockFlow Workbench 仓库
建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。

补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
2026-05-29 17:23:43 +08:00

1004 lines
28 KiB
TypeScript

import { create } from "zustand"
import { demoSnapshot } from "../demo/demoProject"
import { buildReferenceIndex, renderTemplate } from "../../core"
import type {
BlockNode,
DataSetDocument,
FragmentDocument,
InlineNode,
ProjectDiagnostic,
ProjectResourceDirectory,
ProjectResourceFile,
ProjectResourceNode,
ProjectSnapshot,
RenderResult,
TemplateDocument
} from "../../core/types"
import { createBlockId } from "../../utils/id"
export type RightPanelTab = "properties" | "data" | "preview" | "debug" | "references"
export type SaveStatus = "saved" | "dirty" | "saving" | "error"
export type DataEditorStatus = "valid" | "invalid"
export type ProjectOpenStatus = "demo" | "opening" | "open" | "error"
export type OpenDocument = TemplateDocument | FragmentDocument
export type ProjectPersistence = {
isAvailable: () => boolean
openProjectFromDisk: () => Promise<ProjectSnapshot | null>
saveProjectChanges: (snapshot: ProjectSnapshot, dirtyDocumentIds: string[]) => Promise<ProjectSnapshot>
}
export type WorkbenchState = {
snapshot: ProjectSnapshot
selectedDocumentId: string
selectedResourcePath: string
currentDatasetId: string
documentRevision: number
dataEditorText: string
dataEditorStatus: DataEditorStatus
dataEditorError: string | null
selectedBlockId: string | null
rightPanelTab: RightPanelTab
saveStatus: SaveStatus
projectOpenStatus: ProjectOpenStatus
dirtyDocumentIds: string[]
lastSaveError: string | null
isProjectPersistenceAvailable: boolean
openProjectFromDisk: () => Promise<void>
saveProjectChanges: () => Promise<void>
createFragment: () => void
selectResource: (resource: ProjectResourceFile) => void
openDocumentById: (documentId: string) => void
selectDocument: (documentId: string, resourcePath?: string) => void
setCurrentDataset: (datasetId: string) => void
setDataEditorText: (text: string) => void
updateCurrentDatasetData: (data: Record<string, unknown>) => void
setSelectedBlockId: (blockId: string | null) => void
setRightPanelTab: (tab: RightPanelTab) => void
updateOpenDocumentChildren: (children: BlockNode[]) => void
expandSelectedFragmentReference: () => void
resetDemo: () => void
}
export const defaultDocumentId = demoSnapshot.project.entryTemplateId ?? Object.keys(demoSnapshot.templates)[0]!
export const defaultDatasetId = Object.keys(demoSnapshot.datasets)[0]!
export const defaultResourcePath = findResourcePathByDocumentId(
demoSnapshot.resourceTree.children,
defaultDocumentId
) ?? "templates/main.json"
const fallbackDataset: DataSetDocument = {
kind: "dataset",
id: "dataset_runtime_empty",
name: "empty",
data: {}
}
let projectPersistenceOverride: ProjectPersistence | null = null
export function setProjectPersistenceForTests(persistence: ProjectPersistence | null): void {
projectPersistenceOverride = persistence
useWorkbenchStore.setState({
isProjectPersistenceAvailable: getProjectPersistence().isAvailable()
})
}
export const useWorkbenchStore = create<WorkbenchState>((set) => ({
snapshot: demoSnapshot,
selectedDocumentId: defaultDocumentId,
selectedResourcePath: defaultResourcePath,
currentDatasetId: defaultDatasetId,
documentRevision: 0,
dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}),
dataEditorStatus: "valid",
dataEditorError: null,
selectedBlockId: null,
rightPanelTab: "properties",
saveStatus: "saved",
projectOpenStatus: "demo",
dirtyDocumentIds: [],
lastSaveError: null,
isProjectPersistenceAvailable: getProjectPersistence().isAvailable(),
async openProjectFromDisk() {
const persistence = getProjectPersistence()
if (!persistence.isAvailable()) {
set({
projectOpenStatus: "error",
saveStatus: "error",
lastSaveError: "Desktop file access is only available in the Tauri app."
})
return
}
set({ projectOpenStatus: "opening", lastSaveError: null })
try {
const snapshot = await persistence.openProjectFromDisk()
if (snapshot === null) {
set((state) => ({
projectOpenStatus: state.snapshot === demoSnapshot ? "demo" : "open"
}))
return
}
set((state) => ({
...state,
...stateForSnapshot(snapshot, state),
projectOpenStatus: "open",
dirtyDocumentIds: [],
lastSaveError: null,
saveStatus: "saved",
documentRevision: state.documentRevision + 1
}))
} catch (error) {
set({
projectOpenStatus: "error",
saveStatus: "error",
lastSaveError: formatError(error)
})
}
},
async saveProjectChanges() {
const persistence = getProjectPersistence()
if (!persistence.isAvailable()) {
set({
saveStatus: "error",
lastSaveError: "Desktop file access is only available in the Tauri app."
})
return
}
set((state) => {
if (state.dirtyDocumentIds.length === 0) {
return state
}
return {
saveStatus: "saving",
lastSaveError: null
}
})
const state = useWorkbenchStore.getState()
if (state.dirtyDocumentIds.length === 0) {
return
}
try {
const snapshot = await persistence.saveProjectChanges(state.snapshot, state.dirtyDocumentIds)
set((current) => ({
...current,
...stateForSnapshot(snapshot, current),
projectOpenStatus: "open",
dirtyDocumentIds: [],
lastSaveError: null,
saveStatus: "saved",
documentRevision: current.documentRevision + 1
}))
} catch (error) {
set({
saveStatus: "error",
lastSaveError: formatError(error)
})
}
},
createFragment() {
set((state) => {
const next = nextFragmentIdentity(state.snapshot)
const now = new Date().toISOString()
const fragment: FragmentDocument = {
kind: "fragment",
id: next.id,
name: next.name,
filePath: next.path,
createdAt: now,
updatedAt: now,
children: [
{
id: createBlockId("paragraph"),
type: "paragraph",
inlines: []
}
]
}
const fragments = {
...state.snapshot.fragments,
[fragment.id]: fragment
}
const referenceIndex = buildReferenceIndex([
...Object.values(state.snapshot.templates),
...Object.values(fragments)
])
const diagnostics = rebuildReferenceDiagnostics(
state.snapshot.diagnostics,
referenceIndex.missingFragments
)
const resourceFile: ProjectResourceFile = {
type: "file",
name: next.fileName,
path: next.path,
relativePath: next.relativePath,
resourceKind: "fragment",
documentId: fragment.id
}
return {
snapshot: {
...state.snapshot,
fragments,
resourceTree: {
...state.snapshot.resourceTree,
children: addResourceToDirectory(
state.snapshot.resourceTree.children,
"fragments",
resourceFile,
state.snapshot.project.paths.fragmentsDir
)
},
referenceIndex,
diagnostics
},
selectedDocumentId: fragment.id,
selectedResourcePath: next.relativePath,
selectedBlockId: null,
rightPanelTab: "properties",
documentRevision: state.documentRevision + 1,
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, fragment.id),
lastSaveError: null,
saveStatus: "dirty"
}
})
},
selectResource(resource) {
set((state) => {
const nextState: Partial<WorkbenchState> = {
selectedResourcePath: resource.relativePath
}
if (
resource.documentId !== undefined &&
(resource.resourceKind === "template" || resource.resourceKind === "fragment")
) {
nextState.selectedDocumentId = resource.documentId
}
if (resource.documentId !== undefined && resource.resourceKind === "dataset") {
nextState.currentDatasetId = resource.documentId
nextState.dataEditorText = datasetDataTextFor(state.snapshot, resource.documentId)
nextState.dataEditorStatus = "valid"
nextState.dataEditorError = null
nextState.rightPanelTab = "data"
}
return {
...state,
...nextState
}
})
},
openDocumentById(documentId) {
set((state) => {
const document = state.snapshot.templates[documentId] ?? state.snapshot.fragments[documentId]
if (document === undefined) {
return state
}
return {
selectedDocumentId: documentId,
selectedResourcePath:
findResourcePathByDocumentId(state.snapshot.resourceTree.children, documentId) ??
state.selectedResourcePath,
selectedBlockId: null,
rightPanelTab: document.kind === "fragment" ? "properties" : state.rightPanelTab
}
})
},
selectDocument(documentId, resourcePath) {
set((state) => ({
selectedDocumentId: documentId,
selectedResourcePath: resourcePath ?? state.selectedResourcePath
}))
},
setCurrentDataset(datasetId) {
set((state) => ({
currentDatasetId: datasetId,
dataEditorText: datasetDataTextFor(state.snapshot, datasetId),
dataEditorStatus: "valid",
dataEditorError: null
}))
},
setDataEditorText(text) {
const parsed = parseDatasetJson(text)
if (!parsed.ok) {
set({
dataEditorText: text,
dataEditorStatus: "invalid",
dataEditorError: parsed.error
})
return
}
set((state) => applyDatasetDataUpdate(state, parsed.data, text))
},
updateCurrentDatasetData(data) {
set((state) => applyDatasetDataUpdate(state, data, formatJson(data)))
},
setSelectedBlockId(blockId) {
set({ selectedBlockId: blockId })
},
setRightPanelTab(tab) {
set({ rightPanelTab: tab })
},
updateOpenDocumentChildren(children) {
set((state) => {
const template = state.snapshot.templates[state.selectedDocumentId]
const fragment = state.snapshot.fragments[state.selectedDocumentId]
if (template === undefined && fragment === undefined) {
return state
}
const templates = { ...state.snapshot.templates }
const fragments = { ...state.snapshot.fragments }
if (template !== undefined) {
templates[template.id] = { ...template, children }
} else if (fragment !== undefined) {
fragments[fragment.id] = { ...fragment, children }
}
const referenceIndex = buildReferenceIndex([
...Object.values(templates),
...Object.values(fragments)
])
const diagnostics = rebuildReferenceDiagnostics(
state.snapshot.diagnostics,
referenceIndex.missingFragments
)
return {
snapshot: {
...state.snapshot,
templates,
fragments,
referenceIndex,
diagnostics
},
selectedBlockId: blockListContainsId(children, state.selectedBlockId) ? state.selectedBlockId : null,
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, state.selectedDocumentId),
lastSaveError: null,
saveStatus: "dirty"
}
})
},
expandSelectedFragmentReference() {
set((state) => {
if (state.selectedBlockId === null) {
return state
}
const document = getCurrentDocument(state)
const selectedBlock = findBlockById(document.children, state.selectedBlockId)
if (selectedBlock?.type !== "fragmentRef") {
return state
}
const fragment = state.snapshot.fragments[selectedBlock.fragmentId]
if (fragment === undefined) {
return state
}
const replacement = cloneBlocks(fragment.children)
const replaced = replaceBlockById(document.children, selectedBlock.id, replacement)
if (!replaced.replaced) {
return state
}
const templates = { ...state.snapshot.templates }
const fragments = { ...state.snapshot.fragments }
if (document.kind === "template") {
templates[document.id] = { ...document, children: replaced.blocks }
} else {
fragments[document.id] = { ...document, children: replaced.blocks }
}
const referenceIndex = buildReferenceIndex([
...Object.values(templates),
...Object.values(fragments)
])
const diagnostics = rebuildReferenceDiagnostics(
state.snapshot.diagnostics,
referenceIndex.missingFragments
)
return {
snapshot: {
...state.snapshot,
templates,
fragments,
referenceIndex,
diagnostics
},
selectedBlockId: replacement[0]?.id ?? null,
documentRevision: state.documentRevision + 1,
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, document.id),
lastSaveError: null,
saveStatus: "dirty"
}
})
},
resetDemo() {
set({
snapshot: demoSnapshot,
selectedDocumentId: defaultDocumentId,
selectedResourcePath: defaultResourcePath,
currentDatasetId: defaultDatasetId,
documentRevision: 0,
dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}),
dataEditorStatus: "valid",
dataEditorError: null,
selectedBlockId: null,
rightPanelTab: "properties",
saveStatus: "saved",
projectOpenStatus: "demo",
dirtyDocumentIds: [],
lastSaveError: null,
isProjectPersistenceAvailable: getProjectPersistence().isAvailable()
})
}
}))
export function getCurrentDocument(state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId">): OpenDocument {
const template = state.snapshot.templates[state.selectedDocumentId]
if (template !== undefined) {
return template
}
const fragment = state.snapshot.fragments[state.selectedDocumentId]
if (fragment !== undefined) {
return fragment
}
return state.snapshot.templates[defaultDocumentId]!
}
export function getCurrentDataset(state: Pick<WorkbenchState, "snapshot" | "currentDatasetId">): DataSetDocument {
return state.snapshot.datasets[state.currentDatasetId] ?? state.snapshot.datasets[defaultDatasetId] ?? fallbackDataset
}
export function getCurrentRenderResult(
state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId" | "currentDatasetId">,
options: { targetBlockId?: string | null } = {}
): RenderResult {
const document = getCurrentDocument(state)
const dataset = getCurrentDataset(state)
const renderOptions = options.targetBlockId == null ? undefined : { targetBlockId: options.targetBlockId }
if (document.kind === "fragment") {
return renderTemplate({
template: {
kind: "template",
id: `preview_${document.id}`,
name: document.name,
children: document.children
},
fragments: state.snapshot.fragments,
dataset,
...(renderOptions === undefined ? {} : { options: renderOptions })
})
}
return renderTemplate({
template: document,
fragments: state.snapshot.fragments,
dataset,
...(renderOptions === undefined ? {} : { options: renderOptions })
})
}
export function getCurrentBlocks(state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId">): BlockNode[] {
return getCurrentDocument(state).children
}
export function getSelectedBlock(
state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId" | "selectedBlockId">
): BlockNode | undefined {
if (state.selectedBlockId === null) {
return undefined
}
return findBlockById(getCurrentDocument(state).children, state.selectedBlockId)
}
function findResourcePathByDocumentId(
nodes: ProjectResourceNode[],
documentId: string
): string | undefined {
for (const node of nodes) {
if (node.type === "file" && node.documentId === documentId) {
return node.relativePath
}
if (node.type === "directory") {
const found = findResourcePathByDocumentId(node.children, documentId)
if (found !== undefined) {
return found
}
}
}
return undefined
}
function getProjectPersistence(): ProjectPersistence {
if (projectPersistenceOverride !== null) {
return projectPersistenceOverride
}
return {
isAvailable: isTauriRuntime,
async openProjectFromDisk() {
const { createTauriProjectPersistence } = await import("../desktop/projectPersistence")
return createTauriProjectPersistence().openProjectFromDisk()
},
async saveProjectChanges(snapshot, dirtyDocumentIds) {
const { createTauriProjectPersistence } = await import("../desktop/projectPersistence")
return createTauriProjectPersistence().saveProjectChanges(snapshot, dirtyDocumentIds)
}
}
}
function isTauriRuntime(): boolean {
if (typeof window === "undefined") {
return false
}
const runtime = window as Window & {
__TAURI__?: unknown
__TAURI_INTERNALS__?: unknown
}
return runtime.__TAURI__ !== undefined || runtime.__TAURI_INTERNALS__ !== undefined
}
function stateForSnapshot(
snapshot: ProjectSnapshot,
previous: Pick<WorkbenchState, "selectedDocumentId" | "currentDatasetId">
): Pick<
WorkbenchState,
| "snapshot"
| "selectedDocumentId"
| "selectedResourcePath"
| "currentDatasetId"
| "dataEditorText"
| "dataEditorStatus"
| "dataEditorError"
| "selectedBlockId"
| "rightPanelTab"
> {
const selectedDocumentId = documentExists(snapshot, previous.selectedDocumentId)
? previous.selectedDocumentId
: defaultDocumentIdForSnapshot(snapshot)
const currentDatasetId = snapshot.datasets[previous.currentDatasetId] !== undefined
? previous.currentDatasetId
: defaultDatasetIdForSnapshot(snapshot)
return {
snapshot,
selectedDocumentId,
selectedResourcePath:
findResourcePathByDocumentId(snapshot.resourceTree.children, selectedDocumentId) ?? defaultResourcePath,
currentDatasetId,
dataEditorText: datasetDataTextFor(snapshot, currentDatasetId),
dataEditorStatus: "valid",
dataEditorError: null,
selectedBlockId: null,
rightPanelTab: "properties"
}
}
function documentExists(snapshot: ProjectSnapshot, documentId: string): boolean {
return snapshot.templates[documentId] !== undefined || snapshot.fragments[documentId] !== undefined
}
function defaultDocumentIdForSnapshot(snapshot: ProjectSnapshot): string {
if (
snapshot.project.entryTemplateId !== undefined &&
snapshot.templates[snapshot.project.entryTemplateId] !== undefined
) {
return snapshot.project.entryTemplateId
}
return Object.keys(snapshot.templates)[0] ?? Object.keys(snapshot.fragments)[0] ?? defaultDocumentId
}
function defaultDatasetIdForSnapshot(snapshot: ProjectSnapshot): string {
return Object.keys(snapshot.datasets)[0] ?? fallbackDataset.id
}
function addDirtyDocumentId(dirtyDocumentIds: string[], documentId: string): string[] {
return dirtyDocumentIds.includes(documentId) ? dirtyDocumentIds : [...dirtyDocumentIds, documentId]
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function nextFragmentIdentity(snapshot: ProjectSnapshot): {
id: string
name: string
fileName: string
relativePath: string
path: string
} {
let index = Object.keys(snapshot.fragments).length + 1
let id = `fragment_${index}`
while (snapshot.fragments[id] !== undefined) {
index += 1
id = `fragment_${index}`
}
const fileName = `fragment-${index}.json`
const relativePath = `fragments/${fileName}`
const fragmentsDir = snapshot.project.paths.fragmentsDir.replace(/\\/g, "/")
return {
id,
name: `Fragment ${index}`,
fileName,
relativePath,
path: `${fragmentsDir}/${fileName}`
}
}
function addResourceToDirectory(
nodes: ProjectResourceNode[],
directoryRelativePath: string,
file: ProjectResourceFile,
directoryPath: string
): ProjectResourceNode[] {
const result = addResourceToDirectoryRecursive(nodes, directoryRelativePath, file)
if (result.inserted) {
return result.nodes
}
const directory: ProjectResourceDirectory = {
type: "directory",
name: directoryRelativePath,
path: directoryPath,
relativePath: directoryRelativePath,
children: [file]
}
return [...result.nodes, directory]
}
function addResourceToDirectoryRecursive(
nodes: ProjectResourceNode[],
directoryRelativePath: string,
file: ProjectResourceFile
): { nodes: ProjectResourceNode[]; inserted: boolean } {
let inserted = false
const nextNodes = nodes.map((node): ProjectResourceNode => {
if (node.type === "directory" && node.relativePath === directoryRelativePath) {
inserted = true
return {
...node,
children: [...node.children.filter((child) => child.relativePath !== file.relativePath), file]
}
}
if (node.type === "directory") {
const result = addResourceToDirectoryRecursive(node.children, directoryRelativePath, file)
inserted ||= result.inserted
return {
...node,
children: result.nodes
}
}
return node
})
return { nodes: nextNodes, inserted }
}
function rebuildReferenceDiagnostics(
existing: ProjectDiagnostic[],
missingFragments: ProjectSnapshot["referenceIndex"]["missingFragments"]
): ProjectDiagnostic[] {
const nonReferenceDiagnostics = existing.filter((diagnostic) => diagnostic.code !== "MISSING_FRAGMENT")
const missingDiagnostics = missingFragments.map<ProjectDiagnostic>((missing) => {
const diagnostic: ProjectDiagnostic = {
code: "MISSING_FRAGMENT",
severity: "error",
message: `Missing fragment reference: ${missing.fragmentId}`,
documentId: missing.fromId,
fragmentId: missing.fragmentId
}
if (missing.fromPath !== undefined) {
diagnostic.filePath = missing.fromPath
}
return diagnostic
})
return [...nonReferenceDiagnostics, ...missingDiagnostics]
}
function applyDatasetDataUpdate(
state: WorkbenchState,
data: Record<string, unknown>,
dataEditorText: string
): Partial<WorkbenchState> {
const dataset = getCurrentDataset(state)
return {
snapshot: {
...state.snapshot,
datasets: {
...state.snapshot.datasets,
[dataset.id]: {
...dataset,
data
}
}
},
dataEditorText,
dataEditorStatus: "valid",
dataEditorError: null,
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, dataset.id),
lastSaveError: null,
saveStatus: "dirty"
}
}
function datasetDataTextFor(snapshot: ProjectSnapshot, datasetId: string): string {
const dataset = snapshot.datasets[datasetId] ?? snapshot.datasets[defaultDatasetId]
return formatJson(dataset?.data ?? {})
}
function parseDatasetJson(text: string): { ok: true; data: Record<string, unknown> } | { ok: false; error: string } {
try {
const parsed = JSON.parse(text) as unknown
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: "Dataset JSON must be an object." }
}
return { ok: true, data: parsed as Record<string, unknown> }
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Invalid JSON."
}
}
}
function formatJson(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`
}
function blockListContainsId(blocks: BlockNode[], blockId: string | null): boolean {
if (blockId === null) {
return false
}
return findBlockById(blocks, blockId) !== undefined
}
function findBlockById(blocks: BlockNode[], blockId: string): BlockNode | undefined {
for (const block of blocks) {
if (block.id === blockId) {
return block
}
const children = getNestedBlocks(block)
if (children.length > 0) {
const found = findBlockById(children, blockId)
if (found !== undefined) {
return found
}
}
}
return undefined
}
function getNestedBlocks(block: BlockNode): BlockNode[] {
switch (block.type) {
case "container":
return block.children
case "condition":
return [...block.children, ...(block.elseChildren ?? [])]
case "loop":
return [...block.children, ...(block.emptyChildren ?? [])]
case "slot":
return block.fallbackChildren ?? []
default:
return []
}
}
function cloneBlocks(blocks: BlockNode[]): BlockNode[] {
return blocks.map(cloneBlock)
}
function cloneBlock(block: BlockNode): BlockNode {
switch (block.type) {
case "paragraph":
return {
...block,
id: createBlockId("paragraph"),
inlines: cloneInlines(block.inlines)
}
case "container":
return {
...block,
id: createBlockId("container"),
children: cloneBlocks(block.children)
}
case "condition": {
const cloned: BlockNode = {
...block,
id: createBlockId("condition"),
children: cloneBlocks(block.children)
}
if (block.elseChildren !== undefined) {
cloned.elseChildren = cloneBlocks(block.elseChildren)
}
return cloned
}
case "loop": {
const cloned: BlockNode = {
...block,
id: createBlockId("loop"),
children: cloneBlocks(block.children)
}
if (block.emptyChildren !== undefined) {
cloned.emptyChildren = cloneBlocks(block.emptyChildren)
}
return cloned
}
case "fragmentRef":
return {
...block,
id: createBlockId("fragment")
}
case "slot": {
const cloned: BlockNode = {
...block,
id: createBlockId("slot")
}
if (block.fallbackChildren !== undefined) {
cloned.fallbackChildren = cloneBlocks(block.fallbackChildren)
}
return cloned
}
case "comment":
return {
...block,
id: createBlockId("comment")
}
case "output":
return {
...block,
id: createBlockId("output"),
config: { ...block.config }
}
}
}
function cloneInlines(inlines: InlineNode[]): InlineNode[] {
return inlines.map((inline) => ({ ...inline }))
}
function replaceBlockById(
blocks: BlockNode[],
blockId: string,
replacement: BlockNode[]
): { blocks: BlockNode[]; replaced: boolean } {
let replaced = false
const nextBlocks: BlockNode[] = []
for (const block of blocks) {
if (block.id === blockId) {
nextBlocks.push(...replacement)
replaced = true
continue
}
const nested = replaceNestedBlockById(block, blockId, replacement)
nextBlocks.push(nested.block)
replaced ||= nested.replaced
}
return { blocks: nextBlocks, replaced }
}
function replaceNestedBlockById(
block: BlockNode,
blockId: string,
replacement: BlockNode[]
): { block: BlockNode; replaced: boolean } {
switch (block.type) {
case "container": {
const result = replaceBlockById(block.children, blockId, replacement)
return result.replaced
? { block: { ...block, children: result.blocks }, replaced: true }
: { block, replaced: false }
}
case "condition": {
const children = replaceBlockById(block.children, blockId, replacement)
const elseChildren =
block.elseChildren === undefined
? { blocks: undefined, replaced: false }
: replaceBlockById(block.elseChildren, blockId, replacement)
if (!children.replaced && !elseChildren.replaced) {
return { block, replaced: false }
}
const next: BlockNode = { ...block, children: children.blocks }
if (elseChildren.blocks !== undefined) {
next.elseChildren = elseChildren.blocks
}
return { block: next, replaced: true }
}
case "loop": {
const children = replaceBlockById(block.children, blockId, replacement)
const emptyChildren =
block.emptyChildren === undefined
? { blocks: undefined, replaced: false }
: replaceBlockById(block.emptyChildren, blockId, replacement)
if (!children.replaced && !emptyChildren.replaced) {
return { block, replaced: false }
}
const next: BlockNode = { ...block, children: children.blocks }
if (emptyChildren.blocks !== undefined) {
next.emptyChildren = emptyChildren.blocks
}
return { block: next, replaced: true }
}
case "slot": {
if (block.fallbackChildren === undefined) {
return { block, replaced: false }
}
const fallbackChildren = replaceBlockById(block.fallbackChildren, blockId, replacement)
return fallbackChildren.replaced
? { block: { ...block, fallbackChildren: fallbackChildren.blocks }, replaced: true }
: { block, replaced: false }
}
default:
return { block, replaced: false }
}
}