fix: auto show and hide editor action bar

This commit is contained in:
David Nguyen
2026-09-26 16:33:03 +10:00
parent 30a0979573
commit 941f4a1e4c
10 changed files with 68 additions and 33 deletions
@@ -37,12 +37,6 @@ export type EnvelopeCanvasSelectionKind = 'field' | 'content';
export type EnvelopeCanvasSelection = {
kind: EnvelopeCanvasSelectionKind;
groups: Konva.Group[];
/**
* Whether the selection was made programmatically (e.g. on creation) rather
* than by the user.
*/
isAuto: boolean;
} | null;
/**
@@ -65,13 +65,8 @@ type SyncEditorSelectionToCanvasOptions = {
*/
selectedGroups: Konva.Group[];
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[], options?: { isAuto?: boolean }) => void;
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
clear: () => void;
/**
* Whether a selection made by this sync counts as automatic.
*/
isAuto: boolean;
};
/**
@@ -92,7 +87,6 @@ export const syncEditorSelectionToCanvas = ({
selectedGroups,
select,
clear,
isAuto,
}: SyncEditorSelectionToCanvasOptions) => {
const isSingleSelection = selectedGroups.length === 1;
@@ -106,7 +100,7 @@ export const syncEditorSelectionToCanvas = ({
const groupToSelect = layer.findOne(`#${editorFormId}`);
if (groupToSelect instanceof Konva.Group) {
select(kind, [groupToSelect], { isAuto });
select(kind, [groupToSelect]);
}
return;
@@ -345,7 +345,6 @@ export const useEnvelopeCanvasContentsLayer = ({
selectedGroups,
select: selection.select,
clear: selection.clear,
isAuto: false,
});
}
@@ -376,8 +376,6 @@ export const useEnvelopeCanvasFieldsLayer = ({
selection.select('field', liveSelectedGroups);
}
// Newly created fields are auto selected, which suppresses the action bar
// so it can't intercept the next placement click.
syncEditorSelectionToCanvas({
layer,
kind: 'field',
@@ -386,7 +384,6 @@ export const useEnvelopeCanvasFieldsLayer = ({
selectedGroups,
select: selection.select,
clear: selection.clear,
isAuto: true,
});
applyPageItemsVisibility();
@@ -21,10 +21,6 @@ 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 SelectOptions = {
isAuto?: boolean;
};
type UseEnvelopeCanvasSelectionOptions = {
/**
* Resolve the transformer configuration for a selection.
@@ -106,7 +102,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
}, []);
const select = useCallback(
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[], options?: SelectOptions) => {
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
const groups = nodes.filter(
@@ -117,7 +113,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
Boolean(node.getParent()),
);
applySelection(groups.length > 0 ? { kind, groups, isAuto: Boolean(options?.isAuto) } : null);
applySelection(groups.length > 0 ? { kind, groups } : null);
},
[applySelection],
);
@@ -169,7 +165,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
.filter((group) => selectedIds.has(group.id()));
select(currentSelection.kind, groups, { isAuto: currentSelection.isAuto });
select(currentSelection.kind, groups);
},
[select],
);
@@ -83,7 +83,7 @@ type EnvelopeEditorContentDragDropProps = {
};
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
const { envelope, editorContents } = useCurrentEnvelopeEditor();
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
@@ -96,6 +96,16 @@ export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: Envelo
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
// Let the canvas know a content is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedContent !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedContent, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
@@ -94,12 +94,22 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
const { t } = useLingui();
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
// Let the canvas know a field is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedField !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedField, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const isFieldsDisabled = useMemo(() => {
@@ -14,6 +14,7 @@ import { useLingui } from '@lingui/react/macro';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { CopyPlusIcon, TrashIcon } from 'lucide-react';
import { useEffect } from 'react';
import { match } from 'ts-pattern';
import {
EnvelopeCanvasActionBar,
@@ -54,7 +55,7 @@ const getNodeSelectionKind = (node: Konva.Node): EnvelopeCanvasSelectionKind | n
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t } = useLingui();
const { envelope, editorFields, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
const { envelope, editorFields, editorContents, selectedEditorTab, isPlacingItem } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const organisation = useCurrentOrganisation();
@@ -136,6 +137,28 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
onEmptyClick: selection.clear,
});
/**
* Picking a field or content from the palette drops the selection and any
* pending creation, so nothing floating over the page sits under the
* placement click.
*
* The action bar and pending menus are DOM elements above the canvas, so a
* placement click landing on one would press its button as well as placing
* the item, and by then the button acts on the item which was just placed
* (e.g. removing it).
*/
useEffect(() => {
if (!isPlacingItem) {
return;
}
fields.clearPending();
contents.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}, [isPlacingItem]);
/**
* Initialize the Konva page canvas and all fields, contents and interactions.
*/
@@ -234,7 +257,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{currentSelection?.kind === 'field' && (
<EnvelopeCanvasActionBar nodes={currentSelection.groups} hidden={isTransforming || currentSelection.isAuto}>
<EnvelopeCanvasActionBar nodes={currentSelection.groups} hidden={isTransforming}>
<EnvelopeCanvasFieldActionButtons
selectedFieldFormIds={currentSelection.groups.map((group) => group.id())}
onDuplicate={fields.duplicateSelected}
@@ -49,10 +49,7 @@ test('a selected field survives zooming', async ({ page }) => {
await root.getByRole('button', { name: 'Signature', exact: true }).click();
await getPageCanvas(root).click({ position: { x: 160, y: 160 } });
// A freshly placed field is auto selected, which deliberately suppresses
// the action bar, so click it to make it a real selection.
await selectContentOnCanvas(root, { x: 160, y: 160 });
// A freshly placed field is selected, with its action bar showing.
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
@@ -69,6 +69,17 @@ type EnvelopeEditorProviderValue = {
selectedEditorTab: EnvelopeEditorTab;
setSelectedEditorTab: (tab: EnvelopeEditorTab) => void;
/**
* Whether a field or content has been picked from the palette and is
* waiting to be placed on the page.
*
* The canvas drops its selection while this is set, so nothing floating
* over the page (the transformer, the action bar) sits under the
* placement click.
*/
isPlacingItem: boolean;
setIsPlacingItem: (isPlacingItem: boolean) => void;
isAutosaving: boolean;
flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean;
@@ -159,6 +170,8 @@ export const EnvelopeEditorProvider = ({
const [selectedEditorTab, setSelectedEditorTab] = useState<EnvelopeEditorTab>('fields');
const [isPlacingItem, setIsPlacingItem] = useState(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
/**
@@ -655,6 +668,8 @@ export const EnvelopeEditorProvider = ({
editorRecipients,
selectedEditorTab,
setSelectedEditorTab,
isPlacingItem,
setIsPlacingItem,
autosaveError,
flushAutosave,
isAutosaving,