2011 lines
57 KiB
TypeScript
2011 lines
57 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type Konva from "konva";
|
|
import {
|
|
Arc,
|
|
Circle,
|
|
Ellipse,
|
|
Group,
|
|
Image as KonvaImage,
|
|
Line,
|
|
Rect,
|
|
Text,
|
|
} from "react-konva";
|
|
import { effectiveLineHeight } from "@/components/slide-editor/text/text-line-height";
|
|
import { textRunsContent } from "@/components/slide-editor/text/text-runs";
|
|
import {
|
|
displayText,
|
|
layoutRenderTextRuns,
|
|
lineRenderHeight,
|
|
lineStartX,
|
|
fontScaleFromResize,
|
|
rawFont,
|
|
rawRenderTextRuns,
|
|
rawTextContent,
|
|
rawTextListRenderTextRuns,
|
|
textVisualLocalBox,
|
|
type RenderTextRun,
|
|
} from "@/components/slide-editor/text/template-v2-text";
|
|
import type { TableCellSelection } from "@/components/slide-editor/state/state";
|
|
import { loadKonvaImage } from "@/components/slide-editor/surface/exportAssets";
|
|
import { TemplateV2ChartJsElement as RawChartElement } from "@/components/slide-editor/charts/TemplateV2ChartJsElement";
|
|
import { TemplateV2TableElement as RawTableElement } from "@/components/slide-editor/tables/TemplateV2TableElement";
|
|
import { buildSvgUpdateUrl } from "@/lib/svg-color";
|
|
import {
|
|
asRecord,
|
|
borderRadius,
|
|
childArrayInfo,
|
|
clamp,
|
|
colorWithOpacity,
|
|
componentBox,
|
|
elementBox,
|
|
fillColor,
|
|
fillOpacity,
|
|
boxEqual,
|
|
isBoxVisualType,
|
|
isManualPositioned,
|
|
isRawIconElement,
|
|
isStaticSvgIconSource,
|
|
isRecord,
|
|
keyForSelection,
|
|
layoutChildren,
|
|
linePoints,
|
|
nullableBoxEqual,
|
|
numberPathEqual,
|
|
positionFromNodeInParent,
|
|
rawElementKey,
|
|
readArray,
|
|
readBoolean,
|
|
readNumber,
|
|
readString,
|
|
ROOT_ELEMENTS_COMPONENT_INDEX,
|
|
STAGE_BOX,
|
|
resizeComponent,
|
|
resizeComponentElementBounds,
|
|
resizeComponentFrame,
|
|
scaleRawElementTextMetrics,
|
|
selectionTouchesComponent,
|
|
selectionTouchesElement,
|
|
shadowProps,
|
|
shouldClipElementChildren,
|
|
shouldUseCenterOrigin,
|
|
strokeColor,
|
|
strokeOpacity,
|
|
strokeWidth,
|
|
valueProgress,
|
|
pointOnCircle,
|
|
withHash,
|
|
type Box,
|
|
type ComponentSelection,
|
|
type ElementSelection,
|
|
type Point,
|
|
type RawComponent,
|
|
type RawElement,
|
|
type SelectOptions,
|
|
type Selection,
|
|
} from "@/components/slide-editor/model/model";
|
|
|
|
type ComponentTransformAnchor =
|
|
| "top-left"
|
|
| "top-center"
|
|
| "top-right"
|
|
| "middle-left"
|
|
| "middle-right"
|
|
| "bottom-left"
|
|
| "bottom-center"
|
|
| "bottom-right"
|
|
| "rotater";
|
|
|
|
type ComponentResizeMode =
|
|
| "scale-content"
|
|
| "resize-element-bounds"
|
|
| "resize-frame";
|
|
|
|
type ComponentTransformBox = Box & {
|
|
scaleX: number;
|
|
scaleY: number;
|
|
rawWidth: number;
|
|
rawHeight: number;
|
|
};
|
|
|
|
const HORIZONTAL_RESIZE_ANCHORS = new Set<ComponentTransformAnchor>([
|
|
"middle-left",
|
|
"middle-right",
|
|
]);
|
|
const VERTICAL_RESIZE_ANCHORS = new Set<ComponentTransformAnchor>([
|
|
"top-center",
|
|
"bottom-center",
|
|
]);
|
|
|
|
function componentTransformAnchorForNode(
|
|
node: Konva.Node,
|
|
): ComponentTransformAnchor | null {
|
|
const stage = node.getStage();
|
|
if (!stage) return null;
|
|
const transformer = stage
|
|
.find<Konva.Transformer>("Transformer")
|
|
.find((candidate) => candidate.getNodes().includes(node));
|
|
const activeAnchor = transformer?.getActiveAnchor();
|
|
return isComponentTransformAnchor(activeAnchor) ? activeAnchor : null;
|
|
}
|
|
|
|
function isComponentTransformAnchor(
|
|
value: string | null | undefined,
|
|
): value is ComponentTransformAnchor {
|
|
return (
|
|
value === "top-left" ||
|
|
value === "top-center" ||
|
|
value === "top-right" ||
|
|
value === "middle-left" ||
|
|
value === "middle-right" ||
|
|
value === "bottom-left" ||
|
|
value === "bottom-center" ||
|
|
value === "bottom-right" ||
|
|
value === "rotater"
|
|
);
|
|
}
|
|
|
|
function componentResizeModeForTransform(
|
|
anchor: ComponentTransformAnchor | null,
|
|
scaleX: number,
|
|
scaleY: number,
|
|
): ComponentResizeMode {
|
|
if (anchor === "rotater") return "resize-frame";
|
|
if (
|
|
anchor &&
|
|
(HORIZONTAL_RESIZE_ANCHORS.has(anchor) ||
|
|
VERTICAL_RESIZE_ANCHORS.has(anchor))
|
|
) {
|
|
return "resize-element-bounds";
|
|
}
|
|
if (anchor) return "scale-content";
|
|
|
|
const changedX = Math.abs(scaleX - 1) > 0.001;
|
|
const changedY = Math.abs(scaleY - 1) > 0.001;
|
|
if (changedX && changedY) return "scale-content";
|
|
if (changedX || changedY) return "resize-element-bounds";
|
|
return "resize-frame";
|
|
}
|
|
|
|
function componentBoxFromTransform(
|
|
component: RawComponent,
|
|
box: Box,
|
|
scaleX: number,
|
|
scaleY: number,
|
|
anchor: ComponentTransformAnchor | null,
|
|
): ComponentTransformBox {
|
|
const isVerticalOnly = anchor ? VERTICAL_RESIZE_ANCHORS.has(anchor) : false;
|
|
const isHorizontalOnly = anchor ? HORIZONTAL_RESIZE_ANCHORS.has(anchor) : false;
|
|
const nextScaleX = isVerticalOnly || anchor === "rotater" ? 1 : scaleX;
|
|
const nextScaleY = isHorizontalOnly || anchor === "rotater" ? 1 : scaleY;
|
|
const rawWidth = Math.max(1, box.width * nextScaleX);
|
|
const rawHeight = Math.max(1, box.height * nextScaleY);
|
|
const minimumSize = minimumComponentSizeForElementBoundsResize(component);
|
|
const width = isHorizontalOnly ? Math.max(rawWidth, minimumSize.width) : rawWidth;
|
|
const height = isVerticalOnly ? Math.max(rawHeight, minimumSize.height) : rawHeight;
|
|
|
|
return {
|
|
...box,
|
|
width,
|
|
height,
|
|
scaleX: box.width > 0 ? width / box.width : 1,
|
|
scaleY: box.height > 0 ? height / box.height : 1,
|
|
rawWidth,
|
|
rawHeight,
|
|
};
|
|
}
|
|
|
|
function minimumComponentSizeForElementBoundsResize(component: RawComponent) {
|
|
let width = 1;
|
|
let height = 1;
|
|
|
|
const visitElement = (
|
|
element: RawElement,
|
|
box: Box,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
) => {
|
|
const x = offsetX + box.x;
|
|
const y = offsetY + box.y;
|
|
if (x > 0) width = Math.max(width, x + box.width);
|
|
if (y > 0) height = Math.max(height, y + box.height);
|
|
|
|
const childInfo = childArrayInfo(element);
|
|
if (!childInfo) return;
|
|
layoutChildren(element, childInfo.items, box).forEach((childLayout) => {
|
|
visitElement(
|
|
childLayout.child,
|
|
childLayout.box ?? elementBox(childLayout.child),
|
|
x,
|
|
y,
|
|
);
|
|
});
|
|
};
|
|
|
|
readArray(component.elements)
|
|
.filter(isRecord)
|
|
.forEach((element) => {
|
|
visitElement(element as RawElement, elementBox(element), 0, 0);
|
|
});
|
|
|
|
return { width, height };
|
|
}
|
|
|
|
function positionFromComponentTransform(
|
|
node: Konva.Node,
|
|
nextBox: ComponentTransformBox,
|
|
anchor: ComponentTransformAnchor | null,
|
|
): Point {
|
|
const rawPosition = positionFromNodeInParent(node, STAGE_BOX, {
|
|
...nextBox,
|
|
width: nextBox.rawWidth,
|
|
height: nextBox.rawHeight,
|
|
});
|
|
let x = rawPosition.x;
|
|
let y = rawPosition.y;
|
|
|
|
if (anchor === "middle-left") {
|
|
x = rawPosition.x + nextBox.rawWidth - nextBox.width;
|
|
}
|
|
if (anchor === "top-center") {
|
|
y = rawPosition.y + nextBox.rawHeight - nextBox.height;
|
|
}
|
|
|
|
return {
|
|
x: clamp(x, 0, Math.max(0, STAGE_BOX.width - nextBox.width)),
|
|
y: clamp(y, 0, Math.max(0, STAGE_BOX.height - nextBox.height)),
|
|
};
|
|
}
|
|
|
|
function componentFromNodeTransform(
|
|
component: RawComponent,
|
|
node: Konva.Group,
|
|
anchor: ComponentTransformAnchor | null,
|
|
) {
|
|
const box = componentBox(component);
|
|
const scaleX = node.scaleX();
|
|
const scaleY = node.scaleY();
|
|
const nextBox = componentBoxFromTransform(component, box, scaleX, scaleY, anchor);
|
|
const resizeMode = componentResizeModeForTransform(anchor, scaleX, scaleY);
|
|
node.scaleX(1);
|
|
node.scaleY(1);
|
|
const position = positionFromComponentTransform(node, nextBox, anchor);
|
|
const nextComponentBox = {
|
|
...position,
|
|
width: nextBox.width,
|
|
height: nextBox.height,
|
|
rotation: node.rotation(),
|
|
};
|
|
|
|
if (resizeMode === "resize-frame") {
|
|
return resizeComponentFrame(component, nextComponentBox);
|
|
}
|
|
if (resizeMode === "resize-element-bounds") {
|
|
return resizeComponentElementBounds(component, {
|
|
...nextComponentBox,
|
|
scaleX: nextBox.scaleX,
|
|
scaleY: nextBox.scaleY,
|
|
});
|
|
}
|
|
return resizeComponent(component, {
|
|
...nextComponentBox,
|
|
scaleX: nextBox.scaleX,
|
|
scaleY: nextBox.scaleY,
|
|
});
|
|
}
|
|
|
|
export function RawComponentNode({
|
|
component,
|
|
componentIndex,
|
|
isEditMode,
|
|
isMultiSelectedComponent,
|
|
editingKey,
|
|
selectedTableCell,
|
|
setNodeRef,
|
|
onSelect,
|
|
onTableCellSelect,
|
|
onTableCellEdit,
|
|
onOpenElementEditor,
|
|
onComponentChange,
|
|
onComponentDragStart,
|
|
onComponentDragMove,
|
|
onComponentDragEnd,
|
|
onElementChange,
|
|
fontRevision,
|
|
}: {
|
|
component: RawComponent;
|
|
componentIndex: number;
|
|
isEditMode: boolean;
|
|
isMultiSelectedComponent: boolean;
|
|
editingKey: string | null;
|
|
selectedTableCell: TableCellSelection | null;
|
|
setNodeRef: (key: string, node: Konva.Node | null) => void;
|
|
onSelect: (selection: Selection, options?: SelectOptions) => void;
|
|
onTableCellSelect: (
|
|
selection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => void;
|
|
onTableCellEdit: (
|
|
selection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => void;
|
|
onOpenElementEditor: (selection: ElementSelection) => void;
|
|
onComponentChange: (
|
|
componentIndex: number,
|
|
updater: (component: RawComponent) => RawComponent,
|
|
) => void;
|
|
onComponentDragStart: (componentIndex: number, node: Konva.Node) => void;
|
|
onComponentDragMove: (componentIndex: number, node: Konva.Node) => void;
|
|
onComponentDragEnd: (componentIndex: number, node: Konva.Node) => void;
|
|
onElementChange: (
|
|
selection: ElementSelection,
|
|
updater: (element: RawElement) => RawElement,
|
|
) => void;
|
|
fontRevision: number;
|
|
}) {
|
|
const groupRef = useRef<Konva.Group | null>(null);
|
|
const box = componentBox(component);
|
|
const selection: ComponentSelection = { kind: "component", componentIndex };
|
|
const key = keyForSelection(selection);
|
|
const elements = readArray(component.elements).filter(
|
|
isRecord,
|
|
) as RawElement[];
|
|
const clipBounds = isEditMode
|
|
? null
|
|
: componentTextClipBounds(elements, box);
|
|
|
|
return (
|
|
<Group
|
|
ref={(node) => {
|
|
groupRef.current = node;
|
|
setNodeRef(key, node);
|
|
}}
|
|
x={box.x + box.width / 2}
|
|
y={box.y + box.height / 2}
|
|
width={box.width}
|
|
height={box.height}
|
|
offsetX={box.width / 2}
|
|
offsetY={box.height / 2}
|
|
rotation={readNumber(component.rotation) ?? 0}
|
|
clipX={clipBounds?.x}
|
|
clipY={clipBounds?.y}
|
|
clipWidth={clipBounds?.width}
|
|
clipHeight={clipBounds?.height}
|
|
draggable={isEditMode}
|
|
onMouseDown={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
if (isMultiSelectedComponent && !event.evt.shiftKey) return;
|
|
onSelect(selection, { additive: event.evt.shiftKey });
|
|
}}
|
|
onTouchStart={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
if (isMultiSelectedComponent) return;
|
|
onSelect(selection);
|
|
}}
|
|
onDragStart={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
const node = groupRef.current;
|
|
if (!node) return;
|
|
if (!isMultiSelectedComponent && !event.evt.shiftKey) {
|
|
onSelect(selection);
|
|
}
|
|
onComponentDragStart(componentIndex, node);
|
|
}}
|
|
onDragMove={(event) => {
|
|
event.cancelBubble = true;
|
|
const node = groupRef.current;
|
|
if (!node) return;
|
|
onComponentDragMove(componentIndex, node);
|
|
}}
|
|
onDragEnd={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
const node = groupRef.current;
|
|
if (!node) return;
|
|
onComponentDragEnd(componentIndex, node);
|
|
}}
|
|
onTransform={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
}}
|
|
onTransformEnd={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
const node = groupRef.current;
|
|
if (!node) return;
|
|
const anchor = componentTransformAnchorForNode(node);
|
|
const next = componentFromNodeTransform(component, node, anchor);
|
|
onComponentChange(componentIndex, () => next);
|
|
}}
|
|
>
|
|
{isEditMode ? <SelectionBoundsRect width={box.width} height={box.height} /> : null}
|
|
{elements.map((element, elementIndex) => (
|
|
<MemoizedRawElementNode
|
|
key={rawElementKey(element, elementIndex)}
|
|
element={element}
|
|
componentIndex={componentIndex}
|
|
elementPath={[elementIndex]}
|
|
isEditMode={isEditMode}
|
|
editingKey={editingKey}
|
|
selectedTableCell={selectedTableCell}
|
|
setNodeRef={setNodeRef}
|
|
onSelect={onSelect}
|
|
onTableCellSelect={onTableCellSelect}
|
|
onTableCellEdit={onTableCellEdit}
|
|
onOpenEditor={onOpenElementEditor}
|
|
onElementChange={onElementChange}
|
|
parentBox={box}
|
|
textConstraintBox={{
|
|
x: 0,
|
|
y: 0,
|
|
width: box.width,
|
|
height: box.height,
|
|
}}
|
|
layoutManaged={false}
|
|
fontRevision={fontRevision}
|
|
/>
|
|
))}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function componentTextClipBounds(elements: RawElement[], componentBox: Box): Box {
|
|
let left = 0;
|
|
let top = 0;
|
|
let right = componentBox.width;
|
|
let bottom = componentBox.height;
|
|
|
|
const includeBox = (box: Box, offsetX: number, offsetY: number) => {
|
|
left = Math.min(left, offsetX + box.x);
|
|
top = Math.min(top, offsetY + box.y);
|
|
right = Math.max(right, offsetX + box.x + box.width);
|
|
bottom = Math.max(bottom, offsetY + box.y + box.height);
|
|
};
|
|
|
|
const visitElement = (
|
|
element: RawElement,
|
|
box: Box,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
) => {
|
|
const visualBox = constrainedWrappedTextVisualBox(element, box, {
|
|
x: 0,
|
|
y: 0,
|
|
width: Math.max(1, componentBox.width - offsetX),
|
|
height: componentBox.height,
|
|
});
|
|
const type = readString(element.type);
|
|
if (type === "text" || type === "text-list") {
|
|
includeBox(visualBox, offsetX, offsetY);
|
|
}
|
|
|
|
const childInfo = childArrayInfo(element);
|
|
if (!childInfo) return;
|
|
layoutChildren(element, childInfo.items, box).forEach((childLayout) => {
|
|
visitElement(
|
|
childLayout.child,
|
|
childLayout.box ?? elementBox(childLayout.child),
|
|
offsetX + box.x,
|
|
offsetY + box.y,
|
|
);
|
|
});
|
|
};
|
|
|
|
elements.forEach((element) => visitElement(element, elementBox(element), 0, 0));
|
|
|
|
return {
|
|
x: left,
|
|
y: top,
|
|
width: Math.max(1, right - left),
|
|
height: Math.max(1, bottom - top),
|
|
};
|
|
}
|
|
|
|
function constrainedWrappedTextVisualBox(
|
|
element: RawElement,
|
|
box: Box,
|
|
parentBox: Box,
|
|
): Box {
|
|
const type = readString(element.type);
|
|
if (type !== "text" && type !== "text-list") return box;
|
|
|
|
const availableWidth = Math.max(1, parentBox.width - box.x);
|
|
const width = Math.min(box.width, availableWidth);
|
|
if (Math.abs(width - box.width) < 0.01) return box;
|
|
|
|
const constrainedBox = { ...box, width };
|
|
return type === "text-list"
|
|
? textVisualLocalBox(element, constrainedBox, {
|
|
runs: rawTextListRenderTextRuns(element),
|
|
})
|
|
: textVisualLocalBox(element, constrainedBox);
|
|
}
|
|
|
|
export const MemoizedRawComponentNode = memo(
|
|
RawComponentNode,
|
|
(previous, next) => {
|
|
if (
|
|
previous.component !== next.component ||
|
|
previous.componentIndex !== next.componentIndex ||
|
|
previous.isEditMode !== next.isEditMode ||
|
|
previous.isMultiSelectedComponent !== next.isMultiSelectedComponent ||
|
|
previous.setNodeRef !== next.setNodeRef ||
|
|
previous.onSelect !== next.onSelect ||
|
|
previous.onTableCellSelect !== next.onTableCellSelect ||
|
|
previous.onTableCellEdit !== next.onTableCellEdit ||
|
|
previous.onOpenElementEditor !== next.onOpenElementEditor ||
|
|
previous.onComponentChange !== next.onComponentChange ||
|
|
previous.onComponentDragStart !== next.onComponentDragStart ||
|
|
previous.onComponentDragMove !== next.onComponentDragMove ||
|
|
previous.onComponentDragEnd !== next.onComponentDragEnd ||
|
|
previous.onElementChange !== next.onElementChange ||
|
|
previous.selectedTableCell !== next.selectedTableCell ||
|
|
previous.fontRevision !== next.fontRevision
|
|
) {
|
|
return false;
|
|
}
|
|
return !(
|
|
previous.editingKey !== next.editingKey &&
|
|
(selectionTouchesComponent(
|
|
previous.editingKey,
|
|
previous.componentIndex,
|
|
) ||
|
|
selectionTouchesComponent(next.editingKey, next.componentIndex))
|
|
);
|
|
},
|
|
);
|
|
|
|
function RawElementNode({
|
|
element,
|
|
componentIndex,
|
|
elementPath,
|
|
isEditMode,
|
|
editingKey,
|
|
selectedTableCell,
|
|
setNodeRef,
|
|
onSelect,
|
|
onTableCellSelect,
|
|
onTableCellEdit,
|
|
onOpenEditor,
|
|
onElementChange,
|
|
parentBox,
|
|
textConstraintBox,
|
|
renderBox,
|
|
layoutManaged = false,
|
|
fontRevision,
|
|
}: {
|
|
element: RawElement;
|
|
componentIndex: number;
|
|
elementPath: number[];
|
|
isEditMode: boolean;
|
|
editingKey: string | null;
|
|
selectedTableCell: TableCellSelection | null;
|
|
setNodeRef: (key: string, node: Konva.Node | null) => void;
|
|
onSelect: (selection: Selection, options?: SelectOptions) => void;
|
|
onTableCellSelect: (
|
|
selection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => void;
|
|
onTableCellEdit: (
|
|
selection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => void;
|
|
onOpenEditor: (selection: ElementSelection) => void;
|
|
onElementChange: (
|
|
selection: ElementSelection,
|
|
updater: (element: RawElement) => RawElement,
|
|
) => void;
|
|
parentBox: Box;
|
|
textConstraintBox?: Box | null;
|
|
renderBox?: Box | null;
|
|
layoutManaged?: boolean;
|
|
fontRevision: number;
|
|
}) {
|
|
const groupRef = useRef<Konva.Group | null>(null);
|
|
const box = renderBox ?? elementBox(element);
|
|
const selection = useMemo<ElementSelection>(
|
|
() => ({
|
|
kind: "element",
|
|
componentIndex,
|
|
elementPath,
|
|
}),
|
|
[componentIndex, elementPath],
|
|
);
|
|
const key = keyForSelection(selection);
|
|
const selectedCell =
|
|
selectedTableCell?.elementPath === key ? selectedTableCell : null;
|
|
const editing = editingKey === key;
|
|
const childInfo = childArrayInfo(element);
|
|
const children = childInfo?.items ?? [];
|
|
const laidOutChildren = layoutChildren(element, children, box);
|
|
const clipChildren = shouldClipElementChildren(element, childInfo);
|
|
const shouldConstrainTextVisual =
|
|
componentIndex !== ROOT_ELEMENTS_COMPONENT_INDEX || elementPath.length > 1;
|
|
const visualBox = shouldConstrainTextVisual
|
|
? constrainedWrappedTextVisualBox(element, box, textConstraintBox ?? parentBox)
|
|
: box;
|
|
const childTextConstraintBox = childInfo
|
|
? textConstraintBox
|
|
? {
|
|
...textConstraintBox,
|
|
x: textConstraintBox.x + box.x,
|
|
y: textConstraintBox.y + box.y,
|
|
}
|
|
: {
|
|
x: 0,
|
|
y: 0,
|
|
width: box.width,
|
|
height: box.height,
|
|
}
|
|
: null;
|
|
const centerOrigin = shouldUseCenterOrigin(element);
|
|
const handleTableCellSelect = useCallback(
|
|
(rowIndex: number, colIndex: number) => {
|
|
onTableCellSelect(selection, rowIndex, colIndex);
|
|
},
|
|
[onTableCellSelect, selection],
|
|
);
|
|
const handleTableCellEdit = useCallback(
|
|
(rowIndex: number, colIndex: number) => {
|
|
onTableCellEdit(selection, rowIndex, colIndex);
|
|
},
|
|
[onTableCellEdit, selection],
|
|
);
|
|
|
|
return (
|
|
<Group
|
|
ref={(node) => {
|
|
groupRef.current = node;
|
|
setNodeRef(key, node);
|
|
}}
|
|
x={centerOrigin ? box.x + box.width / 2 : box.x}
|
|
y={centerOrigin ? box.y + box.height / 2 : box.y}
|
|
width={box.width}
|
|
height={box.height}
|
|
offsetX={centerOrigin ? box.width / 2 : 0}
|
|
offsetY={centerOrigin ? box.height / 2 : 0}
|
|
clipX={clipChildren ? 0 : undefined}
|
|
clipY={clipChildren ? 0 : undefined}
|
|
clipWidth={clipChildren ? box.width : undefined}
|
|
clipHeight={clipChildren ? box.height : undefined}
|
|
rotation={readNumber(element.rotation) ?? 0}
|
|
opacity={readNumber(element.opacity) ?? 1}
|
|
onMouseDown={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = false;
|
|
}}
|
|
onTouchStart={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = false;
|
|
}}
|
|
onClick={(event) => {
|
|
if (!isEditMode) return;
|
|
if (componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
event.cancelBubble = true;
|
|
onSelect(selection);
|
|
}
|
|
}}
|
|
onTap={(event) => {
|
|
if (!isEditMode) return;
|
|
if (componentIndex === ROOT_ELEMENTS_COMPONENT_INDEX) {
|
|
event.cancelBubble = true;
|
|
onSelect(selection);
|
|
}
|
|
}}
|
|
onDblClick={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
onSelect(selection);
|
|
onOpenEditor(selection);
|
|
}}
|
|
onDblTap={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
onSelect(selection);
|
|
onOpenEditor(selection);
|
|
}}
|
|
onTransformEnd={(event) => {
|
|
if (!isEditMode) return;
|
|
event.cancelBubble = true;
|
|
const node = groupRef.current;
|
|
if (!node) return;
|
|
const scaleX = node.scaleX();
|
|
const scaleY = node.scaleY();
|
|
const nextSize = {
|
|
width: Math.max(1, box.width * scaleX),
|
|
height: Math.max(1, box.height * scaleY),
|
|
};
|
|
node.scaleX(1);
|
|
node.scaleY(1);
|
|
const fontScale = fontScaleFromResize(scaleX, scaleY);
|
|
onElementChange(selection, (current) => ({
|
|
...scaleRawElementTextMetrics(current, fontScale),
|
|
position: positionFromNodeInParent(
|
|
node,
|
|
parentBox,
|
|
{ ...box, ...nextSize },
|
|
),
|
|
size: nextSize,
|
|
rotation: node.rotation(),
|
|
...(layoutManaged || isManualPositioned(current)
|
|
? { __presenton_manual_position: true }
|
|
: {}),
|
|
}));
|
|
}}
|
|
>
|
|
{isEditMode ? (
|
|
<SelectionBoundsRect width={box.width} height={box.height} />
|
|
) : null}
|
|
{editing ? null : (
|
|
<MemoizedRawElementVisual
|
|
element={element}
|
|
width={visualBox.width}
|
|
height={visualBox.height}
|
|
interactive={isEditMode}
|
|
selectedTableCell={selectedCell}
|
|
onTableCellSelect={handleTableCellSelect}
|
|
onTableCellEdit={handleTableCellEdit}
|
|
fontRevision={fontRevision}
|
|
/>
|
|
)}
|
|
{laidOutChildren.map(({ child, index, box: childBox, layoutManaged }) => (
|
|
<MemoizedRawElementNode
|
|
key={rawElementKey(child, index)}
|
|
element={child}
|
|
componentIndex={componentIndex}
|
|
elementPath={[...elementPath, index]}
|
|
isEditMode={isEditMode}
|
|
editingKey={editingKey}
|
|
selectedTableCell={selectedTableCell}
|
|
setNodeRef={setNodeRef}
|
|
onSelect={onSelect}
|
|
onTableCellSelect={onTableCellSelect}
|
|
onTableCellEdit={onTableCellEdit}
|
|
onOpenEditor={onOpenEditor}
|
|
onElementChange={onElementChange}
|
|
parentBox={{
|
|
x: parentBox.x + box.x,
|
|
y: parentBox.y + box.y,
|
|
width: box.width,
|
|
height: box.height,
|
|
}}
|
|
textConstraintBox={childTextConstraintBox}
|
|
renderBox={childBox}
|
|
layoutManaged={layoutManaged}
|
|
fontRevision={fontRevision}
|
|
/>
|
|
))}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export const MemoizedRawElementNode = memo(RawElementNode, (previous, next) => {
|
|
if (
|
|
previous.element !== next.element ||
|
|
previous.componentIndex !== next.componentIndex ||
|
|
previous.isEditMode !== next.isEditMode ||
|
|
previous.layoutManaged !== next.layoutManaged ||
|
|
previous.fontRevision !== next.fontRevision ||
|
|
previous.selectedTableCell !== next.selectedTableCell ||
|
|
previous.setNodeRef !== next.setNodeRef ||
|
|
previous.onSelect !== next.onSelect ||
|
|
previous.onTableCellSelect !== next.onTableCellSelect ||
|
|
previous.onTableCellEdit !== next.onTableCellEdit ||
|
|
previous.onOpenEditor !== next.onOpenEditor ||
|
|
previous.onElementChange !== next.onElementChange ||
|
|
!numberPathEqual(previous.elementPath, next.elementPath) ||
|
|
!boxEqual(previous.parentBox, next.parentBox) ||
|
|
!nullableBoxEqual(previous.textConstraintBox, next.textConstraintBox) ||
|
|
!nullableBoxEqual(previous.renderBox, next.renderBox)
|
|
) {
|
|
return false;
|
|
}
|
|
return !(
|
|
previous.editingKey !== next.editingKey &&
|
|
(selectionTouchesElement(
|
|
previous.editingKey,
|
|
previous.componentIndex,
|
|
previous.elementPath,
|
|
) ||
|
|
selectionTouchesElement(
|
|
next.editingKey,
|
|
next.componentIndex,
|
|
next.elementPath,
|
|
))
|
|
);
|
|
});
|
|
|
|
function SelectionBoundsRect({
|
|
width,
|
|
height,
|
|
}: {
|
|
width: number;
|
|
height: number;
|
|
}) {
|
|
return (
|
|
<Rect
|
|
width={width}
|
|
height={height}
|
|
fill="rgba(0,0,0,0)"
|
|
listening={false}
|
|
perfectDrawEnabled={false}
|
|
shadowForStrokeEnabled={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RawElementVisual({
|
|
element,
|
|
width,
|
|
height,
|
|
interactive,
|
|
selectedTableCell,
|
|
onTableCellSelect,
|
|
onTableCellEdit,
|
|
fontRevision,
|
|
}: {
|
|
element: RawElement;
|
|
width: number;
|
|
height: number;
|
|
interactive: boolean;
|
|
selectedTableCell: TableCellSelection | null;
|
|
onTableCellSelect: (rowIndex: number, colIndex: number) => void;
|
|
onTableCellEdit: (rowIndex: number, colIndex: number) => void;
|
|
fontRevision: number;
|
|
}) {
|
|
void fontRevision;
|
|
const type = readString(element.type);
|
|
if (isBoxVisualType(type)) {
|
|
const fill = colorWithOpacity(
|
|
fillColor(element.fill),
|
|
fillOpacity(element.fill),
|
|
);
|
|
const stroke = colorWithOpacity(
|
|
strokeColor(element.stroke),
|
|
strokeOpacity(element.stroke),
|
|
);
|
|
if (!fill && !(stroke && strokeWidth(element.stroke) > 0)) return null;
|
|
return (
|
|
<Rect
|
|
width={width}
|
|
height={height}
|
|
fill={fill}
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth(element.stroke)}
|
|
cornerRadius={borderRadius(element)}
|
|
{...shadowProps(element)}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "ellipse") {
|
|
return (
|
|
<Ellipse
|
|
x={width / 2}
|
|
y={height / 2}
|
|
radiusX={width / 2}
|
|
radiusY={height / 2}
|
|
fill={
|
|
colorWithOpacity(fillColor(element.fill), fillOpacity(element.fill)) ??
|
|
"transparent"
|
|
}
|
|
stroke={colorWithOpacity(
|
|
strokeColor(element.stroke),
|
|
strokeOpacity(element.stroke),
|
|
)}
|
|
strokeWidth={strokeWidth(element.stroke)}
|
|
{...shadowProps(element)}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "line") {
|
|
const stroke = colorWithOpacity(
|
|
strokeColor(element.stroke),
|
|
strokeOpacity(element.stroke),
|
|
);
|
|
const lineWidth = strokeWidth(element.stroke);
|
|
const lineDash = readArray(asRecord(element.stroke)?.dash)
|
|
.map(readNumber)
|
|
.filter((value): value is number => value != null && value >= 0);
|
|
if (!stroke || lineWidth <= 0) return null;
|
|
return (
|
|
<Line
|
|
points={linePoints(width, height, lineWidth)}
|
|
stroke={stroke}
|
|
strokeWidth={lineWidth}
|
|
dash={lineDash.length ? lineDash : undefined}
|
|
hitStrokeWidth={Math.max(20, lineWidth)}
|
|
{...shadowProps(element)}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "text") {
|
|
return (
|
|
<RawRichTextElement
|
|
element={element}
|
|
width={width}
|
|
height={height}
|
|
interactive={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "text-list") {
|
|
return (
|
|
<RawRichTextElement
|
|
element={element}
|
|
width={width}
|
|
height={height}
|
|
runs={rawTextListRenderTextRuns(element)}
|
|
interactive={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "image") {
|
|
return <RawImageElement element={element} width={width} height={height} interactive={interactive} />;
|
|
}
|
|
if (type === "table") {
|
|
return (
|
|
<RawTableElement
|
|
element={element}
|
|
width={width}
|
|
height={height}
|
|
interactive={interactive}
|
|
selectedCell={selectedTableCell}
|
|
onCellSelect={onTableCellSelect}
|
|
onCellEdit={onTableCellEdit}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "chart") {
|
|
const legendState = Object.prototype.hasOwnProperty.call(element, "legend")
|
|
? String(element.legend)
|
|
: String(element.showLegend ?? "auto");
|
|
return (
|
|
<RawChartElement
|
|
key={`chart-legend-${legendState}`}
|
|
element={element}
|
|
width={width}
|
|
height={height}
|
|
interactive={interactive}
|
|
/>
|
|
);
|
|
}
|
|
if (type === "infographic") {
|
|
return <RawInfographicElement element={element} width={width} height={height} interactive={interactive} />;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const MemoizedRawElementVisual = memo(
|
|
RawElementVisual,
|
|
(previous, next) =>
|
|
previous.element === next.element &&
|
|
previous.width === next.width &&
|
|
previous.height === next.height &&
|
|
previous.interactive === next.interactive &&
|
|
previous.selectedTableCell === next.selectedTableCell &&
|
|
previous.onTableCellSelect === next.onTableCellSelect &&
|
|
previous.onTableCellEdit === next.onTableCellEdit &&
|
|
previous.fontRevision === next.fontRevision,
|
|
);
|
|
|
|
function RawRichTextElement({
|
|
element,
|
|
width,
|
|
height,
|
|
text,
|
|
runs: runsOverride,
|
|
interactive,
|
|
}: {
|
|
element: RawElement;
|
|
width: number;
|
|
height: number;
|
|
text?: string;
|
|
runs?: RenderTextRun[];
|
|
interactive: boolean;
|
|
}) {
|
|
const font = rawFont(element);
|
|
const renderRuns =
|
|
runsOverride ?? (text == null ? rawRenderTextRuns(element) : []);
|
|
const content =
|
|
text ??
|
|
(runsOverride ? textRunsContent(runsOverride) : rawTextContent(element));
|
|
const displayContent = displayText(content);
|
|
const align = readString(element.alignment?.horizontal) ?? "left";
|
|
const verticalAlign = readString(element.alignment?.vertical) ?? "top";
|
|
const textLineHeight = effectiveLineHeight({
|
|
text: displayContent,
|
|
width,
|
|
fontSize: font.size,
|
|
lineHeight: font.lineHeight,
|
|
fallback: 1.15,
|
|
wrap: "word",
|
|
});
|
|
|
|
const layoutRuns =
|
|
renderRuns.length > 0 ? renderRuns : [{ text: displayContent, font }];
|
|
const lines = layoutRenderTextRuns(layoutRuns, width, "word");
|
|
const lineMetrics = lines.map((line) => ({
|
|
height: lineRenderHeight(line, textLineHeight),
|
|
width: line.reduce((sum, segment) => sum + segment.width, 0),
|
|
}));
|
|
const totalHeight = lineMetrics.reduce(
|
|
(sum, metric) => sum + metric.height,
|
|
0,
|
|
);
|
|
const startY =
|
|
verticalAlign === "middle"
|
|
? Math.max(0, (height - totalHeight) / 2)
|
|
: verticalAlign === "bottom"
|
|
? Math.max(0, height - totalHeight)
|
|
: 0;
|
|
let y = startY;
|
|
|
|
return (
|
|
<Group listening={interactive}>
|
|
{lines.map((line, lineIndex) => {
|
|
const lineMetric = lineMetrics[lineIndex] ?? {
|
|
height: font.size * textLineHeight,
|
|
width: 0,
|
|
};
|
|
const startX = lineStartX(align, width, lineMetric.width, false);
|
|
let x = startX;
|
|
const lineY = y;
|
|
y += lineMetric.height;
|
|
return line.map((segment, segmentIndex) => {
|
|
const segmentX = x;
|
|
x += segment.width;
|
|
// Keep width automatic. The custom layout owns the x advance; a fixed
|
|
// tight width lets Konva re-wrap and clip the final glyph.
|
|
return (
|
|
<Text
|
|
key={`${lineIndex}:${segmentIndex}`}
|
|
x={segmentX}
|
|
y={lineY}
|
|
height={lineMetric.height}
|
|
text={segment.text}
|
|
fill={textFill(segment.font)}
|
|
fontFamily={`${segment.font.family}, Helvetica, sans-serif`}
|
|
fontSize={segment.font.size}
|
|
fontStyle={`${segment.font.bold ? "bold" : "normal"} ${segment.font.italic ? "italic" : ""}`}
|
|
textDecoration={segment.font.underline ? "underline" : ""}
|
|
verticalAlign="middle"
|
|
lineHeight={segment.font.lineHeight ?? textLineHeight}
|
|
letterSpacing={segment.font.letterSpacing}
|
|
wrap="none"
|
|
{...shadowProps(element)}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
});
|
|
})}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function textFill(font: { color: string; opacity?: number | null }) {
|
|
return colorWithOpacity(withHash(font.color), font.opacity ?? 1);
|
|
}
|
|
|
|
function RawImageElement({
|
|
element,
|
|
width,
|
|
height,
|
|
interactive,
|
|
}: {
|
|
element: RawElement;
|
|
width: number;
|
|
height: number;
|
|
interactive: boolean;
|
|
}) {
|
|
const src = readString(element.data);
|
|
const color = readString(element.color);
|
|
const isIcon = isRawIconElement(element);
|
|
const renderSrc = useMemo(() => {
|
|
if (!src || !color || !isIcon || typeof window === "undefined") return src;
|
|
const baseUrl = window.location.href;
|
|
if (!isStaticSvgIconSource(src, baseUrl)) return src;
|
|
return buildSvgUpdateUrl(src, baseUrl, { color }) ?? src;
|
|
}, [color, isIcon, src]);
|
|
const loaded = useLoadedKonvaImage(renderSrc);
|
|
|
|
if (!loaded) {
|
|
return (
|
|
<Rect
|
|
width={width}
|
|
height={height}
|
|
fill="#EEF1F5"
|
|
stroke="#CBD2D9"
|
|
strokeWidth={1}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const fit = readString(element.fit) ?? "contain";
|
|
const focusX = clamp(readNumber(element.focus_x) ?? 50, 0, 100) / 100;
|
|
const focusY = clamp(readNumber(element.focus_y) ?? 50, 0, 100) / 100;
|
|
const cropScale = clamp(readNumber(element.crop_scale) ?? 1, 1, 6);
|
|
const flipH = readBoolean(element.flip_h) === true;
|
|
const flipV = readBoolean(element.flip_v) === true;
|
|
const clipPath = imageClipPath(element);
|
|
const cornerRadii = imageCornerRadii(element, width, height);
|
|
const naturalRatio = loaded.width / loaded.height || 1;
|
|
const boxRatio = width / height || 1;
|
|
let drawW = width;
|
|
let drawH = height;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
let crop:
|
|
| {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
| undefined;
|
|
|
|
if (fit === "cover") {
|
|
if (naturalRatio > boxRatio) {
|
|
const baseCropWidth = loaded.height * boxRatio;
|
|
const cropWidth = Math.min(loaded.width, baseCropWidth / cropScale);
|
|
const cropHeight = Math.min(loaded.height, loaded.height / cropScale);
|
|
crop = {
|
|
x: Math.max(0, (loaded.width - cropWidth) * focusX),
|
|
y: Math.max(0, (loaded.height - cropHeight) * focusY),
|
|
width: cropWidth,
|
|
height: cropHeight,
|
|
};
|
|
} else {
|
|
const baseCropHeight = loaded.width / boxRatio;
|
|
const cropWidth = Math.min(loaded.width, loaded.width / cropScale);
|
|
const cropHeight = Math.min(loaded.height, baseCropHeight / cropScale);
|
|
crop = {
|
|
x: Math.max(0, (loaded.width - cropWidth) * focusX),
|
|
y: Math.max(0, (loaded.height - cropHeight) * focusY),
|
|
width: cropWidth,
|
|
height: cropHeight,
|
|
};
|
|
}
|
|
} else if (fit === "contain") {
|
|
if (naturalRatio > boxRatio) {
|
|
drawH = width / naturalRatio;
|
|
offsetY = (height - drawH) * focusY;
|
|
} else {
|
|
drawW = height * naturalRatio;
|
|
offsetX = (width - drawW) * focusX;
|
|
}
|
|
}
|
|
|
|
const imageNode = (
|
|
<KonvaImage
|
|
image={loaded}
|
|
x={offsetX + (flipH ? drawW : 0)}
|
|
y={offsetY + (flipV ? drawH : 0)}
|
|
width={drawW}
|
|
height={drawH}
|
|
crop={crop}
|
|
scaleX={flipH ? -1 : 1}
|
|
scaleY={flipV ? -1 : 1}
|
|
listening={interactive}
|
|
/>
|
|
);
|
|
|
|
const clippedImageNode = clipPath ? (
|
|
<Group
|
|
clipFunc={(context) =>
|
|
drawImageClipPath(context, clipPath, width, height)
|
|
}
|
|
listening={interactive}
|
|
>
|
|
{imageNode}
|
|
</Group>
|
|
) : (
|
|
imageNode
|
|
);
|
|
|
|
return (
|
|
<Group
|
|
clipFunc={(context) =>
|
|
drawRoundedImageClip(context, width, height, cornerRadii)
|
|
}
|
|
listening={interactive}
|
|
>
|
|
{clippedImageNode}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
type ParsedImageClipPath =
|
|
| { kind: "polygon"; points: Point[] }
|
|
| { kind: "path"; data: string }
|
|
| {
|
|
kind: "inset";
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
radius: number;
|
|
}
|
|
| { kind: "rect"; x: number; y: number; width: number; height: number; radius: number }
|
|
| { kind: "circle"; x: number; y: number; radius: number }
|
|
| { kind: "ellipse"; x: number; y: number; radiusX: number; radiusY: number };
|
|
|
|
function imageClipPath(element: RawElement): string | null {
|
|
const raw = readString(element.clippath ?? element.clipPath ?? element.clip_path);
|
|
const clipPath = raw?.trim();
|
|
return clipPath && clipPath.toLowerCase() !== "none" ? clipPath : null;
|
|
}
|
|
|
|
function drawImageClipPath(
|
|
context: Konva.Context,
|
|
clipPath: string,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const parsed = parseImageClipPath(clipPath, width, height);
|
|
if (!parsed) {
|
|
context.rect(0, 0, width, height);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "path") {
|
|
if (typeof Path2D !== "undefined") {
|
|
try {
|
|
return [new Path2D(parsed.data)] as [Path2D];
|
|
} catch {
|
|
// Fall through to the basic path drawer below.
|
|
}
|
|
}
|
|
if (drawBasicSvgClipPath(context, parsed.data)) return;
|
|
context.rect(0, 0, width, height);
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "polygon") {
|
|
parsed.points.forEach((point, index) => {
|
|
if (index === 0) context.moveTo(point.x, point.y);
|
|
else context.lineTo(point.x, point.y);
|
|
});
|
|
context.closePath();
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "inset") {
|
|
const x = parsed.left;
|
|
const y = parsed.top;
|
|
const insetWidth = Math.max(0, width - parsed.left - parsed.right);
|
|
const insetHeight = Math.max(0, height - parsed.top - parsed.bottom);
|
|
const radius = Math.min(parsed.radius, insetWidth / 2, insetHeight / 2);
|
|
if (radius > 0) {
|
|
context.roundRect(x, y, insetWidth, insetHeight, radius);
|
|
} else {
|
|
context.rect(x, y, insetWidth, insetHeight);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "rect") {
|
|
const radius = Math.min(parsed.radius, parsed.width / 2, parsed.height / 2);
|
|
if (radius > 0) {
|
|
context.roundRect(parsed.x, parsed.y, parsed.width, parsed.height, radius);
|
|
} else {
|
|
context.rect(parsed.x, parsed.y, parsed.width, parsed.height);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.kind === "circle") {
|
|
context.arc(parsed.x, parsed.y, parsed.radius, 0, Math.PI * 2);
|
|
return;
|
|
}
|
|
|
|
context.ellipse(
|
|
parsed.x,
|
|
parsed.y,
|
|
parsed.radiusX,
|
|
parsed.radiusY,
|
|
0,
|
|
0,
|
|
Math.PI * 2,
|
|
);
|
|
}
|
|
|
|
function parseImageClipPath(
|
|
value: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const pathData = clipPathDataFromValue(value);
|
|
if (pathData) return { kind: "path", data: pathData };
|
|
|
|
const clipFunction = readCssClipFunction(value);
|
|
if (!clipFunction) return null;
|
|
|
|
const { kind, body } = clipFunction;
|
|
if (kind === "polygon") return parsePolygonClipPath(body, width, height);
|
|
if (kind === "inset") return parseInsetClipPath(body, width, height);
|
|
if (kind === "rect") return parseRectClipPath(body, width, height);
|
|
if (kind === "xywh") return parseXywhClipPath(body, width, height);
|
|
if (kind === "circle") return parseCircleClipPath(body, width, height);
|
|
if (kind === "ellipse") return parseEllipseClipPath(body, width, height);
|
|
return null;
|
|
}
|
|
|
|
function parsePolygonClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const pointSource = body.replace(/^(evenodd|nonzero)\s*,\s*/i, "");
|
|
const rawPoints = pointSource.split(/\s*,\s*/).filter(Boolean);
|
|
const points =
|
|
rawPoints.length >= 3
|
|
? rawPoints.map((point) => parseClipPoint(point, width, height))
|
|
: parseClipPointPairs(splitCssTokens(pointSource), width, height);
|
|
|
|
if (points.length < 3 || points.some((point) => point == null)) return null;
|
|
return {
|
|
kind: "polygon",
|
|
points: points as Point[],
|
|
};
|
|
}
|
|
|
|
function parseInsetClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const [insetPart, radiusPart] = splitCssRound(body);
|
|
const values = splitCssTokens(insetPart);
|
|
if (values.length === 0) return null;
|
|
|
|
const top = parseClipLength(values[0], height);
|
|
const right = parseClipLength(values[1] ?? values[0], width);
|
|
const bottom = parseClipLength(values[2] ?? values[0], height);
|
|
const left = parseClipLength(values[3] ?? values[1] ?? values[0], width);
|
|
if (top == null || right == null || bottom == null || left == null) {
|
|
return null;
|
|
}
|
|
|
|
const radius = parseClipBoxRadius(radiusPart, width, height);
|
|
return {
|
|
kind: "inset",
|
|
top,
|
|
right,
|
|
bottom,
|
|
left,
|
|
radius,
|
|
};
|
|
}
|
|
|
|
function parseRectClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const [rectPart, radiusPart] = splitCssRound(body);
|
|
const values = splitCssTokens(rectPart);
|
|
if (values.length < 4) return null;
|
|
|
|
const top = parseClipLength(values[0], height);
|
|
const right = parseClipLength(values[1], width);
|
|
const bottom = parseClipLength(values[2], height);
|
|
const left = parseClipLength(values[3], width);
|
|
if (top == null || right == null || bottom == null || left == null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kind: "rect",
|
|
x: left,
|
|
y: top,
|
|
width: Math.max(0, right - left),
|
|
height: Math.max(0, bottom - top),
|
|
radius: parseClipBoxRadius(radiusPart, width, height),
|
|
};
|
|
}
|
|
|
|
function parseXywhClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const [boxPart, radiusPart] = splitCssRound(body);
|
|
const values = splitCssTokens(boxPart);
|
|
if (values.length < 4) return null;
|
|
|
|
const x = parseClipLength(values[0], width);
|
|
const y = parseClipLength(values[1], height);
|
|
const rectWidth = parseClipLength(values[2], width);
|
|
const rectHeight = parseClipLength(values[3], height);
|
|
if (x == null || y == null || rectWidth == null || rectHeight == null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kind: "rect",
|
|
x,
|
|
y,
|
|
width: Math.max(0, rectWidth),
|
|
height: Math.max(0, rectHeight),
|
|
radius: parseClipBoxRadius(radiusPart, width, height),
|
|
};
|
|
}
|
|
|
|
function parseCircleClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const [radiusPart, positionPart] = splitCssAt(body);
|
|
const radiusToken = splitCssTokens(radiusPart)[0];
|
|
const center = parseClipPosition(positionPart, width, height);
|
|
if (!center) return null;
|
|
const radius = radiusToken
|
|
? parseCircleRadius(radiusToken, center, width, height)
|
|
: Math.min(center.x, width - center.x, center.y, height - center.y);
|
|
if (radius == null || !center) return null;
|
|
return {
|
|
kind: "circle",
|
|
x: center.x,
|
|
y: center.y,
|
|
radius,
|
|
};
|
|
}
|
|
|
|
function parseEllipseClipPath(
|
|
body: string,
|
|
width: number,
|
|
height: number,
|
|
): ParsedImageClipPath | null {
|
|
const [radiusPart, positionPart] = splitCssAt(body);
|
|
const radiusTokens = splitCssTokens(radiusPart);
|
|
const center = parseClipPosition(positionPart, width, height);
|
|
if (!center) return null;
|
|
const radiusX = radiusTokens[0]
|
|
? parseEllipseRadius(radiusTokens[0], center.x, width)
|
|
: Math.min(center.x, width - center.x);
|
|
const radiusY = radiusTokens[1]
|
|
? parseEllipseRadius(radiusTokens[1], center.y, height)
|
|
: radiusTokens[0]
|
|
? parseEllipseRadius(radiusTokens[0], center.y, height)
|
|
: Math.min(center.y, height - center.y);
|
|
if (radiusX == null || radiusY == null || !center) return null;
|
|
return {
|
|
kind: "ellipse",
|
|
x: center.x,
|
|
y: center.y,
|
|
radiusX,
|
|
radiusY,
|
|
};
|
|
}
|
|
|
|
function parseClipBoxRadius(
|
|
value: string | null,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const radiusToken = value ? splitCssTokens(value)[0] : null;
|
|
return radiusToken
|
|
? parseClipLength(radiusToken, Math.min(width, height)) ?? 0
|
|
: 0;
|
|
}
|
|
|
|
function parseCircleRadius(
|
|
token: string,
|
|
center: Point,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const normalized = token.toLowerCase();
|
|
if (normalized === "closest-side") {
|
|
return Math.min(center.x, width - center.x, center.y, height - center.y);
|
|
}
|
|
if (normalized === "farthest-side") {
|
|
return Math.max(center.x, width - center.x, center.y, height - center.y);
|
|
}
|
|
return parseClipLength(token, Math.min(width, height));
|
|
}
|
|
|
|
function parseEllipseRadius(token: string, center: number, size: number) {
|
|
const normalized = token.toLowerCase();
|
|
if (normalized === "closest-side") return Math.min(center, size - center);
|
|
if (normalized === "farthest-side") return Math.max(center, size - center);
|
|
return parseClipLength(token, size);
|
|
}
|
|
|
|
function drawBasicSvgClipPath(context: Konva.Context, data: string) {
|
|
const tokens =
|
|
data.match(/[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/g) ??
|
|
[];
|
|
let index = 0;
|
|
let command = "";
|
|
let current: Point = { x: 0, y: 0 };
|
|
let subpathStart: Point = { x: 0, y: 0 };
|
|
let lastCubicControl: Point | null = null;
|
|
let lastQuadraticControl: Point | null = null;
|
|
|
|
const isCommand = (token: string | undefined) =>
|
|
Boolean(token && /^[A-Za-z]$/.test(token));
|
|
const readPathNumber = () => {
|
|
const token = tokens[index];
|
|
if (token == null || isCommand(token)) return null;
|
|
index += 1;
|
|
const value = Number.parseFloat(token);
|
|
return Number.isFinite(value) ? value : null;
|
|
};
|
|
const readPoint = (relative: boolean): Point | null => {
|
|
const x = readPathNumber();
|
|
const y = readPathNumber();
|
|
if (x == null || y == null) return null;
|
|
return relative ? { x: current.x + x, y: current.y + y } : { x, y };
|
|
};
|
|
const reflectPoint = (point: Point | null) =>
|
|
point ? { x: current.x * 2 - point.x, y: current.y * 2 - point.y } : current;
|
|
|
|
while (index < tokens.length) {
|
|
if (isCommand(tokens[index])) {
|
|
command = tokens[index] ?? "";
|
|
index += 1;
|
|
} else if (!command) {
|
|
return false;
|
|
}
|
|
|
|
const relative = command === command.toLowerCase();
|
|
switch (command.toLowerCase()) {
|
|
case "m": {
|
|
const point = readPoint(relative);
|
|
if (!point) return false;
|
|
context.moveTo(point.x, point.y);
|
|
current = point;
|
|
subpathStart = point;
|
|
command = relative ? "l" : "L";
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "l": {
|
|
const point = readPoint(relative);
|
|
if (!point) return false;
|
|
context.lineTo(point.x, point.y);
|
|
current = point;
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "h": {
|
|
const value = readPathNumber();
|
|
if (value == null) return false;
|
|
current = { x: relative ? current.x + value : value, y: current.y };
|
|
context.lineTo(current.x, current.y);
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "v": {
|
|
const value = readPathNumber();
|
|
if (value == null) return false;
|
|
current = { x: current.x, y: relative ? current.y + value : value };
|
|
context.lineTo(current.x, current.y);
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "c": {
|
|
const control1 = readPoint(relative);
|
|
const control2 = readPoint(relative);
|
|
const point = readPoint(relative);
|
|
if (!control1 || !control2 || !point) return false;
|
|
context.bezierCurveTo(
|
|
control1.x,
|
|
control1.y,
|
|
control2.x,
|
|
control2.y,
|
|
point.x,
|
|
point.y,
|
|
);
|
|
current = point;
|
|
lastCubicControl = control2;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "s": {
|
|
const control1 = reflectPoint(lastCubicControl);
|
|
const control2 = readPoint(relative);
|
|
const point = readPoint(relative);
|
|
if (!control2 || !point) return false;
|
|
context.bezierCurveTo(
|
|
control1.x,
|
|
control1.y,
|
|
control2.x,
|
|
control2.y,
|
|
point.x,
|
|
point.y,
|
|
);
|
|
current = point;
|
|
lastCubicControl = control2;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
case "q": {
|
|
const control = readPoint(relative);
|
|
const point = readPoint(relative);
|
|
if (!control || !point) return false;
|
|
context.quadraticCurveTo(control.x, control.y, point.x, point.y);
|
|
current = point;
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = control;
|
|
break;
|
|
}
|
|
case "t": {
|
|
const control = reflectPoint(lastQuadraticControl);
|
|
const point = readPoint(relative);
|
|
if (!point) return false;
|
|
context.quadraticCurveTo(control.x, control.y, point.x, point.y);
|
|
current = point;
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = control;
|
|
break;
|
|
}
|
|
case "z": {
|
|
context.closePath();
|
|
current = subpathStart;
|
|
command = "";
|
|
lastCubicControl = null;
|
|
lastQuadraticControl = null;
|
|
break;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function parseClipPoint(
|
|
value: string,
|
|
width: number,
|
|
height: number,
|
|
): Point | null {
|
|
const [rawX, rawY] = splitCssTokens(value);
|
|
const x = parseClipLength(rawX, width);
|
|
const y = parseClipLength(rawY, height);
|
|
return x == null || y == null ? null : { x, y };
|
|
}
|
|
|
|
function parseClipPointPairs(
|
|
tokens: string[],
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const points: Array<Point | null> = [];
|
|
for (let index = 0; index < tokens.length; index += 2) {
|
|
points.push(parseClipPoint(`${tokens[index]} ${tokens[index + 1]}`, width, height));
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function parseClipPosition(
|
|
value: string | null,
|
|
width: number,
|
|
height: number,
|
|
): Point | null {
|
|
const tokens = splitCssTokens(value ?? "");
|
|
if (tokens.length === 0) return { x: width / 2, y: height / 2 };
|
|
if (tokens.length === 1) {
|
|
const token = tokens[0].toLowerCase();
|
|
if (token === "center") return { x: width / 2, y: height / 2 };
|
|
if (token === "left" || token === "right") {
|
|
return {
|
|
x: parseClipPositionLength(token, width, "left", "right") ?? width / 2,
|
|
y: height / 2,
|
|
};
|
|
}
|
|
if (token === "top" || token === "bottom") {
|
|
return {
|
|
x: width / 2,
|
|
y: parseClipPositionLength(token, height, "top", "bottom") ?? height / 2,
|
|
};
|
|
}
|
|
const x = parseClipLength(token, width);
|
|
return x == null ? null : { x, y: height / 2 };
|
|
}
|
|
|
|
const x = parseClipPositionLength(tokens[0], width, "left", "right");
|
|
const y = parseClipPositionLength(tokens[1], height, "top", "bottom");
|
|
return x == null || y == null ? null : { x, y };
|
|
}
|
|
|
|
function parseClipPositionLength(
|
|
token: string | undefined,
|
|
reference: number,
|
|
startKeyword: string,
|
|
endKeyword: string,
|
|
) {
|
|
if (!token) return null;
|
|
const normalized = token.toLowerCase();
|
|
if (normalized === "center") return reference / 2;
|
|
if (normalized === startKeyword) return 0;
|
|
if (normalized === endKeyword) return reference;
|
|
return parseClipLength(normalized, reference);
|
|
}
|
|
|
|
function parseClipLength(token: string | undefined, reference: number) {
|
|
if (!token) return null;
|
|
const normalized = token.trim().toLowerCase();
|
|
if (!normalized) return null;
|
|
if (normalized.endsWith("%")) {
|
|
const value = Number.parseFloat(normalized.slice(0, -1));
|
|
return Number.isFinite(value) ? (value / 100) * reference : null;
|
|
}
|
|
if (normalized.endsWith("px")) {
|
|
const value = Number.parseFloat(normalized.slice(0, -2));
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
const value = Number.parseFloat(normalized);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function splitCssAt(value: string): [string, string | null] {
|
|
const parts = value.split(/\s+at\s+/i);
|
|
return [parts[0]?.trim() ?? "", parts[1]?.trim() ?? null];
|
|
}
|
|
|
|
function splitCssRound(value: string): [string, string | null] {
|
|
const parts = value.split(/\s+round\s+/i);
|
|
return [parts[0]?.trim() ?? "", parts[1]?.trim() ?? null];
|
|
}
|
|
|
|
function splitCssTokens(value: string) {
|
|
return value.trim().split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
function readCssClipFunction(value: string) {
|
|
const match = /([a-z-]+)\(/i.exec(value);
|
|
if (!match || match.index == null) return null;
|
|
|
|
const kind = match[1].toLowerCase();
|
|
const bodyStart = match.index + match[0].length;
|
|
let depth = 1;
|
|
let quote: string | null = null;
|
|
|
|
for (let index = bodyStart; index < value.length; index += 1) {
|
|
const char = value[index];
|
|
if (quote) {
|
|
if (char === "\\" && index + 1 < value.length) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (char === "'" || char === '"') {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === "(") {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if (char === ")") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return {
|
|
kind,
|
|
body: value.slice(bodyStart, index).trim(),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function clipPathDataFromValue(value: string) {
|
|
const clipFunction = readCssClipFunction(value);
|
|
if (clipFunction?.kind === "path") {
|
|
const data = extractCssPathData(clipFunction.body);
|
|
return data && isSafeSvgClipPathData(data) ? data : null;
|
|
}
|
|
|
|
const data = extractCssPathData(value);
|
|
return data && isSafeSvgClipPathData(data) ? data : null;
|
|
}
|
|
|
|
function extractCssPathData(value: string) {
|
|
const body = value.trim().replace(/^(evenodd|nonzero)\s*,\s*/i, "");
|
|
const quoted = /^(['"])([\s\S]*)\1$/.exec(body);
|
|
return quoted ? quoted[2].trim() : body;
|
|
}
|
|
|
|
function isSafeSvgClipPathData(value: string) {
|
|
return (
|
|
/[A-Za-z]/.test(value) &&
|
|
/^[AaCcHhLlMmQqSsTtVvZz0-9eE\s.,+\-]*$/.test(value)
|
|
);
|
|
}
|
|
|
|
function useLoadedKonvaImage(src: string | null): HTMLImageElement | null {
|
|
const [loaded, setLoaded] = useState<HTMLImageElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!src) {
|
|
setLoaded(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void loadKonvaImage(src).then((image) => {
|
|
if (!cancelled) setLoaded(image);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [src]);
|
|
|
|
return loaded;
|
|
}
|
|
|
|
function imageCornerRadii(
|
|
element: RawElement,
|
|
width: number,
|
|
height: number,
|
|
): [number, number, number, number] {
|
|
const rawRadius = borderRadius(element);
|
|
const values = Array.isArray(rawRadius)
|
|
? rawRadius
|
|
: [rawRadius, rawRadius, rawRadius, rawRadius];
|
|
const maxRadius = Math.max(0, Math.min(width, height) / 2);
|
|
return [
|
|
clamp(values[0] ?? 0, 0, maxRadius),
|
|
clamp(values[1] ?? 0, 0, maxRadius),
|
|
clamp(values[2] ?? 0, 0, maxRadius),
|
|
clamp(values[3] ?? 0, 0, maxRadius),
|
|
];
|
|
}
|
|
|
|
function drawRoundedImageClip(
|
|
context: Konva.Context,
|
|
width: number,
|
|
height: number,
|
|
[topLeft, topRight, bottomRight, bottomLeft]: [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
],
|
|
) {
|
|
context.beginPath();
|
|
context.moveTo(topLeft, 0);
|
|
context.lineTo(width - topRight, 0);
|
|
context.quadraticCurveTo(width, 0, width, topRight);
|
|
context.lineTo(width, height - bottomRight);
|
|
context.quadraticCurveTo(width, height, width - bottomRight, height);
|
|
context.lineTo(bottomLeft, height);
|
|
context.quadraticCurveTo(0, height, 0, height - bottomLeft);
|
|
context.lineTo(0, topLeft);
|
|
context.quadraticCurveTo(0, 0, topLeft, 0);
|
|
context.closePath();
|
|
}
|
|
|
|
|
|
|
|
function RawInfographicElement({
|
|
element,
|
|
width,
|
|
height,
|
|
interactive,
|
|
}: {
|
|
element: RawElement;
|
|
width: number;
|
|
height: number;
|
|
interactive: boolean;
|
|
}) {
|
|
const infographicType =
|
|
readString(element.infographic_type) ??
|
|
readString(element.infographicType) ??
|
|
"gauge";
|
|
const progress = valueProgress(element);
|
|
const baseColor =
|
|
withHash(readString(element.base_color) ?? readString(element.baseColor)) ??
|
|
"#E5E7EB";
|
|
const highlightColor =
|
|
withHash(
|
|
readString(element.highlight_color) ?? readString(element.highlightColor),
|
|
) ?? "#2563EB";
|
|
|
|
if (infographicType === "progress_bar") {
|
|
const radius = Math.min(height / 2, 8);
|
|
return (
|
|
<Group listening={interactive} {...shadowProps(element)}>
|
|
<Rect width={width} height={height} cornerRadius={radius} fill={baseColor} />
|
|
<Rect
|
|
width={width * progress}
|
|
height={height}
|
|
cornerRadius={radius}
|
|
fill={highlightColor}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const valueAngle = 180 * progress;
|
|
const thickness = Math.max(6, Math.min(width, height) * 0.18);
|
|
const outerRadius = Math.max(1, Math.min(width * 0.43, height * 0.86));
|
|
const innerRadius = Math.max(1, outerRadius - thickness);
|
|
const middleRadius = (outerRadius + innerRadius) / 2;
|
|
const capRadius = thickness / 2;
|
|
const centerX = width / 2;
|
|
const centerY = Math.min(height - capRadius, height * 0.86);
|
|
const start = pointOnCircle(centerX, centerY, middleRadius, 180);
|
|
const end = pointOnCircle(centerX, centerY, middleRadius, 180 + valueAngle);
|
|
return (
|
|
<Group listening={interactive} {...shadowProps(element)}>
|
|
<Arc
|
|
x={centerX}
|
|
y={centerY}
|
|
innerRadius={innerRadius}
|
|
outerRadius={outerRadius}
|
|
angle={180}
|
|
rotation={180}
|
|
fill={baseColor}
|
|
/>
|
|
<Circle x={start.x} y={start.y} radius={capRadius} fill={baseColor} />
|
|
<Circle
|
|
x={pointOnCircle(centerX, centerY, middleRadius, 360).x}
|
|
y={pointOnCircle(centerX, centerY, middleRadius, 360).y}
|
|
radius={capRadius}
|
|
fill={baseColor}
|
|
/>
|
|
{valueAngle > 0 ? (
|
|
<>
|
|
<Arc
|
|
x={centerX}
|
|
y={centerY}
|
|
innerRadius={innerRadius}
|
|
outerRadius={outerRadius}
|
|
angle={valueAngle}
|
|
rotation={180}
|
|
fill={highlightColor}
|
|
/>
|
|
<Circle x={start.x} y={start.y} radius={capRadius} fill={highlightColor} />
|
|
<Circle x={end.x} y={end.y} radius={capRadius} fill={highlightColor} />
|
|
</>
|
|
) : null}
|
|
<Text
|
|
x={0}
|
|
y={height * 0.5}
|
|
width={width}
|
|
height={height * 0.3}
|
|
text={String(Math.round(readNumber(element.value) ?? 0))}
|
|
fontFamily="Arial, Helvetica, sans-serif"
|
|
fontSize={Math.max(10, Math.min(width, height) * 0.22)}
|
|
fontStyle="bold"
|
|
align="center"
|
|
verticalAlign="middle"
|
|
fill="#172033"
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|