feat(builder): add dynamic blocks email and dnd packages
This commit is contained in:
1 parent
06433e4ac4
commit
0bd2a6860d
37 files changed
+3060
-1
No files matched your search
@@ -0,0 +1,233 @@
|
||||
import { findBlock, validateDocument } from "./document.ts";
|
||||
import type { DocumentKindDefinition } from "./kinds.ts";
|
||||
import type { ReadonlyBlockRegistry } from "./registry.ts";
|
||||
import type {
|
||||
BlockDocument,
|
||||
BlockLocation,
|
||||
BlockNode,
|
||||
BlockStyles,
|
||||
Diagnostic,
|
||||
JsonObject,
|
||||
MoveIntent,
|
||||
Result,
|
||||
SelectionState,
|
||||
} from "./types.ts";
|
||||
|
||||
export type BlockCommand =
|
||||
| { type: "insert"; location: BlockLocation; nodes: BlockNode[] }
|
||||
| { type: "remove"; nodeIds: string[] }
|
||||
| { type: "move"; intent: MoveIntent }
|
||||
| { type: "updateProps"; nodeId: string; patch: JsonObject }
|
||||
| { type: "updateStyles"; nodeId: string; patch: BlockStyles }
|
||||
| { type: "replace"; nodeId: string; node: BlockNode }
|
||||
| { type: "batch"; commands: BlockCommand[] };
|
||||
|
||||
export interface CommandResult {
|
||||
document: BlockDocument;
|
||||
inverse: BlockCommand;
|
||||
affectedIds: string[];
|
||||
selection: SelectionState;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
function updateNode(
|
||||
root: BlockNode,
|
||||
id: string,
|
||||
update: (node: BlockNode) => BlockNode,
|
||||
): BlockNode {
|
||||
if (root.id === id) return update(root);
|
||||
const slots: Record<string, BlockNode[]> = {};
|
||||
for (const [name, children] of Object.entries(root.slots))
|
||||
slots[name] = children.map((child) => updateNode(child, id, update));
|
||||
return { ...root, slots };
|
||||
}
|
||||
|
||||
function locationOf(root: BlockNode, id: string): BlockLocation | undefined {
|
||||
for (const [slot, children] of Object.entries(root.slots)) {
|
||||
const index = children.findIndex((child) => child.id === id);
|
||||
if (index >= 0) return { parentId: root.id, slot, index };
|
||||
for (const child of children) {
|
||||
const nested = locationOf(child, id);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insert(root: BlockNode, location: BlockLocation, nodes: BlockNode[]): BlockNode {
|
||||
return updateNode(root, location.parentId, (parent) => {
|
||||
const children = [...(parent.slots[location.slot] ?? [])];
|
||||
children.splice(location.index, 0, ...nodes);
|
||||
return { ...parent, slots: { ...parent.slots, [location.slot]: children } };
|
||||
});
|
||||
}
|
||||
|
||||
function remove(root: BlockNode, ids: Set<string>): BlockNode {
|
||||
const slots: Record<string, BlockNode[]> = {};
|
||||
for (const [name, children] of Object.entries(root.slots))
|
||||
slots[name] = children.filter((child) => !ids.has(child.id)).map((child) => remove(child, ids));
|
||||
return { ...root, slots };
|
||||
}
|
||||
|
||||
export function applyCommand(
|
||||
document: BlockDocument,
|
||||
command: BlockCommand,
|
||||
kind: DocumentKindDefinition,
|
||||
registry: ReadonlyBlockRegistry,
|
||||
): Result<CommandResult> {
|
||||
if (command.type === "batch") {
|
||||
let current = document;
|
||||
const inverses: BlockCommand[] = [];
|
||||
const affected: string[] = [];
|
||||
for (const child of command.commands) {
|
||||
const result = applyCommand(current, child, kind, registry);
|
||||
if (!result.ok) return result;
|
||||
current = result.value.document;
|
||||
inverses.unshift(result.value.inverse);
|
||||
affected.push(...result.value.affectedIds);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
document: current,
|
||||
inverse: { type: "batch", commands: inverses },
|
||||
affectedIds: [...new Set(affected)],
|
||||
selection: { primary: affected.at(-1) ?? null, ordered: [...new Set(affected)] },
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
let root = document.root;
|
||||
let inverse: BlockCommand;
|
||||
let affected: string[];
|
||||
if (command.type === "insert") {
|
||||
root = insert(root, command.location, clone(command.nodes));
|
||||
affected = command.nodes.map((node) => node.id);
|
||||
inverse = { type: "remove", nodeIds: affected };
|
||||
} else if (command.type === "remove") {
|
||||
const captures = command.nodeIds.map((id) => ({
|
||||
node: findBlock(root, id),
|
||||
location: locationOf(root, id),
|
||||
}));
|
||||
if (captures.some((item) => !item.node || !item.location))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "command.missing",
|
||||
message: "Cannot remove the root or a missing block.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
root = remove(root, new Set(command.nodeIds));
|
||||
affected = command.nodeIds;
|
||||
inverse = {
|
||||
type: "batch",
|
||||
commands: captures.map((item) => ({
|
||||
type: "insert",
|
||||
location: item.location!,
|
||||
nodes: [item.node!],
|
||||
})),
|
||||
};
|
||||
} else if (command.type === "move") {
|
||||
const moving = command.intent.nodeIds.map((id) => findBlock(root, id));
|
||||
if (moving.some((node) => !node))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{ code: "command.missing", message: "A moved block does not exist.", severity: "error" },
|
||||
],
|
||||
};
|
||||
for (const node of moving)
|
||||
if (node && findBlock(node, command.intent.to.parentId))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "command.cycle",
|
||||
message: "A block cannot move into its descendant.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
root = insert(
|
||||
remove(root, new Set(command.intent.nodeIds)),
|
||||
command.intent.to,
|
||||
moving as BlockNode[],
|
||||
);
|
||||
affected = command.intent.nodeIds;
|
||||
inverse = {
|
||||
type: "batch",
|
||||
commands: command.intent.from.map((location, index) => ({
|
||||
type: "move",
|
||||
intent: {
|
||||
nodeIds: [command.intent.nodeIds[index]!],
|
||||
from: [command.intent.to],
|
||||
to: location,
|
||||
},
|
||||
})),
|
||||
};
|
||||
} else if (command.type === "updateProps") {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, (node) => ({
|
||||
...node,
|
||||
props: { ...node.props, ...command.patch },
|
||||
}));
|
||||
affected = [command.nodeId];
|
||||
inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) };
|
||||
} else if (command.type === "updateStyles") {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, (node) => ({
|
||||
...node,
|
||||
styles: { ...node.styles, ...command.patch },
|
||||
}));
|
||||
affected = [command.nodeId];
|
||||
inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) };
|
||||
} else {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, () => clone(command.node));
|
||||
affected = [command.node.id];
|
||||
inverse = { type: "replace", nodeId: command.node.id, node: clone(previous) };
|
||||
}
|
||||
const next = { ...document, root };
|
||||
const valid = validateDocument(next, kind, registry);
|
||||
if (!valid.ok) return valid;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
document: next,
|
||||
inverse,
|
||||
affectedIds: affected,
|
||||
selection: { primary: affected[0] ?? null, ordered: affected },
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateNodes(nodes: readonly BlockNode[], id: () => string): BlockNode[] {
|
||||
const copy = (node: BlockNode): BlockNode => ({
|
||||
...clone(node),
|
||||
id: id(),
|
||||
slots: Object.fromEntries(
|
||||
Object.entries(node.slots).map(([name, children]) => [name, children.map(copy)]),
|
||||
),
|
||||
});
|
||||
return nodes.map(copy);
|
||||
}
|
||||
Reference in new issue
Block a user