presenton--presenton
2077 行
66 KiB
TypeScript
2077 行
66 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
memo,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent as ReactChangeEvent,
|
|
} from "react";
|
|
import type Konva from "konva";
|
|
import { useDispatch } from "react-redux";
|
|
import { Loader2 } from "lucide-react";
|
|
import { Layer, Rect, Stage } from "react-konva";
|
|
import { notify } from "@/components/ui/sonner";
|
|
import type { TemplateV2Layout } from "@/components/slide-editor/importing/template-v2-import";
|
|
import {
|
|
templateFontOptionsFromMap,
|
|
} from "@/components/slide-editor/text/google-fonts";
|
|
import {
|
|
canUngroupTemplateV2Component,
|
|
ungroupTemplateV2ComponentInUi,
|
|
} from "@/components/slide-editor/model/template-v2-ungroup";
|
|
import { textRunsContent } from "@/components/slide-editor/text/text-runs";
|
|
|
|
import {
|
|
normalizeRawTextMarkdownElement,
|
|
rawTextContent,
|
|
rawTextListRunsForEditor,
|
|
rawTextRunsForEditor,
|
|
rawTextStyle,
|
|
setRawTextListRunsContent,
|
|
setRawTextRunsContent,
|
|
} from "@/components/slide-editor/text/template-v2-text";
|
|
import type {
|
|
SlideElement,
|
|
TextRun,
|
|
} from "@/components/slide-editor/types";
|
|
import {
|
|
useTableCellSelection,
|
|
useTemplateV2InlineEditing,
|
|
type ChartSlideElement,
|
|
type TableSlideElement,
|
|
} from "@/components/slide-editor/state/state";
|
|
import { ElementToolbar } from "@/components/slide-editor/toolbar/ElementToolbar";
|
|
import { ChartDataEditorPopover } from "@/components/slide-editor/charts/ChartEditorContent";
|
|
import { TableInlineEditor } from "@/components/slide-editor/tables/TableInlineEditor";
|
|
import { TemplateV2InlineEditor } from "@/components/slide-editor/text/TemplateV2InlineEditor";
|
|
import {
|
|
measureWordWrappedTextRunsHeight,
|
|
type TemplateV2InlineEditBox,
|
|
type TemplateV2TextEditStyle,
|
|
wordWrappedTextRuns,
|
|
} from "@/components/slide-editor/text/template-v2-text-editing";
|
|
|
|
|
|
import { updateSlideUi } from "@/store/slices/presentationGeneration";
|
|
import { resolveBackendAssetSource } from "@/utils/api";
|
|
import { bucketFileSize, sanitizeAnalyticsError } from "@/utils/analytics";
|
|
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
|
import { ImagesApi } from "@/app/(presentation-generator)/services/api/images";
|
|
import IconsEditor from "@/components/slide-editor/images/IconsEditor";
|
|
import {
|
|
createTemplateV2ClipboardPayload,
|
|
pasteTemplateV2ClipboardPayload,
|
|
type TemplateV2ClipboardPayload,
|
|
} from "@/components/slide-editor/clipboard/clipboard";
|
|
import { useTemplateV2Clipboard } from "@/components/slide-editor/clipboard/useClipboard";
|
|
import {
|
|
isTemplateV2FlowLayoutElement,
|
|
isTemplateV2GroupElement,
|
|
isTemplateV2LayoutElement,
|
|
type TemplateV2SelectionComponentActions,
|
|
} from "@/components/slide-editor/layout/LayoutToolbar";
|
|
import { TemplateV2SelectionToolbar } from "@/components/slide-editor/selection/SelectionToolbar";
|
|
import {
|
|
getTemplateV2SelectionToolbarAnchorBox,
|
|
getTemplateV2SelectionToolbarBounds,
|
|
getTemplateV2SelectionToolbarPosition,
|
|
hasTemplateV2SelectionToolbar,
|
|
} from "@/components/slide-editor/selection/toolbarPosition";
|
|
import {
|
|
getTemplateV2SelectionChartToolbarTarget,
|
|
getTemplateV2SelectionEditorToolbarTarget,
|
|
getTemplateV2SelectionTableToolbarTarget,
|
|
getTemplateV2SelectionToolbarTarget,
|
|
} from "@/components/slide-editor/selection/toolbarTarget";
|
|
import { updateComponentLayoutElement } from "@/components/slide-editor/layout/layoutResize";
|
|
import {
|
|
reorderComponentLayer,
|
|
type ComponentLayerAction,
|
|
} from "@/components/slide-editor/selection/layering";
|
|
import { TemplateV2SelectionTransformers } from "@/components/slide-editor/selection/SelectionTransformers";
|
|
import { useFontLoadState } from "@/components/slide-editor/surface/fontLoading";
|
|
import {
|
|
MemoizedRawComponentNode,
|
|
MemoizedRawElementNode,
|
|
} from "@/components/slide-editor/surface/nodes";
|
|
import {
|
|
MAX_HISTORY_ENTRIES,
|
|
ROOT_ELEMENTS_COMPONENT_INDEX,
|
|
SCROLL_DISMISS_THRESHOLD_PX,
|
|
STAGE_BOX,
|
|
STAGE_HEIGHT,
|
|
STAGE_WIDTH,
|
|
absoluteBoxForSelection,
|
|
absoluteInlineEditBox,
|
|
appendInsertedContent,
|
|
asRecord,
|
|
backgroundColor,
|
|
childArrayInfo,
|
|
childrenBounds,
|
|
cloneJson,
|
|
componentBox,
|
|
componentForClipboardSelection,
|
|
componentIndexesForSelection,
|
|
deleteSelectionFromUi,
|
|
editorChartToRawChart,
|
|
elementBox,
|
|
elementSize,
|
|
elementWithInlineDraft,
|
|
elementWithNormalizedLayoutChildren,
|
|
eventTargetsThisSlide,
|
|
getElementAtSelection,
|
|
syncComponentHeightToElement,
|
|
isEditableTarget,
|
|
isManualPositioned,
|
|
isRecord,
|
|
isRawIconElement,
|
|
keyForSelection,
|
|
keysForSelection,
|
|
layoutChildren,
|
|
normalizeMarkdownTextInUi,
|
|
componentKey,
|
|
mergeEditorToolbarElement,
|
|
rawChartToEditorChart,
|
|
rawElementForEditorToolbar,
|
|
rawElementKey,
|
|
rawIconQuery,
|
|
readArray,
|
|
readPoint,
|
|
readString,
|
|
renderedLocalBoxForElementSelection,
|
|
rootElementsComponent,
|
|
selectionWithComponentToggle,
|
|
setComponentPositionsInUi,
|
|
surfaceSelectionTarget,
|
|
unclampedPositionFromNodeInParent,
|
|
updateComponentInUi,
|
|
updateElementInUi,
|
|
type ComponentSelection,
|
|
type ElementSelection,
|
|
type MultiComponentDragState,
|
|
type Point,
|
|
type RawComponent,
|
|
type RawElement,
|
|
type RawUi,
|
|
type SelectOptions,
|
|
type Selection,
|
|
type UnknownRecord,
|
|
} from "@/components/slide-editor/model/model";
|
|
import {
|
|
TEMPLATE_V2_ACTIVATE_SURFACE_EVENT,
|
|
TEMPLATE_V2_INSERT_ELEMENTS_EVENT,
|
|
TEMPLATE_V2_SURFACE_SELECTED_EVENT,
|
|
type TemplateV2ActivateSurfaceDetail,
|
|
type TemplateV2InsertElementsDetail,
|
|
type TemplateV2SurfaceSelectedDetail,
|
|
} from "@/components/slide-editor/events/events";
|
|
|
|
function autoSizeInlineTextFrame(
|
|
frame: TemplateV2InlineEditBox | null | undefined,
|
|
runs: TextRun[],
|
|
style: TemplateV2TextEditStyle,
|
|
) {
|
|
if (!frame) return frame;
|
|
const contentHeight = measureWordWrappedTextRunsHeight(
|
|
runs,
|
|
frame.width,
|
|
style,
|
|
);
|
|
return {
|
|
...frame,
|
|
height: Math.max(1, contentHeight),
|
|
};
|
|
}
|
|
|
|
const EDITING_SCENE_DEVICE_OVERSAMPLE = 1.5;
|
|
const MIN_EDITING_SCENE_PIXEL_RATIO = 3;
|
|
const MAX_EDITING_SCENE_PIXEL_RATIO = 4;
|
|
|
|
function syncEditingScenePixelRatio(layer: Konva.Layer | null) {
|
|
if (!layer || typeof window === "undefined") return;
|
|
const pixelRatio = Math.min(
|
|
MAX_EDITING_SCENE_PIXEL_RATIO,
|
|
Math.max(
|
|
MIN_EDITING_SCENE_PIXEL_RATIO,
|
|
(window.devicePixelRatio || 1) * EDITING_SCENE_DEVICE_OVERSAMPLE,
|
|
),
|
|
);
|
|
const canvas = layer.getCanvas();
|
|
if (Math.abs(canvas.getPixelRatio() - pixelRatio) < 0.01) return;
|
|
canvas.setPixelRatio(pixelRatio);
|
|
layer.batchDraw();
|
|
}
|
|
|
|
export {
|
|
TEMPLATE_V2_ACTIVATE_SURFACE_EVENT,
|
|
TEMPLATE_V2_INSERT_ELEMENTS_EVENT,
|
|
TEMPLATE_V2_SURFACE_SELECTED_EVENT,
|
|
type TemplateV2ActivateSurfaceDetail,
|
|
type TemplateV2InsertElementsDetail,
|
|
type TemplateV2SurfaceSelectedDetail,
|
|
} from "@/components/slide-editor/events/events";
|
|
|
|
type TemplateV2KonvaSlideProps = {
|
|
layout: TemplateV2Layout;
|
|
isEditMode: boolean;
|
|
slideId?: string | number | null;
|
|
presentationId?: string;
|
|
slideIndex: number;
|
|
renderIndex?: number;
|
|
fonts?: unknown;
|
|
};
|
|
|
|
function TemplateV2KonvaSlideComponent({
|
|
layout,
|
|
isEditMode,
|
|
slideId = null,
|
|
presentationId,
|
|
slideIndex,
|
|
renderIndex,
|
|
fonts,
|
|
}: TemplateV2KonvaSlideProps) {
|
|
const dispatch = useDispatch();
|
|
const surfaceId = useId();
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null);
|
|
const nodeRefs = useRef(new Map<string, Konva.Node>());
|
|
const contentLayerRef = useRef<Konva.Layer | null>(null);
|
|
const imageUploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
const pendingImageUploadRef = useRef<ElementSelection | null>(null);
|
|
const undoStackRef = useRef<RawUi[]>([]);
|
|
const redoStackRef = useRef<RawUi[]>([]);
|
|
const multiComponentDragRef = useRef<MultiComponentDragState | null>(null);
|
|
const [uiDraft, setUiDraft] = useState<RawUi>(() =>
|
|
normalizeMarkdownTextInUi(cloneJson(layout as RawUi)),
|
|
);
|
|
const templateFonts = useMemo(() => templateFontOptionsFromMap(fonts), [
|
|
fonts,
|
|
]);
|
|
const fontLoadState = useFontLoadState(uiDraft, templateFonts);
|
|
const currentUiRef = useRef<RawUi>(uiDraft);
|
|
const [selection, setSelection] = useState<Selection>(null);
|
|
const selectionRef = useRef<Selection>(selection);
|
|
const {
|
|
inlineEdit,
|
|
clearInlineEdit,
|
|
startInlineEdit,
|
|
updateInlineDraft,
|
|
updateInlineEdit,
|
|
updateInlineTextSelectionRange,
|
|
} = useTemplateV2InlineEditing<ElementSelection>({
|
|
keyForSelection,
|
|
});
|
|
const [iconEditorSelection, setIconEditorSelection] =
|
|
useState<ElementSelection | null>(null);
|
|
const [chartEditorSelection, setChartEditorSelection] =
|
|
useState<ElementSelection | null>(null);
|
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
|
const [, setHistoryAvailability] = useState({
|
|
canUndo: false,
|
|
canRedo: false,
|
|
});
|
|
const {
|
|
clearTableCellEditing,
|
|
clearTableCellSelection,
|
|
editingTableCell,
|
|
editTableCellSelection,
|
|
selectedTableCell,
|
|
selectTableCellSelection,
|
|
visibleSelectedTableCell,
|
|
} = useTableCellSelection<Selection, ElementSelection>({
|
|
keyForSelection,
|
|
selection,
|
|
});
|
|
const setRootNode = useCallback((node: HTMLDivElement | null) => {
|
|
rootRef.current = node;
|
|
setRootElement(node);
|
|
}, []);
|
|
|
|
const components = useMemo(
|
|
() => readArray(uiDraft.components).filter(isRecord) as RawComponent[],
|
|
[uiDraft.components],
|
|
);
|
|
const rootElements = useMemo(
|
|
() => readArray(uiDraft.elements).filter(isRecord) as RawElement[],
|
|
[uiDraft.elements],
|
|
);
|
|
const setSelectionNodeRef = useCallback(
|
|
(key: string, node: Konva.Node | null) => {
|
|
if (node) nodeRefs.current.set(key, node);
|
|
else nodeRefs.current.delete(key);
|
|
},
|
|
[],
|
|
);
|
|
const selectedComponentIndexes = useMemo(
|
|
() => componentIndexesForSelection(selection),
|
|
[selection],
|
|
);
|
|
const selectedComponentIndexesRef = useRef<number[]>(selectedComponentIndexes);
|
|
const selectedComponentIndexSet = useMemo(
|
|
() => new Set(selectedComponentIndexes),
|
|
[selectedComponentIndexes],
|
|
);
|
|
const selectedKeys = useMemo(() => keysForSelection(selection), [selection]);
|
|
const selectedKey = selectedKeys.length === 1 ? selectedKeys[0] : null;
|
|
const selectedParentComponentKey =
|
|
selection?.kind === "element" &&
|
|
selection.componentIndex !== ROOT_ELEMENTS_COMPONENT_INDEX
|
|
? keyForSelection({
|
|
kind: "component",
|
|
componentIndex: selection.componentIndex,
|
|
})
|
|
: null;
|
|
const editingKey = inlineEdit ? keyForSelection(inlineEdit.selection) : null;
|
|
const selectedElement =
|
|
selection?.kind === "element"
|
|
? getElementAtSelection(uiDraft, selection)
|
|
: null;
|
|
const selectedComponent =
|
|
selection?.kind === "component"
|
|
? asRecord(readArray(uiDraft.components)[selection.componentIndex])
|
|
: null;
|
|
const selectedBox = selection
|
|
? absoluteBoxForSelection(uiDraft, selection)
|
|
: null;
|
|
const layoutToolbarTarget = useMemo(
|
|
() =>
|
|
getTemplateV2SelectionToolbarTarget({
|
|
selection,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
absoluteBoxForSelection: (targetSelection) =>
|
|
absoluteBoxForSelection(uiDraft, targetSelection),
|
|
}),
|
|
[selectedBox, selectedComponent, selectedElement, selection, uiDraft],
|
|
);
|
|
const chartToolbarTarget = useMemo(
|
|
() =>
|
|
layoutToolbarTarget
|
|
? null
|
|
: getTemplateV2SelectionChartToolbarTarget({
|
|
selection,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
absoluteBoxForSelection: (targetSelection) =>
|
|
absoluteBoxForSelection(uiDraft, targetSelection),
|
|
}),
|
|
[
|
|
layoutToolbarTarget,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
selection,
|
|
uiDraft,
|
|
],
|
|
);
|
|
const tableToolbarTarget = useMemo(
|
|
() =>
|
|
layoutToolbarTarget || chartToolbarTarget || editingTableCell
|
|
? null
|
|
: getTemplateV2SelectionTableToolbarTarget({
|
|
selection,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
absoluteBoxForSelection: (targetSelection) =>
|
|
absoluteBoxForSelection(uiDraft, targetSelection),
|
|
}),
|
|
[
|
|
chartToolbarTarget,
|
|
editingTableCell,
|
|
layoutToolbarTarget,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
selection,
|
|
uiDraft,
|
|
],
|
|
);
|
|
const editorToolbarTarget = useMemo(
|
|
() =>
|
|
layoutToolbarTarget || chartToolbarTarget || tableToolbarTarget
|
|
? null
|
|
: getTemplateV2SelectionEditorToolbarTarget({
|
|
selection,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
absoluteBoxForSelection: (targetSelection) =>
|
|
absoluteBoxForSelection(uiDraft, targetSelection),
|
|
}),
|
|
[
|
|
chartToolbarTarget,
|
|
layoutToolbarTarget,
|
|
selectedBox,
|
|
selectedComponent,
|
|
selectedElement,
|
|
selection,
|
|
tableToolbarTarget,
|
|
uiDraft,
|
|
],
|
|
);
|
|
const toolbarElement = useMemo(
|
|
() => {
|
|
if (!selectedElement || !selectedBox) return null;
|
|
const inlineTextElement =
|
|
inlineEdit &&
|
|
inlineEdit.kind === "text" &&
|
|
inlineEdit.runs &&
|
|
selection?.kind === "element" &&
|
|
keyForSelection(inlineEdit.selection) === keyForSelection(selection)
|
|
? setRawTextRunsContent(selectedElement, inlineEdit.runs)
|
|
: inlineEdit &&
|
|
inlineEdit.kind === "text-list" &&
|
|
inlineEdit.runs &&
|
|
selection?.kind === "element" &&
|
|
keyForSelection(inlineEdit.selection) === keyForSelection(selection)
|
|
? setRawTextListRunsContent(selectedElement, inlineEdit.runs)
|
|
: selectedElement;
|
|
return rawElementForEditorToolbar(inlineTextElement, selectedBox);
|
|
},
|
|
[inlineEdit, selectedBox, selectedElement, selection],
|
|
);
|
|
const canUngroupSelectedComponent = useMemo(
|
|
() =>
|
|
selection?.kind === "component" &&
|
|
selectedComponent != null &&
|
|
canUngroupTemplateV2Component(selectedComponent),
|
|
[selectedComponent, selection],
|
|
);
|
|
const canUngroupLayoutTargetComponent = useMemo(() => {
|
|
const componentIndex = layoutToolbarTarget?.selection.componentIndex;
|
|
if (
|
|
componentIndex == null ||
|
|
componentIndex < 0 ||
|
|
!layoutToolbarTarget ||
|
|
(!isTemplateV2FlowLayoutElement(layoutToolbarTarget.element) &&
|
|
!isTemplateV2GroupElement(layoutToolbarTarget.element))
|
|
) {
|
|
return false;
|
|
}
|
|
const component = asRecord(readArray(uiDraft.components)[componentIndex]);
|
|
return canUngroupTemplateV2Component(component);
|
|
}, [layoutToolbarTarget, uiDraft.components]);
|
|
const [, setToolbarViewportVersion] = useState(0);
|
|
const hasDismissibleEditorUi = Boolean(
|
|
selection ||
|
|
inlineEdit ||
|
|
iconEditorSelection ||
|
|
chartEditorSelection ||
|
|
selectedTableCell ||
|
|
editingTableCell,
|
|
);
|
|
const floatingToolbarAnchorBox = getTemplateV2SelectionToolbarAnchorBox({
|
|
chartTarget: chartToolbarTarget,
|
|
layoutTarget: layoutToolbarTarget,
|
|
selectedBox,
|
|
selection,
|
|
tableTarget: tableToolbarTarget,
|
|
});
|
|
const hasFloatingToolbar = hasTemplateV2SelectionToolbar({
|
|
anchorBox: floatingToolbarAnchorBox,
|
|
chartTarget: chartToolbarTarget,
|
|
isEditMode,
|
|
layoutTarget: layoutToolbarTarget,
|
|
selection,
|
|
tableTarget: tableToolbarTarget,
|
|
});
|
|
const selectionToolbarPosition = getTemplateV2SelectionToolbarPosition({
|
|
anchorBox: floatingToolbarAnchorBox,
|
|
chartTarget: chartToolbarTarget,
|
|
layoutTarget: layoutToolbarTarget,
|
|
root: rootElement,
|
|
tableTarget: tableToolbarTarget,
|
|
});
|
|
const selectionToolbarBounds =
|
|
getTemplateV2SelectionToolbarBounds(rootElement);
|
|
const inlineEditBox = inlineEdit
|
|
? absoluteInlineEditBox(uiDraft, inlineEdit.selection, inlineEdit.frame)
|
|
: null;
|
|
const iconEditorElement = iconEditorSelection
|
|
? getElementAtSelection(uiDraft, iconEditorSelection)
|
|
: null;
|
|
const chartEditorElement = chartEditorSelection
|
|
? getElementAtSelection(uiDraft, chartEditorSelection)
|
|
: null;
|
|
const surfaceSlideIndex = useMemo(() => {
|
|
const index = typeof renderIndex === "number" ? renderIndex : slideIndex;
|
|
return Number.isFinite(index) ? index : null;
|
|
}, [renderIndex, slideIndex]);
|
|
const editorAnalyticsProps = useCallback(
|
|
(props: Record<string, unknown> = {}) => ({
|
|
presentation_id: presentationId ?? null,
|
|
slide_index: surfaceSlideIndex ?? slideIndex,
|
|
...props,
|
|
}),
|
|
[presentationId, slideIndex, surfaceSlideIndex],
|
|
);
|
|
const selectedSurfaceTarget = useMemo(
|
|
() => surfaceSelectionTarget(uiDraft, selection, surfaceSlideIndex),
|
|
[selection, surfaceSlideIndex, uiDraft],
|
|
);
|
|
const toolbarSelectedTableCell =
|
|
tableToolbarTarget &&
|
|
selectedTableCell?.elementPath ===
|
|
keyForSelection(tableToolbarTarget.selection)
|
|
? selectedTableCell
|
|
: null;
|
|
useEffect(() => {
|
|
selectionRef.current = selection;
|
|
}, [selection]);
|
|
|
|
useEffect(() => {
|
|
if (!fontLoadState.ready) return;
|
|
contentLayerRef.current?.batchDraw();
|
|
}, [fontLoadState.ready, fontLoadState.revision]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof window === "undefined") return;
|
|
const refreshPixelRatio = () =>
|
|
syncEditingScenePixelRatio(contentLayerRef.current);
|
|
refreshPixelRatio();
|
|
window.addEventListener("resize", refreshPixelRatio);
|
|
return () => window.removeEventListener("resize", refreshPixelRatio);
|
|
}, [isEditMode]);
|
|
|
|
useEffect(() => {
|
|
selectedComponentIndexesRef.current = selectedComponentIndexes;
|
|
}, [selectedComponentIndexes]);
|
|
|
|
useEffect(() => {
|
|
if (layout === currentUiRef.current) return;
|
|
const next = normalizeMarkdownTextInUi(cloneJson(layout as RawUi));
|
|
currentUiRef.current = next;
|
|
setUiDraft(next);
|
|
setSelection(null);
|
|
clearTableCellSelection();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
setChartEditorSelection(null);
|
|
undoStackRef.current = [];
|
|
redoStackRef.current = [];
|
|
setHistoryAvailability({ canUndo: false, canRedo: false });
|
|
}, [clearInlineEdit, clearTableCellSelection, layout]);
|
|
|
|
useEffect(() => {
|
|
if (!hasFloatingToolbar || typeof window === "undefined") return;
|
|
let frame = 0;
|
|
const refreshToolbarPosition = () => {
|
|
window.cancelAnimationFrame(frame);
|
|
frame = window.requestAnimationFrame(() => {
|
|
setToolbarViewportVersion((version) => version + 1);
|
|
});
|
|
};
|
|
window.addEventListener("resize", refreshToolbarPosition);
|
|
refreshToolbarPosition();
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
window.removeEventListener("resize", refreshToolbarPosition);
|
|
};
|
|
}, [hasFloatingToolbar]);
|
|
|
|
const isSurfaceActive = useCallback(
|
|
() =>
|
|
typeof document !== "undefined" &&
|
|
document.documentElement.dataset.templateV2KonvaActiveSurface === surfaceId,
|
|
[surfaceId],
|
|
);
|
|
|
|
const activateSurface = useCallback((nextSelection?: Selection) => {
|
|
if (
|
|
!isEditMode ||
|
|
typeof document === "undefined" ||
|
|
typeof window === "undefined"
|
|
) {
|
|
return;
|
|
}
|
|
document.documentElement.dataset.templateV2KonvaActiveSurface = surfaceId;
|
|
if (surfaceSlideIndex != null) {
|
|
document.documentElement.dataset.templateV2KonvaActiveSlideIndex =
|
|
String(surfaceSlideIndex);
|
|
}
|
|
window.dispatchEvent(
|
|
new CustomEvent<TemplateV2SurfaceSelectedDetail>(
|
|
TEMPLATE_V2_SURFACE_SELECTED_EVENT,
|
|
{
|
|
detail: {
|
|
slideId,
|
|
slideIndex: surfaceSlideIndex,
|
|
selection: surfaceSelectionTarget(
|
|
currentUiRef.current,
|
|
nextSelection === undefined ? selectionRef.current : nextSelection,
|
|
surfaceSlideIndex,
|
|
),
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}, [isEditMode, slideId, surfaceId, surfaceSlideIndex]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || !isSurfaceActive() || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
window.dispatchEvent(
|
|
new CustomEvent<TemplateV2SurfaceSelectedDetail>(
|
|
TEMPLATE_V2_SURFACE_SELECTED_EVENT,
|
|
{
|
|
detail: {
|
|
slideId,
|
|
slideIndex: surfaceSlideIndex,
|
|
selection: selectedSurfaceTarget,
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}, [
|
|
isEditMode,
|
|
isSurfaceActive,
|
|
selectedSurfaceTarget,
|
|
slideId,
|
|
surfaceSlideIndex,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof window === "undefined") return;
|
|
|
|
const handleActivateSurface = (event: Event) => {
|
|
const detail = (event as CustomEvent<TemplateV2ActivateSurfaceDetail>)
|
|
.detail;
|
|
if (
|
|
!detail ||
|
|
!eventTargetsThisSlide(detail, slideId, surfaceSlideIndex, () => false)
|
|
) {
|
|
return;
|
|
}
|
|
activateSurface();
|
|
};
|
|
|
|
window.addEventListener(
|
|
TEMPLATE_V2_ACTIVATE_SURFACE_EVENT,
|
|
handleActivateSurface,
|
|
);
|
|
return () =>
|
|
window.removeEventListener(
|
|
TEMPLATE_V2_ACTIVATE_SURFACE_EVENT,
|
|
handleActivateSurface,
|
|
);
|
|
}, [activateSurface, isEditMode, slideId, surfaceSlideIndex]);
|
|
|
|
const clearSurface = useCallback(() => {
|
|
if (typeof document === "undefined") return;
|
|
if (
|
|
document.documentElement.dataset.templateV2KonvaActiveSurface === surfaceId
|
|
) {
|
|
delete document.documentElement.dataset.templateV2KonvaActiveSurface;
|
|
delete document.documentElement.dataset.templateV2KonvaActiveSlideIndex;
|
|
}
|
|
}, [surfaceId]);
|
|
|
|
const clearEditorUiState = useCallback(
|
|
(options?: { clearActiveSurface?: boolean }) => {
|
|
multiComponentDragRef.current = null;
|
|
setSelection(null);
|
|
clearTableCellSelection();
|
|
clearTableCellEditing();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
setChartEditorSelection(null);
|
|
if (options?.clearActiveSurface) {
|
|
clearSurface();
|
|
}
|
|
},
|
|
[
|
|
clearInlineEdit,
|
|
clearSurface,
|
|
clearTableCellEditing,
|
|
clearTableCellSelection,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isEditMode) return;
|
|
clearEditorUiState({ clearActiveSurface: true });
|
|
pendingImageUploadRef.current = null;
|
|
}, [clearEditorUiState, isEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isEditMode ||
|
|
!hasDismissibleEditorUi ||
|
|
typeof document === "undefined" ||
|
|
typeof window === "undefined"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let cleared = false;
|
|
let accumulatedScrollDistance = 0;
|
|
const lastScrollPositionByTarget = new Map<EventTarget, Point>([
|
|
[
|
|
document,
|
|
{
|
|
x: window.scrollX,
|
|
y: window.scrollY,
|
|
},
|
|
],
|
|
]);
|
|
const scrollStateForTarget = (target: EventTarget | null) => {
|
|
if (
|
|
target instanceof Element &&
|
|
target !== document.documentElement &&
|
|
target !== document.body
|
|
) {
|
|
return {
|
|
key: target,
|
|
position: {
|
|
x: target.scrollLeft,
|
|
y: target.scrollTop,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: document,
|
|
position: {
|
|
x: window.scrollX,
|
|
y: window.scrollY,
|
|
},
|
|
};
|
|
};
|
|
const handleScroll = (event: Event) => {
|
|
if (cleared) return;
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (target?.closest("[data-inline-edit-ignore='true']")) return;
|
|
|
|
const { key, position } = scrollStateForTarget(event.target);
|
|
const previousPosition = lastScrollPositionByTarget.get(key);
|
|
lastScrollPositionByTarget.set(key, position);
|
|
if (!previousPosition) return;
|
|
|
|
accumulatedScrollDistance +=
|
|
Math.abs(position.x - previousPosition.x) +
|
|
Math.abs(position.y - previousPosition.y);
|
|
if (accumulatedScrollDistance < SCROLL_DISMISS_THRESHOLD_PX) return;
|
|
|
|
cleared = true;
|
|
clearEditorUiState({ clearActiveSurface: true });
|
|
};
|
|
|
|
document.addEventListener("scroll", handleScroll, true);
|
|
window.addEventListener("scroll", handleScroll, true);
|
|
return () => {
|
|
document.removeEventListener("scroll", handleScroll, true);
|
|
window.removeEventListener("scroll", handleScroll, true);
|
|
};
|
|
}, [clearEditorUiState, hasDismissibleEditorUi, isEditMode]);
|
|
|
|
const commitUi = useCallback(
|
|
(nextUi: RawUi, pushHistory = true) => {
|
|
if (!isEditMode) return;
|
|
if (nextUi === currentUiRef.current) return;
|
|
if (pushHistory) {
|
|
undoStackRef.current.push(currentUiRef.current);
|
|
if (undoStackRef.current.length > MAX_HISTORY_ENTRIES) {
|
|
undoStackRef.current.shift();
|
|
}
|
|
redoStackRef.current = [];
|
|
}
|
|
currentUiRef.current = nextUi;
|
|
setUiDraft(nextUi);
|
|
dispatch(
|
|
updateSlideUi({
|
|
index: surfaceSlideIndex ?? slideIndex,
|
|
ui: nextUi as Record<string, unknown>,
|
|
}),
|
|
);
|
|
setHistoryAvailability({
|
|
canUndo: undoStackRef.current.length > 0,
|
|
canRedo: redoStackRef.current.length > 0,
|
|
});
|
|
},
|
|
[dispatch, isEditMode, slideIndex, surfaceSlideIndex],
|
|
);
|
|
|
|
const undo = useCallback(() => {
|
|
const previous = undoStackRef.current.pop();
|
|
if (!previous) return;
|
|
redoStackRef.current.push(currentUiRef.current);
|
|
commitUi(previous, false);
|
|
}, [commitUi]);
|
|
|
|
const redo = useCallback(() => {
|
|
const next = redoStackRef.current.pop();
|
|
if (!next) return;
|
|
undoStackRef.current.push(currentUiRef.current);
|
|
commitUi(next, false);
|
|
}, [commitUi]);
|
|
|
|
const select = useCallback(
|
|
(nextSelection: Selection, options?: SelectOptions) => {
|
|
clearTableCellSelection();
|
|
const resolvedSelection = selectionWithComponentToggle(
|
|
selectionRef.current,
|
|
nextSelection,
|
|
options,
|
|
);
|
|
selectionRef.current = resolvedSelection;
|
|
setSelection(resolvedSelection);
|
|
activateSurface(resolvedSelection);
|
|
},
|
|
[activateSurface, clearTableCellSelection],
|
|
);
|
|
|
|
const selectTableCell = useCallback(
|
|
(
|
|
elementSelection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => {
|
|
activateSurface(elementSelection);
|
|
setSelection(elementSelection);
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
selectTableCellSelection(elementSelection, rowIndex, colIndex);
|
|
},
|
|
[activateSurface, clearInlineEdit, selectTableCellSelection],
|
|
);
|
|
|
|
const editTableCell = useCallback(
|
|
(
|
|
elementSelection: ElementSelection,
|
|
rowIndex: number,
|
|
colIndex: number,
|
|
) => {
|
|
activateSurface(elementSelection);
|
|
setSelection(elementSelection);
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
editTableCellSelection(elementSelection, rowIndex, colIndex);
|
|
},
|
|
[activateSurface, clearInlineEdit, editTableCellSelection],
|
|
);
|
|
|
|
const updateComponent = useCallback(
|
|
(
|
|
componentIndex: number,
|
|
updater: (component: RawComponent) => RawComponent,
|
|
pushHistory = true,
|
|
) => {
|
|
commitUi(updateComponentInUi(currentUiRef.current, componentIndex, updater), pushHistory);
|
|
},
|
|
[commitUi],
|
|
);
|
|
|
|
const handleComponentDragStart = useCallback(
|
|
(componentIndex: number, node: Konva.Node) => {
|
|
const selectedIndexes = selectedComponentIndexesRef.current;
|
|
if (
|
|
selectedIndexes.length < 2 ||
|
|
!selectedIndexes.includes(componentIndex)
|
|
) {
|
|
multiComponentDragRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const sourceComponents = readArray(currentUiRef.current.components);
|
|
const nodes = selectedIndexes.flatMap((selectedIndex) => {
|
|
const selectedNode = nodeRefs.current.get(
|
|
keyForSelection({ kind: "component", componentIndex: selectedIndex }),
|
|
);
|
|
if (!selectedNode) return [];
|
|
const nodePosition = selectedNode.position();
|
|
const component = asRecord(sourceComponents[selectedIndex]);
|
|
const modelPosition = component
|
|
? readPoint(component.position)
|
|
: nodePosition;
|
|
return [
|
|
{
|
|
componentIndex: selectedIndex,
|
|
node: selectedNode,
|
|
nodeStart: { x: nodePosition.x, y: nodePosition.y },
|
|
modelStart: { x: modelPosition.x, y: modelPosition.y },
|
|
},
|
|
];
|
|
});
|
|
const draggedNodeStart = node.position();
|
|
multiComponentDragRef.current = {
|
|
draggedComponentIndex: componentIndex,
|
|
draggedNodeStart: { x: draggedNodeStart.x, y: draggedNodeStart.y },
|
|
nodes,
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleComponentDragMove = useCallback(
|
|
(componentIndex: number, node: Konva.Node) => {
|
|
const dragState = multiComponentDragRef.current;
|
|
if (!dragState || dragState.draggedComponentIndex !== componentIndex) {
|
|
return;
|
|
}
|
|
const position = node.position();
|
|
const delta = {
|
|
x: position.x - dragState.draggedNodeStart.x,
|
|
y: position.y - dragState.draggedNodeStart.y,
|
|
};
|
|
dragState.nodes.forEach(({ node, nodeStart }) => {
|
|
node.position({
|
|
x: nodeStart.x + delta.x,
|
|
y: nodeStart.y + delta.y,
|
|
});
|
|
});
|
|
node.getLayer()?.batchDraw();
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleComponentDragEnd = useCallback(
|
|
(componentIndex: number, node: Konva.Node) => {
|
|
const dragState = multiComponentDragRef.current;
|
|
if (!dragState || dragState.draggedComponentIndex !== componentIndex) {
|
|
updateComponent(componentIndex, (current) => ({
|
|
...current,
|
|
position: unclampedPositionFromNodeInParent(
|
|
node,
|
|
STAGE_BOX,
|
|
componentBox(current),
|
|
),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
multiComponentDragRef.current = null;
|
|
const position = node.position();
|
|
const delta = {
|
|
x: position.x - dragState.draggedNodeStart.x,
|
|
y: position.y - dragState.draggedNodeStart.y,
|
|
};
|
|
if (Math.abs(delta.x) < 0.01 && Math.abs(delta.y) < 0.01) {
|
|
return;
|
|
}
|
|
commitUi(
|
|
setComponentPositionsInUi(
|
|
currentUiRef.current,
|
|
dragState.nodes.map(({ componentIndex, modelStart }) => ({
|
|
componentIndex,
|
|
position: {
|
|
x: modelStart.x + delta.x,
|
|
y: modelStart.y + delta.y,
|
|
},
|
|
})),
|
|
),
|
|
);
|
|
},
|
|
[commitUi, updateComponent],
|
|
);
|
|
|
|
const updateElement = useCallback(
|
|
(
|
|
elementSelection: ElementSelection,
|
|
updater: (element: RawElement) => RawElement,
|
|
pushHistory = true,
|
|
) => {
|
|
commitUi(updateElementInUi(currentUiRef.current, elementSelection, updater), pushHistory);
|
|
},
|
|
[commitUi],
|
|
);
|
|
|
|
const closeChartEditor = useCallback(() => {
|
|
setChartEditorSelection(null);
|
|
}, []);
|
|
|
|
const deleteComponentAtIndex = useCallback(
|
|
(componentIndex: number) => {
|
|
const components = [...readArray(currentUiRef.current.components)];
|
|
if (componentIndex < 0 || componentIndex >= components.length) return;
|
|
components.splice(componentIndex, 1);
|
|
trackEvent(MixpanelEvent.Editor_Element_Deleted, {
|
|
...editorAnalyticsProps({
|
|
target_kind: "component",
|
|
element_type: "component",
|
|
}),
|
|
});
|
|
commitUi({ ...currentUiRef.current, components });
|
|
setSelection(null);
|
|
clearTableCellSelection();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
closeChartEditor();
|
|
},
|
|
[
|
|
clearInlineEdit,
|
|
clearTableCellSelection,
|
|
closeChartEditor,
|
|
commitUi,
|
|
editorAnalyticsProps,
|
|
],
|
|
);
|
|
|
|
const deleteSelection = useCallback(() => {
|
|
if (!selection) return;
|
|
const element =
|
|
selection.kind === "element"
|
|
? getElementAtSelection(currentUiRef.current, selection)
|
|
: null;
|
|
trackEvent(MixpanelEvent.Editor_Element_Deleted, {
|
|
...editorAnalyticsProps({
|
|
target_kind: selection.kind,
|
|
element_type:
|
|
selection.kind === "element"
|
|
? readString(element?.type) || "unknown"
|
|
: "component",
|
|
}),
|
|
});
|
|
commitUi(deleteSelectionFromUi(currentUiRef.current, selection));
|
|
setSelection(null);
|
|
clearTableCellSelection();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
closeChartEditor();
|
|
}, [
|
|
clearInlineEdit,
|
|
clearTableCellSelection,
|
|
closeChartEditor,
|
|
commitUi,
|
|
editorAnalyticsProps,
|
|
selection,
|
|
]);
|
|
|
|
const createClipboardPayload = useCallback((): TemplateV2ClipboardPayload | null => {
|
|
const clipboardComponent = componentForClipboardSelection(
|
|
currentUiRef.current,
|
|
selection,
|
|
);
|
|
return clipboardComponent
|
|
? createTemplateV2ClipboardPayload(
|
|
clipboardComponent.components.map((item) => ({
|
|
data: item.component,
|
|
absoluteBox: item.box,
|
|
})),
|
|
)
|
|
: null;
|
|
}, [selection]);
|
|
|
|
const pasteClipboardPayload = useCallback(
|
|
(payload: TemplateV2ClipboardPayload, offset: number) => {
|
|
const result = pasteTemplateV2ClipboardPayload({
|
|
sourceUi: currentUiRef.current,
|
|
payload,
|
|
offset,
|
|
});
|
|
if (!result) return;
|
|
commitUi(result.ui);
|
|
setSelection(result.selection);
|
|
clearTableCellSelection();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
activateSurface(result.selection);
|
|
},
|
|
[activateSurface, clearInlineEdit, clearTableCellSelection, commitUi],
|
|
);
|
|
|
|
const duplicateComponentAtIndex = useCallback(
|
|
(componentIndex: number) => {
|
|
const clipboardComponent = componentForClipboardSelection(
|
|
currentUiRef.current,
|
|
{ kind: "component", componentIndex },
|
|
);
|
|
if (!clipboardComponent) return;
|
|
trackEvent(MixpanelEvent.Editor_Element_Duplicated, {
|
|
...editorAnalyticsProps({
|
|
target_kind: "component",
|
|
}),
|
|
});
|
|
pasteClipboardPayload(
|
|
createTemplateV2ClipboardPayload(
|
|
clipboardComponent.components.map((item) => ({
|
|
data: item.component,
|
|
absoluteBox: item.box,
|
|
})),
|
|
),
|
|
16,
|
|
);
|
|
},
|
|
[editorAnalyticsProps, pasteClipboardPayload],
|
|
);
|
|
|
|
const duplicateSelection = useCallback(() => {
|
|
const payload = createClipboardPayload();
|
|
if (!payload) return;
|
|
trackEvent(MixpanelEvent.Editor_Element_Duplicated, {
|
|
...editorAnalyticsProps({
|
|
target_kind: selection?.kind ?? "selection",
|
|
}),
|
|
});
|
|
pasteClipboardPayload(payload, 16);
|
|
}, [createClipboardPayload, editorAnalyticsProps, pasteClipboardPayload, selection]);
|
|
|
|
useTemplateV2Clipboard({
|
|
enabled: isEditMode,
|
|
isSurfaceActive,
|
|
isEditableTarget,
|
|
onCopy: createClipboardPayload,
|
|
onPaste: pasteClipboardPayload,
|
|
});
|
|
|
|
const openInlineEditor = useCallback(
|
|
(elementSelection: ElementSelection) => {
|
|
const element = getElementAtSelection(currentUiRef.current, elementSelection);
|
|
if (!element) return;
|
|
clearTableCellEditing();
|
|
const type = readString(element.type);
|
|
const frame = renderedLocalBoxForElementSelection(
|
|
currentUiRef.current,
|
|
elementSelection,
|
|
);
|
|
if (type === "text") {
|
|
const normalized = normalizeRawTextMarkdownElement(element);
|
|
if (normalized.changed) {
|
|
updateElement(elementSelection, () => normalized.element, false);
|
|
}
|
|
const style = rawTextStyle(normalized.element);
|
|
const runs = wordWrappedTextRuns(normalized.runs);
|
|
startInlineEdit({
|
|
kind: "text",
|
|
selection: elementSelection,
|
|
draft: textRunsContent(runs),
|
|
runs,
|
|
frame: autoSizeInlineTextFrame(frame, runs, style),
|
|
style,
|
|
});
|
|
} else if (type === "text-list") {
|
|
const runs = wordWrappedTextRuns(rawTextListRunsForEditor(element));
|
|
const style = rawTextStyle(element);
|
|
startInlineEdit({
|
|
kind: "text-list",
|
|
selection: elementSelection,
|
|
draft: textRunsContent(runs),
|
|
runs,
|
|
frame: autoSizeInlineTextFrame(frame, runs, style),
|
|
style,
|
|
});
|
|
}
|
|
},
|
|
[clearTableCellEditing, startInlineEdit, updateElement],
|
|
);
|
|
|
|
const closeInlineEditor = useCallback(
|
|
(commit = true, runsOverride?: TextRun[]) => {
|
|
const current = inlineEdit;
|
|
if (!current) return;
|
|
if (commit) {
|
|
const runs =
|
|
current.kind === "text" || current.kind === "text-list"
|
|
? runsOverride ?? current.runs
|
|
: current.runs;
|
|
const style =
|
|
(current.kind === "text" || current.kind === "text-list") &&
|
|
current.style
|
|
? current.style
|
|
: current.style;
|
|
const frame =
|
|
(current.kind === "text" || current.kind === "text-list") &&
|
|
style &&
|
|
runs
|
|
? autoSizeInlineTextFrame(current.frame, runs, style)
|
|
: current.frame;
|
|
const previousElement = getElementAtSelection(
|
|
currentUiRef.current,
|
|
current.selection,
|
|
);
|
|
const previousContent =
|
|
!previousElement
|
|
? ""
|
|
: current.kind === "text"
|
|
? rawTextContent(previousElement as any)
|
|
: textRunsContent(rawTextListRunsForEditor(previousElement as any));
|
|
const nextContent = runsOverride
|
|
? textRunsContent(runsOverride)
|
|
: current.draft;
|
|
commitUi(
|
|
syncComponentHeightToElement(
|
|
updateElementInUi(
|
|
currentUiRef.current,
|
|
current.selection,
|
|
(element) =>
|
|
elementWithInlineDraft(
|
|
element,
|
|
current.kind,
|
|
runsOverride
|
|
? textRunsContent(runsOverride)
|
|
: current.draft,
|
|
style,
|
|
frame,
|
|
runs,
|
|
),
|
|
),
|
|
current.selection,
|
|
),
|
|
);
|
|
if (previousContent !== nextContent) {
|
|
trackEvent(MixpanelEvent.Editor_Element_Text_Edited, {
|
|
...editorAnalyticsProps({
|
|
element_type: current.kind,
|
|
target_kind: current.selection.kind,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
setSelection(current.selection);
|
|
clearInlineEdit();
|
|
},
|
|
[clearInlineEdit, commitUi, editorAnalyticsProps, inlineEdit],
|
|
);
|
|
|
|
const commitInlineTextRuns = useCallback(
|
|
(elementSelection: ElementSelection, runs: TextRun[]) => {
|
|
updateInlineEdit(elementSelection, (active) => {
|
|
if (active.kind !== "text" && active.kind !== "text-list") {
|
|
return active;
|
|
}
|
|
const nextRuns = wordWrappedTextRuns(runs);
|
|
const style = active.style
|
|
? active.style
|
|
: undefined;
|
|
return {
|
|
...active,
|
|
draft: textRunsContent(nextRuns),
|
|
runs: nextRuns,
|
|
style,
|
|
frame:
|
|
style != null
|
|
? autoSizeInlineTextFrame(active.frame, nextRuns, style)
|
|
: active.frame,
|
|
};
|
|
});
|
|
},
|
|
[updateInlineEdit],
|
|
);
|
|
|
|
const applyToolbarElementChange = useCallback(
|
|
(editorElement: SlideElement) => {
|
|
if (selection?.kind !== "element") return;
|
|
const current = getElementAtSelection(currentUiRef.current, selection);
|
|
const box = absoluteBoxForSelection(currentUiRef.current, selection);
|
|
if (!current || !box) return;
|
|
const next = mergeEditorToolbarElement(current, editorElement, box);
|
|
updateElement(selection, () => next);
|
|
trackEvent(MixpanelEvent.Editor_Element_Style_Changed, {
|
|
...editorAnalyticsProps({
|
|
element_type: readString(current.type) || editorElement.type,
|
|
change_source: "element_toolbar",
|
|
}),
|
|
});
|
|
updateInlineEdit(selection, (active) => {
|
|
if (
|
|
!active?.style ||
|
|
keyForSelection(active.selection) !== keyForSelection(selection)
|
|
) {
|
|
return active;
|
|
}
|
|
if (active.kind === "text") {
|
|
const runs = wordWrappedTextRuns(rawTextRunsForEditor(next));
|
|
const style = rawTextStyle(next);
|
|
return {
|
|
...active,
|
|
draft: rawTextContent(next),
|
|
runs,
|
|
style,
|
|
frame: autoSizeInlineTextFrame(active.frame, runs, style),
|
|
};
|
|
}
|
|
if (active.kind === "text-list") {
|
|
const runs = wordWrappedTextRuns(rawTextListRunsForEditor(next));
|
|
const style = rawTextStyle(next);
|
|
return {
|
|
...active,
|
|
draft: textRunsContent(runs),
|
|
runs,
|
|
style,
|
|
frame: autoSizeInlineTextFrame(active.frame, runs, style),
|
|
};
|
|
}
|
|
return { ...active, style: rawTextStyle(next) };
|
|
});
|
|
},
|
|
[editorAnalyticsProps, selection, updateElement, updateInlineEdit],
|
|
);
|
|
|
|
const applyLayoutElementChange = useCallback(
|
|
(changes: Record<string, unknown>) => {
|
|
if (!layoutToolbarTarget) return;
|
|
trackEvent(MixpanelEvent.Editor_Element_Style_Changed, {
|
|
...editorAnalyticsProps({
|
|
element_type: "layout",
|
|
change_source: "layout_toolbar",
|
|
}),
|
|
});
|
|
if (
|
|
layoutToolbarTarget.selection.componentIndex ===
|
|
ROOT_ELEMENTS_COMPONENT_INDEX
|
|
) {
|
|
const updatedRoot = updateComponentLayoutElement(
|
|
rootElementsComponent(currentUiRef.current),
|
|
layoutToolbarTarget.selection.elementPath,
|
|
changes,
|
|
layoutToolbarTarget.box,
|
|
{
|
|
childrenBounds,
|
|
elementBox,
|
|
elementSize,
|
|
isManualPositioned,
|
|
normalizeLayoutChildren: elementWithNormalizedLayoutChildren,
|
|
},
|
|
);
|
|
commitUi({
|
|
...currentUiRef.current,
|
|
elements: readArray(updatedRoot.elements),
|
|
});
|
|
return;
|
|
}
|
|
updateComponent(layoutToolbarTarget.selection.componentIndex, (component) =>
|
|
updateComponentLayoutElement(
|
|
component,
|
|
layoutToolbarTarget.selection.elementPath,
|
|
changes,
|
|
layoutToolbarTarget.box,
|
|
{
|
|
childrenBounds,
|
|
elementBox,
|
|
elementSize,
|
|
isManualPositioned,
|
|
normalizeLayoutChildren: elementWithNormalizedLayoutChildren,
|
|
},
|
|
),
|
|
);
|
|
},
|
|
[commitUi, editorAnalyticsProps, layoutToolbarTarget, updateComponent],
|
|
);
|
|
|
|
const applyChartToolbarElementChange = useCallback(
|
|
(editorElement: ChartSlideElement) => {
|
|
if (!chartToolbarTarget) return;
|
|
const current = getElementAtSelection(
|
|
currentUiRef.current,
|
|
chartToolbarTarget.selection,
|
|
);
|
|
const box = absoluteBoxForSelection(
|
|
currentUiRef.current,
|
|
chartToolbarTarget.selection,
|
|
);
|
|
if (!current || !box) return;
|
|
const next = mergeEditorToolbarElement(current, editorElement, box);
|
|
updateElement(chartToolbarTarget.selection, () => next);
|
|
trackEvent(MixpanelEvent.Editor_Element_Style_Changed, {
|
|
...editorAnalyticsProps({
|
|
element_type: "chart",
|
|
change_source: "chart_toolbar",
|
|
}),
|
|
});
|
|
},
|
|
[chartToolbarTarget, editorAnalyticsProps, updateElement],
|
|
);
|
|
|
|
const applyTableToolbarElementChange = useCallback(
|
|
(editorElement: TableSlideElement) => {
|
|
if (!tableToolbarTarget) return;
|
|
const current = getElementAtSelection(
|
|
currentUiRef.current,
|
|
tableToolbarTarget.selection,
|
|
);
|
|
const box = absoluteBoxForSelection(
|
|
currentUiRef.current,
|
|
tableToolbarTarget.selection,
|
|
);
|
|
if (!current || !box) return;
|
|
const next = mergeEditorToolbarElement(current, editorElement, box);
|
|
updateElement(tableToolbarTarget.selection, () => next);
|
|
trackEvent(MixpanelEvent.Editor_Element_Style_Changed, {
|
|
...editorAnalyticsProps({
|
|
element_type: "table",
|
|
change_source: "table_toolbar",
|
|
}),
|
|
});
|
|
},
|
|
[editorAnalyticsProps, tableToolbarTarget, updateElement],
|
|
);
|
|
|
|
const applyEditorToolbarTargetElementChange = useCallback(
|
|
(editorElement: SlideElement) => {
|
|
if (!editorToolbarTarget) return;
|
|
const current = getElementAtSelection(
|
|
currentUiRef.current,
|
|
editorToolbarTarget.selection,
|
|
);
|
|
const box = absoluteBoxForSelection(
|
|
currentUiRef.current,
|
|
editorToolbarTarget.selection,
|
|
);
|
|
if (!current || !box) return;
|
|
const next = mergeEditorToolbarElement(current, editorElement, box);
|
|
updateElement(editorToolbarTarget.selection, () => next);
|
|
trackEvent(MixpanelEvent.Editor_Element_Style_Changed, {
|
|
...editorAnalyticsProps({
|
|
element_type: readString(current.type) || editorElement.type,
|
|
change_source: "component_element_toolbar",
|
|
}),
|
|
});
|
|
},
|
|
[editorAnalyticsProps, editorToolbarTarget, updateElement],
|
|
);
|
|
|
|
const ungroupComponentAtIndex = useCallback((componentIndex: number) => {
|
|
if (componentIndex < 0) return;
|
|
const component = asRecord(
|
|
readArray(currentUiRef.current.components)[componentIndex],
|
|
);
|
|
if (!canUngroupTemplateV2Component(component)) return;
|
|
const result = ungroupTemplateV2ComponentInUi(
|
|
currentUiRef.current,
|
|
componentIndex,
|
|
{
|
|
childArrayInfo,
|
|
componentBox,
|
|
elementBox,
|
|
layoutChildren,
|
|
},
|
|
);
|
|
if (!result) return;
|
|
commitUi(result.ui as RawUi);
|
|
trackEvent(MixpanelEvent.Editor_Component_Ungrouped, {
|
|
...editorAnalyticsProps(),
|
|
});
|
|
setSelection(result.selection);
|
|
clearInlineEdit();
|
|
clearTableCellSelection();
|
|
setIconEditorSelection(null);
|
|
}, [
|
|
clearInlineEdit,
|
|
clearTableCellSelection,
|
|
commitUi,
|
|
editorAnalyticsProps,
|
|
]);
|
|
|
|
const ungroupSelectedComponent = useCallback(() => {
|
|
if (selection?.kind !== "component") return;
|
|
ungroupComponentAtIndex(selection.componentIndex);
|
|
}, [selection, ungroupComponentAtIndex]);
|
|
|
|
const ungroupLayoutTargetComponent = useCallback(() => {
|
|
const componentIndex = layoutToolbarTarget?.selection.componentIndex;
|
|
if (componentIndex == null || componentIndex < 0) return;
|
|
ungroupComponentAtIndex(componentIndex);
|
|
}, [layoutToolbarTarget, ungroupComponentAtIndex]);
|
|
|
|
const reorderComponentLayerAtIndex = useCallback(
|
|
(componentIndex: number, action: ComponentLayerAction) => {
|
|
const result = reorderComponentLayer(
|
|
readArray(currentUiRef.current.components),
|
|
componentIndex,
|
|
action,
|
|
);
|
|
if (!result) return;
|
|
const nextSelection: ComponentSelection = {
|
|
kind: "component",
|
|
componentIndex: result.componentIndex,
|
|
};
|
|
commitUi({
|
|
...currentUiRef.current,
|
|
components: result.components,
|
|
});
|
|
trackEvent(MixpanelEvent.Editor_Component_Layer_Changed, {
|
|
...editorAnalyticsProps({
|
|
action,
|
|
}),
|
|
});
|
|
setSelection(nextSelection);
|
|
clearTableCellSelection();
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
activateSurface(nextSelection);
|
|
},
|
|
[
|
|
activateSurface,
|
|
clearInlineEdit,
|
|
clearTableCellSelection,
|
|
commitUi,
|
|
editorAnalyticsProps,
|
|
],
|
|
);
|
|
|
|
const reorderSelectedComponentLayer = useCallback(
|
|
(action: ComponentLayerAction) => {
|
|
if (selection?.kind !== "component") return;
|
|
reorderComponentLayerAtIndex(selection.componentIndex, action);
|
|
},
|
|
[reorderComponentLayerAtIndex, selection],
|
|
);
|
|
|
|
const targetComponentActions =
|
|
useMemo<TemplateV2SelectionComponentActions | null>(() => {
|
|
const componentIndex =
|
|
tableToolbarTarget?.selection.componentIndex ??
|
|
chartToolbarTarget?.selection.componentIndex;
|
|
if (componentIndex == null || componentIndex < 0) return null;
|
|
const component = asRecord(readArray(uiDraft.components)[componentIndex]);
|
|
return {
|
|
canUngroup: canUngroupTemplateV2Component(component),
|
|
componentCount: components.length,
|
|
componentIndex,
|
|
onDelete: () => deleteComponentAtIndex(componentIndex),
|
|
onDuplicate: () => duplicateComponentAtIndex(componentIndex),
|
|
onLayerAction: (action: ComponentLayerAction) =>
|
|
reorderComponentLayerAtIndex(componentIndex, action),
|
|
onUngroup: () => ungroupComponentAtIndex(componentIndex),
|
|
};
|
|
}, [
|
|
chartToolbarTarget,
|
|
components.length,
|
|
deleteComponentAtIndex,
|
|
duplicateComponentAtIndex,
|
|
reorderComponentLayerAtIndex,
|
|
tableToolbarTarget,
|
|
uiDraft.components,
|
|
ungroupComponentAtIndex,
|
|
]);
|
|
|
|
const openImageUpload = useCallback(
|
|
(elementSelection: ElementSelection) => {
|
|
const element = getElementAtSelection(currentUiRef.current, elementSelection);
|
|
if (readString(element?.type) !== "image") return;
|
|
activateSurface(elementSelection);
|
|
pendingImageUploadRef.current = elementSelection;
|
|
if (imageUploadInputRef.current) {
|
|
imageUploadInputRef.current.value = "";
|
|
imageUploadInputRef.current.click();
|
|
}
|
|
},
|
|
[activateSurface],
|
|
);
|
|
|
|
const openIconEditor = useCallback(
|
|
(elementSelection: ElementSelection) => {
|
|
const element = getElementAtSelection(
|
|
currentUiRef.current,
|
|
elementSelection,
|
|
);
|
|
if (!element || !isRawIconElement(element)) {
|
|
return;
|
|
}
|
|
activateSurface(elementSelection);
|
|
setSelection(elementSelection);
|
|
clearInlineEdit();
|
|
setIconEditorSelection(elementSelection);
|
|
},
|
|
[activateSurface, clearInlineEdit],
|
|
);
|
|
|
|
const handleIconChange = useCallback(
|
|
(newIconUrl: string, query?: string) => {
|
|
if (!iconEditorSelection || !newIconUrl) return;
|
|
updateElement(iconEditorSelection, (element) => ({
|
|
...element,
|
|
data: newIconUrl,
|
|
...(query ? { icon_query: query } : {}),
|
|
}));
|
|
trackEvent(MixpanelEvent.Editor_Icon_Replaced, {
|
|
...editorAnalyticsProps({
|
|
query_present: Boolean(query),
|
|
}),
|
|
});
|
|
},
|
|
[editorAnalyticsProps, iconEditorSelection, updateElement],
|
|
);
|
|
|
|
const openChartEditor = useCallback(
|
|
(elementSelection: ElementSelection) => {
|
|
const element = getElementAtSelection(currentUiRef.current, elementSelection);
|
|
if (!element || readString(element.type) !== "chart") return;
|
|
activateSurface(elementSelection);
|
|
setSelection(elementSelection);
|
|
clearInlineEdit();
|
|
setIconEditorSelection(null);
|
|
setChartEditorSelection(elementSelection);
|
|
},
|
|
[activateSurface, clearInlineEdit],
|
|
);
|
|
|
|
const handleImageUploadChange = useCallback(
|
|
async (event: ReactChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
const target = pendingImageUploadRef.current;
|
|
if (!file || !target) return;
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
trackEvent(MixpanelEvent.Editor_Image_Replace_Failed, {
|
|
...editorAnalyticsProps({
|
|
error_message: "Invalid image file type",
|
|
}),
|
|
});
|
|
notify.warning("Invalid file", "Please upload an image file.");
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
trackEvent(MixpanelEvent.Editor_Image_Replace_Failed, {
|
|
...editorAnalyticsProps({
|
|
file_size_bucket: bucketFileSize(file.size),
|
|
error_message: "Image file too large",
|
|
}),
|
|
});
|
|
notify.warning("File too large", "Image files must be smaller than 5MB.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsUploadingImage(true);
|
|
const uploaded = await ImagesApi.uploadImage(file);
|
|
const imageUrl = resolveBackendAssetSource(uploaded);
|
|
if (!imageUrl) throw new Error("Upload did not return an image URL.");
|
|
updateElement(target, (element) => ({
|
|
...element,
|
|
data: imageUrl,
|
|
name: element.name ?? file.name,
|
|
}));
|
|
trackEvent(MixpanelEvent.Editor_Image_Replaced, {
|
|
...editorAnalyticsProps({
|
|
file_size_bucket: bucketFileSize(file.size),
|
|
}),
|
|
});
|
|
notify.success("Image updated", "The selected image was replaced.");
|
|
} catch (error) {
|
|
trackEvent(MixpanelEvent.Editor_Image_Replace_Failed, {
|
|
...editorAnalyticsProps({
|
|
error_message: sanitizeAnalyticsError(
|
|
error,
|
|
"Failed to upload image"
|
|
),
|
|
}),
|
|
});
|
|
notify.error(
|
|
"Upload failed",
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to upload image. Please try again.",
|
|
);
|
|
} finally {
|
|
pendingImageUploadRef.current = null;
|
|
setIsUploadingImage(false);
|
|
}
|
|
},
|
|
[editorAnalyticsProps, updateElement],
|
|
);
|
|
|
|
const handleElementDoubleClick = useCallback(
|
|
(elementSelection: ElementSelection) => {
|
|
const element = getElementAtSelection(currentUiRef.current, elementSelection);
|
|
const type = readString(element?.type);
|
|
if (type === "image") {
|
|
if (element && isRawIconElement(element)) {
|
|
openIconEditor(elementSelection);
|
|
}
|
|
return;
|
|
}
|
|
if (type === "chart") {
|
|
openChartEditor(elementSelection);
|
|
return;
|
|
}
|
|
openInlineEditor(elementSelection);
|
|
},
|
|
[openChartEditor, openIconEditor, openInlineEditor],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof window === "undefined") return;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.isComposing ||
|
|
(event.key !== "Delete" && event.key !== "Backspace") ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.altKey ||
|
|
isEditableTarget(event.target)
|
|
) {
|
|
return;
|
|
}
|
|
if (!selection) return;
|
|
event.preventDefault();
|
|
deleteSelection();
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [deleteSelection, isEditMode, selection]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof window === "undefined") return;
|
|
|
|
const handleInsertElements = (event: Event) => {
|
|
const detail = (event as CustomEvent<TemplateV2InsertElementsDetail>).detail;
|
|
const elements = detail?.elements ?? [];
|
|
const insertedComponents = detail?.components ?? [];
|
|
if (elements.length === 0 && insertedComponents.length === 0) return;
|
|
if (!eventTargetsThisSlide(detail, slideId, surfaceSlideIndex, isSurfaceActive)) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = readArray(currentUiRef.current.components).length;
|
|
const nextUi = appendInsertedContent(
|
|
currentUiRef.current,
|
|
elements as unknown as UnknownRecord[],
|
|
insertedComponents as unknown as UnknownRecord[],
|
|
detail.label,
|
|
);
|
|
commitUi(nextUi);
|
|
setSelection({
|
|
kind: "component",
|
|
componentIndex: Math.max(0, nextIndex),
|
|
});
|
|
detail.handled = true;
|
|
};
|
|
|
|
window.addEventListener(TEMPLATE_V2_INSERT_ELEMENTS_EVENT, handleInsertElements);
|
|
return () =>
|
|
window.removeEventListener(
|
|
TEMPLATE_V2_INSERT_ELEMENTS_EVENT,
|
|
handleInsertElements,
|
|
);
|
|
}, [commitUi, isEditMode, isSurfaceActive, slideId, surfaceSlideIndex]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof document === "undefined") return;
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const root = rootRef.current;
|
|
const targetNode = event.target instanceof Node ? event.target : null;
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (
|
|
target?.closest(
|
|
"[data-template-v2-floating-toolbar='true'], [data-inline-edit-ignore='true']",
|
|
)
|
|
) {
|
|
if (isSurfaceActive()) {
|
|
activateSurface();
|
|
}
|
|
return;
|
|
}
|
|
if (targetNode && root?.contains(targetNode)) {
|
|
activateSurface();
|
|
return;
|
|
}
|
|
|
|
clearEditorUiState({ clearActiveSurface: true });
|
|
};
|
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
clearSurface();
|
|
};
|
|
}, [
|
|
activateSurface,
|
|
clearEditorUiState,
|
|
clearSurface,
|
|
isEditMode,
|
|
isSurfaceActive,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || typeof document === "undefined") return;
|
|
|
|
const handleUndoRedoShortcut = (event: KeyboardEvent) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.isComposing ||
|
|
!isSurfaceActive() ||
|
|
isEditableTarget(event.target) ||
|
|
!(event.metaKey || event.ctrlKey) ||
|
|
event.altKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key.toLowerCase();
|
|
const wantsUndo = key === "z" && !event.shiftKey;
|
|
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
|
|
if (!wantsUndo && !wantsRedo) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.stopImmediatePropagation();
|
|
|
|
if (wantsUndo) {
|
|
undo();
|
|
return;
|
|
}
|
|
redo();
|
|
};
|
|
|
|
document.addEventListener("keydown", handleUndoRedoShortcut, true);
|
|
return () =>
|
|
document.removeEventListener("keydown", handleUndoRedoShortcut, true);
|
|
}, [isEditMode, isSurfaceActive, redo, undo]);
|
|
|
|
if (!uiDraft) {
|
|
return (
|
|
<div className="flex h-full aspect-video flex-col items-center justify-center rounded-lg bg-gray-100">
|
|
<Loader2 className="mb-2 h-4 w-4 animate-spin" />
|
|
<p className="text-center text-sm text-gray-600">Loading slide layout...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={setRootNode}
|
|
data-template-v2-konva-surface={surfaceId}
|
|
className="relative h-full w-full overflow-hidden bg-white"
|
|
style={{ width: STAGE_WIDTH, height: STAGE_HEIGHT }}
|
|
onPointerDown={() => activateSurface()}
|
|
>
|
|
{isEditMode ? (
|
|
<input
|
|
ref={imageUploadInputRef}
|
|
accept="image/*"
|
|
className="hidden"
|
|
type="file"
|
|
onChange={handleImageUploadChange}
|
|
/>
|
|
) : null}
|
|
<Stage
|
|
width={STAGE_WIDTH}
|
|
height={STAGE_HEIGHT}
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.target.getStage()) {
|
|
activateSurface(null);
|
|
clearEditorUiState();
|
|
return;
|
|
}
|
|
activateSurface();
|
|
}}
|
|
onTouchStart={(event) => {
|
|
if (event.target === event.target.getStage()) {
|
|
activateSurface(null);
|
|
clearEditorUiState();
|
|
return;
|
|
}
|
|
activateSurface();
|
|
}}
|
|
>
|
|
<Layer listening={false}>
|
|
<Rect width={STAGE_WIDTH} height={STAGE_HEIGHT} fill={backgroundColor(uiDraft)} />
|
|
</Layer>
|
|
<Layer ref={contentLayerRef} listening={isEditMode}>
|
|
{rootElements.map((element, elementIndex) => (
|
|
<MemoizedRawElementNode
|
|
key={`root:${rawElementKey(element, elementIndex)}`}
|
|
element={element}
|
|
componentIndex={ROOT_ELEMENTS_COMPONENT_INDEX}
|
|
elementPath={[elementIndex]}
|
|
isEditMode={isEditMode}
|
|
editingKey={editingKey}
|
|
selectedTableCell={visibleSelectedTableCell}
|
|
setNodeRef={setSelectionNodeRef}
|
|
onSelect={select}
|
|
onTableCellSelect={selectTableCell}
|
|
onTableCellEdit={editTableCell}
|
|
onOpenEditor={handleElementDoubleClick}
|
|
onElementChange={updateElement}
|
|
parentBox={STAGE_BOX}
|
|
layoutManaged={false}
|
|
fontRevision={fontLoadState.revision}
|
|
/>
|
|
))}
|
|
{components.map((component, componentIndex) => (
|
|
<MemoizedRawComponentNode
|
|
key={componentKey(component, componentIndex)}
|
|
component={component}
|
|
componentIndex={componentIndex}
|
|
isEditMode={isEditMode}
|
|
isMultiSelectedComponent={
|
|
selectedComponentIndexes.length > 1 &&
|
|
selectedComponentIndexSet.has(componentIndex)
|
|
}
|
|
editingKey={editingKey}
|
|
selectedTableCell={visibleSelectedTableCell}
|
|
setNodeRef={setSelectionNodeRef}
|
|
onSelect={select}
|
|
onTableCellSelect={selectTableCell}
|
|
onTableCellEdit={editTableCell}
|
|
onOpenElementEditor={handleElementDoubleClick}
|
|
onComponentChange={updateComponent}
|
|
onComponentDragStart={handleComponentDragStart}
|
|
onComponentDragMove={handleComponentDragMove}
|
|
onComponentDragEnd={handleComponentDragEnd}
|
|
onElementChange={updateElement}
|
|
fontRevision={fontLoadState.revision}
|
|
/>
|
|
))}
|
|
{isEditMode ? (
|
|
<TemplateV2SelectionTransformers
|
|
nodeRefs={nodeRefs}
|
|
parentComponentKey={inlineEdit ? null : selectedParentComponentKey}
|
|
selectedKey={selectedKey}
|
|
selectedKeys={selectedKeys}
|
|
selectionKind={selection?.kind ?? null}
|
|
horizontalResizeOnly={editorToolbarTarget?.element.type === "line"}
|
|
suppressSelectedOutline={Boolean(
|
|
selectedTableCell ||
|
|
inlineEdit ||
|
|
readString(selectedElement?.type) === "chart",
|
|
)}
|
|
/>
|
|
) : null}
|
|
</Layer>
|
|
</Stage>
|
|
<TemplateV2SelectionToolbar
|
|
anchorBox={floatingToolbarAnchorBox}
|
|
canUngroupComponent={canUngroupSelectedComponent}
|
|
canUngroupLayoutTarget={canUngroupLayoutTargetComponent}
|
|
chartTarget={chartToolbarTarget}
|
|
componentCount={components.length}
|
|
editorTarget={editorToolbarTarget}
|
|
isEditMode={isEditMode}
|
|
layoutTarget={layoutToolbarTarget}
|
|
position={selectionToolbarPosition}
|
|
selectedTableCell={toolbarSelectedTableCell}
|
|
selection={selection}
|
|
selectionKey={keyForSelection(selection)}
|
|
tableTarget={tableToolbarTarget}
|
|
targetComponentActions={targetComponentActions}
|
|
templateFonts={templateFonts}
|
|
toolbarBounds={selectionToolbarBounds}
|
|
onChartChange={applyChartToolbarElementChange}
|
|
onChartEdit={() => {
|
|
if (chartToolbarTarget) {
|
|
openChartEditor(chartToolbarTarget.selection);
|
|
}
|
|
}}
|
|
onDeleteSelection={deleteSelection}
|
|
onDuplicateSelection={duplicateSelection}
|
|
onEditorChange={applyEditorToolbarTargetElementChange}
|
|
onLayoutChange={applyLayoutElementChange}
|
|
onLayerAction={reorderSelectedComponentLayer}
|
|
onTableChange={applyTableToolbarElementChange}
|
|
onUngroupComponent={ungroupSelectedComponent}
|
|
onUngroupLayoutTarget={ungroupLayoutTargetComponent}
|
|
/>
|
|
{isEditMode &&
|
|
selection?.kind === "element" &&
|
|
selectedElement &&
|
|
selectedBox &&
|
|
toolbarElement &&
|
|
!chartToolbarTarget &&
|
|
!tableToolbarTarget &&
|
|
!isTemplateV2LayoutElement(selectedElement) &&
|
|
!isTemplateV2GroupElement(selectedElement) &&
|
|
!isRawIconElement(selectedElement) &&
|
|
!(editingTableCell && readString(selectedElement.type) === "table") ? (
|
|
<ElementToolbar
|
|
element={toolbarElement}
|
|
index={selection.componentIndex}
|
|
anchorBox={selectedBox}
|
|
path={keyForSelection(selection)}
|
|
scale={1}
|
|
selectedTableCell={selectedTableCell}
|
|
templateFonts={templateFonts}
|
|
textSelectionRange={
|
|
inlineEdit &&
|
|
(inlineEdit.kind === "text" || inlineEdit.kind === "text-list") &&
|
|
keyForSelection(inlineEdit.selection) === keyForSelection(selection)
|
|
? inlineEdit.textSelectionRange
|
|
: null
|
|
}
|
|
onChange={(_index, element) => applyToolbarElementChange(element)}
|
|
onEditImage={() => openImageUpload(selection)}
|
|
onEditText={() => openInlineEditor(selection)}
|
|
/>
|
|
) : null}
|
|
{isEditMode &&
|
|
selection?.kind === "element" &&
|
|
editingTableCell &&
|
|
toolbarElement &&
|
|
readString((toolbarElement as UnknownRecord).type) === "table" ? (
|
|
<TableInlineEditor
|
|
key={`${keyForSelection(selection)}:${editingTableCell.rowIndex}:${editingTableCell.colIndex}`}
|
|
element={toolbarElement as TableSlideElement}
|
|
index={selection.componentIndex}
|
|
scale={1}
|
|
selectedCell={editingTableCell}
|
|
templateFonts={templateFonts}
|
|
onChange={(_index, element) => applyToolbarElementChange(element)}
|
|
onClose={clearTableCellEditing}
|
|
/>
|
|
) : null}
|
|
{isEditMode && inlineEdit && inlineEditBox ? (
|
|
<TemplateV2InlineEditor
|
|
key={keyForSelection(inlineEdit.selection)}
|
|
draft={inlineEdit.draft}
|
|
kind={inlineEdit.kind}
|
|
box={inlineEditBox}
|
|
runs={inlineEdit.runs}
|
|
style={inlineEdit.style}
|
|
onChange={updateInlineDraft}
|
|
onSelectionChange={(textSelectionRange) =>
|
|
updateInlineTextSelectionRange(
|
|
inlineEdit.selection,
|
|
textSelectionRange,
|
|
)
|
|
}
|
|
onRunsChange={(runs) =>
|
|
commitInlineTextRuns(inlineEdit.selection, runs)
|
|
}
|
|
onClose={(commit, runs) => closeInlineEditor(commit, runs)}
|
|
/>
|
|
) : null}
|
|
{isEditMode &&
|
|
chartEditorSelection &&
|
|
chartEditorElement &&
|
|
readString(chartEditorElement.type) === "chart" ? (
|
|
<ChartDataEditorPopover
|
|
key={keyForSelection(chartEditorSelection)}
|
|
chart={rawChartToEditorChart(chartEditorElement)}
|
|
chartPath={keyForSelection(chartEditorSelection)}
|
|
onChange={(chart) =>
|
|
updateElement(chartEditorSelection, (element) =>
|
|
editorChartToRawChart(
|
|
element,
|
|
chart as unknown as UnknownRecord,
|
|
),
|
|
)
|
|
}
|
|
onClose={closeChartEditor}
|
|
/>
|
|
) : null}
|
|
{isEditMode &&
|
|
iconEditorSelection &&
|
|
iconEditorElement &&
|
|
isRawIconElement(iconEditorElement) ? (
|
|
<IconsEditor
|
|
key={keyForSelection(iconEditorSelection)}
|
|
icon_prompt={[rawIconQuery(iconEditorElement)]}
|
|
currentIconUrl={readString(iconEditorElement.data) ?? ""}
|
|
onClose={() => setIconEditorSelection(null)}
|
|
onIconChange={handleIconChange}
|
|
/>
|
|
) : null}
|
|
{isUploadingImage ? (
|
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-white/35">
|
|
<div className="flex items-center gap-2 rounded-full bg-white px-3 py-2 text-xs font-medium text-[#191919] shadow-md">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Uploading image...
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const TemplateV2KonvaSlide = memo(TemplateV2KonvaSlideComponent);
|
|
TemplateV2KonvaSlide.displayName = "TemplateV2KonvaSlide";
|