You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/words/engine/operations/visual.ts

187 lines
6.2 KiB

/**
* setBlockVisual — merges common style props into a block.
*
* The common style properties (`align`, `margin`, `padding`,
* `background`, `color`, `border`) live on the base `Block`, so every
* block accepts any of them — no per-type whitelist. `undefined`
* clears a property; a patch that changes nothing is a no-op.
*/
import { normalizeDocument } from './normalize';
import { getNodeAtPath, updateNodeAtPath } from './helpers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type { WordsPath } from '../path';
import type { Block, WordsBlock } from '../types';
/**
* A node accepts the common `Block` style props (margin / padding /
* color / border / …) when it EXTENDS `Block`. That is every block
* (paragraph, heading, …) AND every structural Block-subtype that has
* no `type` discriminator: `Column`, `TableRow`, `TableCell`,
* `ListItem`. It is NOT the document and NOT an inline (`text` / `link`,
* which carry a `type` but are not `Block`s).
*
* The earlier guard tested `'type' in target`, which was exactly
* backwards: it let inlines through (they have a `type`) and rejected
* columns / rows / cells (they don't). Those structural nodes are the
* whole point — a column carries its own background / padding / border.
*/
function isBlockLikeTarget(target: unknown): boolean {
if (!target || typeof target !== 'object') return false;
if ('version' in target) return false; // document root
const t = (target as { type?: string }).type;
if (t === 'text' || t === 'link') return false; // inline
return true; // block OR column / table-row / table-cell / list-item
}
const STYLE_KEYS = [
'align',
'margin',
'padding',
'background',
'color',
'border',
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight',
'fontStyle',
'strikethrough',
'letterSpacing',
'wordSpacing',
'textWrap',
'maxWidth',
'tabularNumbers'
] as const;
export function setBlockVisual(
state: WordsEditorState,
blockIndex: number,
patch: Partial<Block>
): WordsOperationResult {
const block = state.document.children[blockIndex];
if (!block) return noOp(state);
const prev = block as unknown as Record<string, unknown>;
const next: Record<string, unknown> = { ...prev };
let touched = false;
for (const key of STYLE_KEYS) {
if (!(key in patch)) continue;
const value = (patch as Record<string, unknown>)[key];
if (value === undefined) {
if (key in next) {
delete next[key];
touched = true;
}
} else if (!styleEqual(next[key], value)) {
next[key] = value;
touched = true;
}
}
if (!touched) return noOp(state);
const nextDoc = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, next as unknown as WordsBlock)
}).document;
return changed({ ...state, document: nextDoc });
}
/**
* Path-aware twin of `setBlockVisual`. Walks `blockPath` to find the
* target block (top-level OR nested inside `columns` / `callout`) and
* applies the same style-merge semantics. Used by the inspector when
* the user has clicked into a block nested inside a column — the
* top-level `blockIndex` would point at the wrapper, so the path
* version is the only way to touch the right block.
*/
export function setBlockVisualAtPath(
state: WordsEditorState,
blockPath: WordsPath,
patch: Partial<Block>
): WordsOperationResult {
if (blockPath.length === 0) return noOp(state);
const target = getNodeAtPath(state.document, blockPath);
if (!isBlockLikeTarget(target)) return noOp(state);
const prev = target as unknown as Record<string, unknown>;
const next: Record<string, unknown> = { ...prev };
let touched = false;
for (const key of STYLE_KEYS) {
if (!(key in patch)) continue;
const value = (patch as Record<string, unknown>)[key];
if (value === undefined) {
if (key in next) {
delete next[key];
touched = true;
}
} else if (!styleEqual(next[key], value)) {
next[key] = value;
touched = true;
}
}
if (!touched) return noOp(state);
const nextDoc = updateNodeAtPath(state.document, blockPath, () => next as unknown as WordsBlock);
if (nextDoc === state.document) return noOp(state);
const normalized = normalizeDocument(nextDoc).document;
return changed({ ...state, document: normalized });
}
/**
* Path-aware shallow merge — mirrors `updateBlockAt` but accepts a full
* document path. Used by the inspector's Block panel (alt / caption /
* src / column children…) when the target block sits inside a column.
* Preserves the block's discriminator (`type` is filtered out of the
* patch, same as the top-level op).
*/
export function updateBlockAtPath(
state: WordsEditorState,
blockPath: WordsPath,
patch: Readonly<Record<string, unknown>>
): WordsOperationResult {
if (blockPath.length === 0) return noOp(state);
const target = getNodeAtPath(state.document, blockPath);
if (!isBlockLikeTarget(target)) return noOp(state);
const safePatch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'type') continue;
safePatch[k] = v;
}
const nextDoc = updateNodeAtPath(
state.document,
blockPath,
(node) =>
({
...(node as unknown as Record<string, unknown>),
...safePatch
}) as unknown as WordsBlock
);
if (nextDoc === state.document) return noOp(state);
const normalized = normalizeDocument(nextDoc).document;
return changed({ ...state, document: normalized });
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Shallow equality, descending one level into the small style objects
* (`margin` / `padding` / `border`). */
function styleEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const ak = Object.keys(ao);
const bk = Object.keys(bo);
if (ak.length !== bk.length) return false;
return ak.every((k) => ao[k] === bo[k]);
}
return false;
}
function replaceAt<T>(arr: readonly T[], index: number, value: T): readonly T[] {
return [...arr.slice(0, index), value, ...arr.slice(index + 1)];
}

Powered by TurnKey Linux.