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.
187 lines
6.2 KiB
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)];
|
|
}
|