mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
|
|
import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer';
|
|
import {
|
|
boundTransformerBoxToPage,
|
|
DEFAULT_TRANSFORMER_SELECTION_CONFIG,
|
|
} from '@documenso/lib/universal/konva/transformer';
|
|
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
|
import Konva from 'konva';
|
|
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
|
|
import type { EnvelopeCanvasSelection, EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
|
|
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
|
|
|
|
/**
|
|
* A stable empty array so consumers of a kind which is not selected keep a
|
|
* stable dependency and don't re-render on every selection change.
|
|
*/
|
|
const EMPTY_GROUPS: Konva.Group[] = [];
|
|
|
|
/** How far past a resize handle you can still grab it, in screen pixels. */
|
|
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
|
|
|
|
type UseEnvelopeCanvasSelectionOptions = {
|
|
/**
|
|
* Resolve the transformer configuration for a selection.
|
|
*/
|
|
getTransformerConfig: (kind: EnvelopeCanvasSelectionKind, groups: Konva.Group[]) => TransformerSelectionConfig;
|
|
|
|
/**
|
|
* Called synchronously whenever the selection changes, so the editor
|
|
* selection can be kept in sync without a frame of drift.
|
|
*/
|
|
onChange: (selection: EnvelopeCanvasSelection) => void;
|
|
};
|
|
|
|
/**
|
|
* The single canvas selection shared by fields and contents, along with the
|
|
* Konva transformer which acts upon it.
|
|
*
|
|
* The exposed functions are stable and read their options through refs, so
|
|
* they are safe to call from Konva handlers bound once at stage creation.
|
|
*/
|
|
export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: UseEnvelopeCanvasSelectionOptions) => {
|
|
const transformerRef = useRef<Transformer | null>(null);
|
|
|
|
const [selection, setSelectionState] = useState<EnvelopeCanvasSelection>(null);
|
|
|
|
/**
|
|
* Whether a selected item is being dragged, resized or rotated.
|
|
*/
|
|
const [isTransforming, setIsTransforming] = useState(false);
|
|
|
|
const getTransformerConfigRef = useLatestRef(getTransformerConfig);
|
|
const onChangeRef = useLatestRef(onChange);
|
|
|
|
const applyTransformerConfig = useCallback((transformer: Transformer, currentSelection: EnvelopeCanvasSelection) => {
|
|
const config = currentSelection
|
|
? getTransformerConfigRef.current(currentSelection.kind, currentSelection.groups)
|
|
: DEFAULT_TRANSFORMER_SELECTION_CONFIG;
|
|
|
|
transformer.enabledAnchors(config.enabledAnchors);
|
|
transformer.rotateEnabled(config.rotateEnabled);
|
|
transformer.keepRatio(config.keepRatio);
|
|
transformer.borderEnabled(config.borderEnabled);
|
|
}, []);
|
|
|
|
const selectionRef = useLatestRef(selection);
|
|
|
|
/**
|
|
* Re-sync the transformer after the items change without the selection
|
|
* changing: re-resolve its configuration (e.g. an image attached to the
|
|
* selected content locks its ratio) and raise it back above the items.
|
|
*
|
|
* The handles straddle the selected item's edges, so any item stacked above
|
|
* the transformer hides part of them and takes their clicks. Rendering
|
|
* appends new items on top and the contents stacking raises every content,
|
|
* so each reconcile calls this afterwards.
|
|
*/
|
|
const refreshTransformer = useCallback(() => {
|
|
const transformer = transformerRef.current;
|
|
|
|
if (!transformer) {
|
|
return;
|
|
}
|
|
|
|
applyTransformerConfig(transformer, selectionRef.current);
|
|
transformer.moveToTop();
|
|
transformer.forceUpdate();
|
|
}, [applyTransformerConfig]);
|
|
|
|
const applySelection = useCallback((nextSelection: EnvelopeCanvasSelection) => {
|
|
const transformer = transformerRef.current;
|
|
|
|
if (transformer) {
|
|
// Configure the transformer before assigning nodes, since assigning
|
|
// nodes triggers an update using the current configuration.
|
|
applyTransformerConfig(transformer, nextSelection);
|
|
transformer.nodes(nextSelection?.groups ?? []);
|
|
}
|
|
|
|
if (nextSelection?.groups.length === 1) {
|
|
nextSelection.groups[0].moveToTop();
|
|
}
|
|
|
|
// Above the item just raised, so its handles stay visible and clickable.
|
|
transformer?.moveToTop();
|
|
|
|
setSelectionState(nextSelection);
|
|
onChangeRef.current(nextSelection);
|
|
}, []);
|
|
|
|
const select = useCallback(
|
|
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
|
|
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
|
|
|
|
const groups = nodes.filter(
|
|
(node): node is Konva.Group =>
|
|
node instanceof Konva.Group &&
|
|
node.hasName(groupName) &&
|
|
Boolean(node.getStage()) &&
|
|
Boolean(node.getParent()),
|
|
);
|
|
|
|
applySelection(groups.length > 0 ? { kind, groups } : null);
|
|
},
|
|
[applySelection],
|
|
);
|
|
|
|
/**
|
|
* Toggle a node in or out of the current selection (shift-click semantics).
|
|
*
|
|
* Nodes of a different kind than the toggled node are dropped, since only
|
|
* one kind can be selected at a time.
|
|
*/
|
|
const toggle = useCallback(
|
|
(kind: EnvelopeCanvasSelectionKind, node: Konva.Node) => {
|
|
const currentNodes = transformerRef.current?.nodes() ?? [];
|
|
const isAlreadySelected = currentNodes.includes(node);
|
|
|
|
select(kind, isAlreadySelected ? currentNodes.filter((current) => current !== node) : [...currentNodes, node]);
|
|
},
|
|
[select],
|
|
);
|
|
|
|
const clear = useCallback(() => {
|
|
applySelection(null);
|
|
}, [applySelection]);
|
|
|
|
const isSelected = useCallback((node: Konva.Node) => {
|
|
return (transformerRef.current?.nodes() ?? []).includes(node);
|
|
}, []);
|
|
|
|
/**
|
|
* Re-resolve the selection against the groups currently on the layer.
|
|
*
|
|
* The stage is destroyed and rebuilt whenever the page is rescaled (e.g.
|
|
* zooming), which leaves the selection holding detached nodes: the handles
|
|
* disappear, the action bar positions itself from an empty rect, and the
|
|
* next reconcile drops the selection altogether. Groups keep their render
|
|
* ID across the rebuild, so the equivalent new groups are selected instead.
|
|
*/
|
|
const reattachSelection = useCallback(
|
|
(layer: Konva.Layer) => {
|
|
const currentSelection = selectionRef.current;
|
|
|
|
if (!currentSelection) {
|
|
return;
|
|
}
|
|
|
|
const selectedIds = new Set(currentSelection.groups.map((group) => group.id()));
|
|
|
|
const groups = layer
|
|
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
|
|
.filter((group) => selectedIds.has(group.id()));
|
|
|
|
select(currentSelection.kind, groups);
|
|
},
|
|
[select],
|
|
);
|
|
|
|
/**
|
|
* Create the transformer on a layer. Called once when the page canvas is
|
|
* created, and again whenever the stage is recreated.
|
|
*/
|
|
const attach = useCallback((layer: Konva.Layer) => {
|
|
// Match the brand green used for the first recipient's fields rather than
|
|
// Konva's default blue.
|
|
const selectionColor = getRecipientColorStyles('green').baseRing;
|
|
|
|
const transformer = new Konva.Transformer({
|
|
...DEFAULT_TRANSFORMER_SELECTION_CONFIG,
|
|
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
|
|
rotationSnapTolerance: 5,
|
|
keepRatio: false,
|
|
borderStroke: selectionColor,
|
|
anchorStroke: selectionColor,
|
|
ignoreStroke: true,
|
|
flipEnabled: false,
|
|
anchorStyleFunc: (anchor) => {
|
|
// The stage is scaled to the page, so the hit area is divided by that
|
|
// scale to stay a constant size on screen.
|
|
const stageScale = layer.getStage()?.scaleX() ?? 1;
|
|
|
|
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / stageScale);
|
|
},
|
|
boundBoxFunc: (oldBox, newBox) => {
|
|
// Boxes are in absolute stage coordinates and the stage is the page,
|
|
// so pin the resize to the stage's own size.
|
|
const stage = layer.getStage();
|
|
|
|
const bounded = stage
|
|
? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() })
|
|
: newBox;
|
|
|
|
// Enforce minimum size
|
|
if (bounded.width < 30 || bounded.height < 20) {
|
|
return oldBox;
|
|
}
|
|
|
|
return bounded;
|
|
},
|
|
});
|
|
|
|
layer.add(transformer);
|
|
|
|
// Konva fires transform events (resize and rotate) directly on the
|
|
// transformer and its nodes without bubbling, so they cannot be observed
|
|
// on the stage.
|
|
transformer.on('transformstart', () => setIsTransforming(true));
|
|
transformer.on('transformend', () => setIsTransforming(false));
|
|
|
|
transformerRef.current = transformer;
|
|
|
|
return transformer;
|
|
}, []);
|
|
|
|
const fieldGroups = useMemo(() => (selection?.kind === 'field' ? selection.groups : EMPTY_GROUPS), [selection]);
|
|
|
|
const contentGroups = useMemo(() => (selection?.kind === 'content' ? selection.groups : EMPTY_GROUPS), [selection]);
|
|
|
|
return {
|
|
selection,
|
|
fieldGroups,
|
|
contentGroups,
|
|
isTransforming,
|
|
setIsTransforming,
|
|
attach,
|
|
reattachSelection,
|
|
select,
|
|
toggle,
|
|
clear,
|
|
isSelected,
|
|
refreshTransformer,
|
|
};
|
|
};
|
|
|
|
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
|