175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||
|
|
import { useEditor } from '@craftjs/core';
|
||
|
|
import { useNodeActions } from '../../hooks/useNodeActions';
|
||
|
|
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Phase B mobile selection toolbar -- the on-canvas action bar that makes a
|
||
|
|
* tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
|
||
|
|
* select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
|
||
|
|
* Both share the exact same behavior via `useNodeActions` (item 1).
|
||
|
|
*
|
||
|
|
* Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
|
||
|
|
* selection from `useEditor` directly -- NOT per-node -- and positioned
|
||
|
|
* bottom-fixed just above the tab bar (simplest, robust; a node-anchored
|
||
|
|
* floating toolbar would have to constantly reposition as the canvas
|
||
|
|
* scrolls, and risks being clipped at the viewport edge for a node near the
|
||
|
|
* top or bottom of a tall page). This is a deliberate design call: see the
|
||
|
|
* report for the alternative (floating over the node) that was passed over.
|
||
|
|
*
|
||
|
|
* Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
|
||
|
|
* sheet supersedes it, and the two must never visually stack.
|
||
|
|
*
|
||
|
|
* Delete is a two-tap in-app confirm (tap once -> the button becomes
|
||
|
|
* "Confirm?" for a few seconds; tap again within that window commits the
|
||
|
|
* delete; selecting something else cancels it) rather than a native
|
||
|
|
* `confirm()`, matching the project's no-native-dialogs rule.
|
||
|
|
*/
|
||
|
|
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
|
||
|
|
|
||
|
|
export const MobileSelectionToolbar: React.FC = () => {
|
||
|
|
const { activeSheet, openSheet } = useMobileChrome();
|
||
|
|
const { selectedId } = useEditor((state) => {
|
||
|
|
const selected = state.events.selected;
|
||
|
|
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
|
||
|
|
return { selectedId: id && id !== 'ROOT' ? id : null };
|
||
|
|
});
|
||
|
|
|
||
|
|
const nodeActions = useNodeActions(selectedId);
|
||
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||
|
|
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
|
|
||
|
|
// `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
|
||
|
|
// from a live (never-cached) Craft.js query -- see that hook's doc comment
|
||
|
|
// for why it deliberately does NOT hold its own store subscription to force
|
||
|
|
// a refresh. Moving/duplicating/deleting via THIS toolbar changes the
|
||
|
|
// selected node's position/siblings WITHOUT changing `selectedId` itself
|
||
|
|
// (the node stays selected), so nothing else would otherwise trigger a
|
||
|
|
// re-render of this component to pick up the new boundary flags after,
|
||
|
|
// say, tapping "Move Up" until the node reaches the top. Bumping a plain
|
||
|
|
// local tick from these buttons' own click handlers -- an ordinary,
|
||
|
|
// locally-owned re-render -- covers exactly that, without the broader
|
||
|
|
// store-wide subscription that was found to trip React's "setState while
|
||
|
|
// rendering a different component" warning whenever some unrelated
|
||
|
|
// container mounts (see useNodeActions.ts).
|
||
|
|
const [, forceRefresh] = useState(0);
|
||
|
|
const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
|
||
|
|
|
||
|
|
const clearConfirmTimeout = useCallback(() => {
|
||
|
|
if (confirmTimeoutRef.current) {
|
||
|
|
clearTimeout(confirmTimeoutRef.current);
|
||
|
|
confirmTimeoutRef.current = null;
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Reset the two-tap delete confirmation whenever the selection changes
|
||
|
|
// (including being cleared) so a stale "Confirm?" state never lingers onto
|
||
|
|
// a different node.
|
||
|
|
const prevSelectedRef = useRef(selectedId);
|
||
|
|
useEffect(() => {
|
||
|
|
if (prevSelectedRef.current !== selectedId) {
|
||
|
|
prevSelectedRef.current = selectedId;
|
||
|
|
clearConfirmTimeout();
|
||
|
|
setConfirmingDelete(false);
|
||
|
|
}
|
||
|
|
}, [selectedId, clearConfirmTimeout]);
|
||
|
|
|
||
|
|
useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
|
||
|
|
|
||
|
|
const handleDeleteTap = useCallback(() => {
|
||
|
|
if (confirmingDelete) {
|
||
|
|
clearConfirmTimeout();
|
||
|
|
setConfirmingDelete(false);
|
||
|
|
nodeActions.deleteNode();
|
||
|
|
bumpTick();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setConfirmingDelete(true);
|
||
|
|
clearConfirmTimeout();
|
||
|
|
confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
|
||
|
|
}, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
|
||
|
|
|
||
|
|
const handleEditStyles = useCallback(() => {
|
||
|
|
openSheet('styles');
|
||
|
|
}, [openSheet]);
|
||
|
|
|
||
|
|
const handleMoveUp = useCallback(() => {
|
||
|
|
nodeActions.moveUp();
|
||
|
|
bumpTick();
|
||
|
|
}, [nodeActions, bumpTick]);
|
||
|
|
|
||
|
|
const handleMoveDown = useCallback(() => {
|
||
|
|
nodeActions.moveDown();
|
||
|
|
bumpTick();
|
||
|
|
}, [nodeActions, bumpTick]);
|
||
|
|
|
||
|
|
const handleDuplicate = useCallback(() => {
|
||
|
|
nodeActions.duplicate();
|
||
|
|
bumpTick();
|
||
|
|
}, [nodeActions, bumpTick]);
|
||
|
|
|
||
|
|
if (!selectedId || activeSheet !== null) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mobile-selection-toolbar-btn"
|
||
|
|
onClick={handleMoveUp}
|
||
|
|
disabled={!nodeActions.canMoveUp}
|
||
|
|
aria-label="Move up"
|
||
|
|
>
|
||
|
|
<i className="fa fa-arrow-up" aria-hidden="true" />
|
||
|
|
<span>Up</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mobile-selection-toolbar-btn"
|
||
|
|
onClick={handleMoveDown}
|
||
|
|
disabled={!nodeActions.canMoveDown}
|
||
|
|
aria-label="Move down"
|
||
|
|
>
|
||
|
|
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||
|
|
<span>Down</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mobile-selection-toolbar-btn"
|
||
|
|
onClick={handleDuplicate}
|
||
|
|
aria-label="Duplicate"
|
||
|
|
>
|
||
|
|
<i className="fa fa-clone" aria-hidden="true" />
|
||
|
|
<span>Copy</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mobile-selection-toolbar-btn"
|
||
|
|
onClick={nodeActions.selectParent}
|
||
|
|
aria-label="Select parent"
|
||
|
|
>
|
||
|
|
<i className="fa fa-level-up" aria-hidden="true" />
|
||
|
|
<span>Parent</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="mobile-selection-toolbar-btn"
|
||
|
|
onClick={handleEditStyles}
|
||
|
|
aria-label="Edit styles"
|
||
|
|
>
|
||
|
|
<i className="fa fa-paint-brush" aria-hidden="true" />
|
||
|
|
<span>Style</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
|
||
|
|
onClick={handleDeleteTap}
|
||
|
|
disabled={!nodeActions.canDelete}
|
||
|
|
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
|
||
|
|
>
|
||
|
|
<i className="fa fa-trash" aria-hidden="true" />
|
||
|
|
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|