/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/board-payload.js
236 строк
13 KB
Maksim Ratnikov
fix: фигуры не раздувались, схема не рассыпалась, промпт переписан по ревью
05 авг 2026, 17:49
05 авг 2026, 17:49
fceef36
Код
Авторство
О чём код?
import { parseNativeText, renderNativeText } from "./board-text.js"; export const DEFAULT_BACKGROUND = "#FFFFFF"; export const DEFAULT_FONT_SIZE = 24; // Подтверждено двумя выгрузками: связи 1→3 идут слева направо (right→left), // связь 2→0 — сверху вниз (bottom→top). export const PORT_SIDES = { 0: "top", 1: "right", 2: "bottom", 3: "left" }; export const SIDE_PORTS = { top: 0, right: 1, bottom: 2, left: 3 }; export function tableCellRects(attrs) { const rects = new Map(); let y = 0; for (const row of attrs?.rows ?? []) { let x = 0; let rowHeight = 0; for (const cell of row.cells ?? []) { rects.set(cell.id, { x, y, w: cell.w, h: cell.h }); x += cell.w ?? 0; rowHeight = Math.max(rowHeight, cell.h ?? 0); } y += rowHeight; } return rects; } export function localToWorld(point, origin) { return { x: (origin?.x ?? 0) + (point?.x ?? 0), y: (origin?.y ?? 0) + (point?.y ?? 0) }; } export function worldToLocal(point, origin) { return { x: (point?.x ?? 0) - (origin?.x ?? 0), y: (point?.y ?? 0) - (origin?.y ?? 0) }; } export function unwrapPayload(text) { const parsed = typeof text === "string" ? JSON.parse(text) : text; const inner = parsed?.original?.["text/plain"]; const doc = typeof inner === "string" ? JSON.parse(inner) : parsed; if (!doc || typeof doc !== "object" || !doc.tree || !Array.isArray(doc.tree.children)) { throw new Error("board payload: ожидался объект { tree: { children: [] } }"); } return { tree: doc.tree, lines: Array.isArray(doc.lines) ? doc.lines : [] }; } export function parseBoardPayload(text) { const doc = unwrapPayload(text); const warnings = []; const nodes = []; const byUuid = new Map(); const cellOwner = new Map(); const rawLines = [...doc.lines]; let nodeCounter = 0; let cellCounter = 0; const readTable = (attrs, nodeKey, worldRect) => { const rects = tableCellRects(attrs); const columns = (attrs.rows?.[0]?.cells ?? []).map((cell, index) => ({ key: `${nodeKey}col${index + 1}`, w: cell.w ?? 0 })); const rows = (attrs.rows ?? []).map((row, rowIndex) => ({ key: `${nodeKey}row${rowIndex + 1}`, h: row.cells?.[0]?.h ?? 0, cells: (row.cells ?? []).map((cell, index) => { const key = `c${++cellCounter}`; const rect = rects.get(cell.id) ?? { x: 0, y: 0, w: cell.w ?? 0, h: cell.h ?? 0 }; cellOwner.set(cell.id, { nodeKey, cellKey: key, origin: { x: worldRect.x + rect.x, y: worldRect.y + rect.y } }); if (columns[index] && columns[index].w !== cell.w) { warnings.push({ code: "ragged-column-width", path: `${nodeKey}/${key}`, severity: "warn" }); } return { key, columnKey: columns[index]?.key ?? `${nodeKey}col${index + 1}`, text: parseNativeText(cell.data?.text), color: cell.backgroundColor ?? DEFAULT_BACKGROUND, opacity: cell.opacity ?? 1, fontSize: cell.data?.textStyle?.fontSize ?? DEFAULT_FONT_SIZE }; }) })); return { columns, rows }; }; let admit; const visit = (entry, origin, parent) => { const element = entry?.element; if (!element) return; if (element.type === "line") { rawLines.push(element); return; } const attrs = element.attrs ?? {}; const key = `n${++nodeCounter}`; const world = localToWorld({ x: attrs.x, y: attrs.y }, origin); const rect = { x: world.x, y: world.y, w: attrs.w ?? 0, h: attrs.h ?? 0 }; const node = { key, kind: element.type, parent, rect, paintOrder: attrs.z ?? 0, style: { color: attrs.backgroundColor ?? DEFAULT_BACKGROUND, fontSize: attrs.textStyle?.fontSize ?? DEFAULT_FONT_SIZE, ...(attrs.opacity === undefined ? {} : { opacity: attrs.opacity }), ...(attrs.figureType ? { figureType: attrs.figureType } : {}), ...(attrs.strokeColor && element.type === "figure" ? { strokeColor: attrs.strokeColor } : {}) }, connectable: element.type !== "table" }; if (element.type === "table") { if (attrs.title !== undefined) node.title = parseNativeText(attrs.title); node.table = readTable(attrs, key, rect); const sumH = node.table.rows.reduce((sum, row) => sum + row.h, 0); const sumW = node.table.columns.reduce((sum, column) => sum + column.w, 0); if (Math.abs(rect.h - sumH) > 1e-6 || Math.abs(rect.w - sumW) > 1e-6) { warnings.push({ code: "table-geometry-mismatch", path: key, severity: "warn" }); } rect.h = sumH; rect.w = sumW; } else node.text = parseNativeText(attrs.text); nodes.push(node); byUuid.set(element.id, node); for (const child of entry.children ?? []) admit(child); }; // Разбор не зависит от порядка: элемент, чья ячейка ещё не встречена, // откладывается и разбирается, когда владелец появится. const deferred = []; admit = (entry) => { const space = entry?.element?.attrs?.space; if (space?.type !== "cell") { visit(entry, { x: 0, y: 0 }, null); return; } const owner = cellOwner.get(space.attrs?.cellId); if (!owner) { deferred.push(entry); return; } visit(entry, owner.origin, { nodeKey: owner.nodeKey, cellKey: owner.cellKey }); }; for (const entry of doc.tree.children) admit(entry); let pending = deferred.splice(0); while (pending.length) { const before = pending.length; for (const entry of pending.splice(0)) admit(entry); const next = deferred.splice(0); if (next.length >= before) { for (const entry of next) { warnings.push({ code: "unknown-cell", path: entry.element?.id ?? "", severity: "warn" }); } break; } pending = next; } nodes.slice().sort((a, b) => a.paintOrder - b.paintOrder).forEach((node, index) => { node.paintOrder = index; }); const edges = []; let edgeCounter = 0; for (const line of rawLines) { const attrs = line.attrs ?? {}; const from = resolveEnd(attrs.start, byUuid, warnings, line.id); const to = resolveEnd(attrs.end, byUuid, warnings, line.id); if (!from || !to) { warnings.push({ code: "absolute-line-dropped", path: line.id ?? "", severity: "warn" }); continue; } const startArrow = attrs.start?.form === "arrow"; const endArrow = attrs.end?.form === "arrow"; edges.push({ key: `e${++edgeCounter}`, from, to, arrow: startArrow && endArrow ? "both" : endArrow ? "end" : startArrow ? "start" : "none", ...(attrs.lineType && attrs.lineType !== "bezierCurve" ? { lineType: attrs.lineType } : {}) }); } return { scene: { nodes, edges }, warnings }; } function resolveEnd(end, byUuid, warnings, lineId) { const position = end?.position; if (position?.type !== "bind") return null; const node = byUuid.get(position.elementId); if (!node) return null; const side = PORT_SIDES[position.portId]; if (!side) warnings.push({ code: "unknown-port", path: lineId ?? "", severity: "info" }); return { nodeKey: node.key, side: side ?? "top" }; } const DEFAULT_TABLE_STYLE = { strokeColor: "#000000", strokeWidth: 1, strokeOpacity: 1, strokeStyle: "solid" }; function defaultIdFactory() { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); const hex = (n) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join(""); return `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`; } export function sceneToBoardPayload(scene, opts = {}) { const idFactory = opts.idFactory ?? defaultIdFactory; const nodes = scene?.nodes ?? []; const childrenOf = new Map(); for (const node of nodes) { const parentKey = node.parent?.nodeKey ?? null; if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []); childrenOf.get(parentKey).push(node); } const uuidOf = new Map(); const cellUuidOf = new Map(); const cellOriginOf = new Map(); for (const node of nodes) uuidOf.set(node.key, idFactory()); const lines = []; const buildEntry = (node, origin) => { const local = worldToLocal(node.rect, origin); const attrs = { x: local.x, y: local.y, z: node.paintOrder ?? 0, w: node.rect.w, h: node.rect.h }; if (node.kind === "table") { Object.assign(attrs, DEFAULT_TABLE_STYLE); attrs.rows = buildRows(node); if (node.title) attrs.title = renderNativeText(node.title); attrs.h = attrs.rows.reduce((sum, row) => sum + row.cells[0].h, 0); attrs.w = attrs.rows[0]?.cells.reduce((sum, cell) => sum + cell.w, 0) ?? attrs.w; } else { attrs.backgroundColor = node.style?.color ?? DEFAULT_BACKGROUND; attrs.text = renderNativeText(node.text); // textArea на доске подгоняет кегль под рамку — иначе подпись обрежется. attrs.textStyle = { fontSizeAuto: node.kind !== "sticker", fontSize: node.style?.fontSize ?? DEFAULT_FONT_SIZE, fontColor: "#000000", strike: false }; attrs.angle = 0; attrs.showOwnerName = false; if (node.kind === "figure") { Object.assign(attrs, DEFAULT_TABLE_STYLE, { strokeWidth: 2 }); attrs.strokeColor = node.style?.strokeColor ?? "#3F81FD"; attrs.figureType = node.style?.figureType ?? "rectangle"; } if (node.style?.opacity !== undefined) attrs.opacity = node.style.opacity; } attrs.space = node.parent ? { type: "cell", attrs: { id: uuidOf.get(node.parent.nodeKey), cellId: cellUuidOf.get(node.parent.cellKey) } } : { type: "root", attrs: {} }; return { element: { type: node.kind, id: uuidOf.get(node.key), attrs, deleted: false, userId: "", ver: 0 }, space: node.parent ? uuidOf.get(node.parent.nodeKey) : "root", children: (childrenOf.get(node.key) ?? []).map((child) => buildEntry(child, cellOriginOf.get(child.parent.cellKey) ?? { x: 0, y: 0 })) }; }; const buildRows = (node) => { const columns = node.table?.columns ?? []; let y = 0; return (node.table?.rows ?? []).map((row) => { let x = 0; const cells = (row.cells ?? []).map((cell, index) => { const uuid = idFactory(); cellUuidOf.set(cell.key, uuid); cellOriginOf.set(cell.key, { x: node.rect.x + x, y: node.rect.y + y }); const width = columns[index]?.w ?? 0; x += width; return { id: uuid, w: width, h: row.h, opacity: cell.opacity ?? 1, backgroundColor: cell.color ?? DEFAULT_BACKGROUND, dataType: "text", data: { text: renderNativeText(cell.text), textStyle: { fontSize: cell.fontSize ?? node.style?.fontSize ?? DEFAULT_FONT_SIZE, fontColor: "#000000", strike: false } } }; }); y += row.h; return { cells }; }); }; const children = (childrenOf.get(null) ?? []).map((node) => buildEntry(node, { x: 0, y: 0 })); const nodeByKey = new Map(nodes.map((node) => [node.key, node])); const maxPaintOrder = nodes.reduce((max, node) => Math.max(max, node.paintOrder ?? 0), -1); let edgeIndex = 0; for (const edge of scene?.edges ?? []) { const fromUuid = uuidOf.get(edge.from?.nodeKey); const toUuid = uuidOf.get(edge.to?.nodeKey); if (!fromUuid || !toUuid) continue; const fromParent = nodeByKey.get(edge.from.nodeKey)?.parent; const toParent = nodeByKey.get(edge.to.nodeKey)?.parent; const sameCell = fromParent && toParent && fromParent.cellKey === toParent.cellKey; const lineSpace = sameCell ? { type: "cell", attrs: { id: uuidOf.get(fromParent.nodeKey), cellId: cellUuidOf.get(fromParent.cellKey) } } : { type: "root", attrs: {} }; const entry = { element: { type: "line", id: idFactory(), attrs: { start: { form: edge.arrow === "both" || edge.arrow === "start" ? "arrow" : null, position: { type: "bind", elementId: fromUuid, portId: SIDE_PORTS[edge.from.side] ?? 0 } }, end: { form: edge.arrow === "both" || edge.arrow === "end" ? "arrow" : null, position: { type: "bind", elementId: toUuid, portId: SIDE_PORTS[edge.to.side] ?? 0 } }, controlPoints: [], strokeWidth: 2, strokeStyle: "solid", strokeColor: "#3F81FD", strokeOpacity: 1, lineType: edge.lineType ?? "bezierCurve", z: maxPaintOrder + 1 + edgeIndex, space: lineSpace }, deleted: false, userId: "", ver: 0 }, space: sameCell ? uuidOf.get(fromParent.nodeKey) : "root", children: [] }; edgeIndex += 1; // Привязанная связь всегда живёт в lines[], в каком бы пространстве ни была. // В дереве доска держит только свободные линии absolute → absolute, а // привязанные оттуда попросту не рисует — проверено на двух выгрузках. lines.push(entry.element); } return JSON.stringify({ tree: { space: "root", children }, lines }); }