mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
d8213bfa42
Elements can be grouped on a page and picked, moved, resized, aligned and restacked as one. A layers panel lists the page front to back with lock, visibility, rename and drag to restack. The sheet gains marquee selection, arrow-key nudge, one box that resizes a multi-selection with Shift holding the proportions, and the design-tool shortcuts. The inspector lines a selection up and spaces it out, and copies and pastes a style. The assistant gets the same through the group, ungroup, name and hide operations, and describe_book_page lists the groups. The format gains the optional group, name and hidden fields and a spread's group names, so BOOK_SCHEMA_REV goes to 2.
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
import type { BookElement } from '@trek/shared';
|
|
import { isWholeGroup } from '@trek/shared';
|
|
import {
|
|
Award,
|
|
ChartColumn,
|
|
ChevronRight,
|
|
Eye,
|
|
EyeOff,
|
|
Globe,
|
|
GripVertical,
|
|
Group,
|
|
ImageIcon,
|
|
List,
|
|
Lock,
|
|
MapIcon,
|
|
Shapes,
|
|
Sparkles,
|
|
Square,
|
|
Type,
|
|
Ungroup,
|
|
Unlock,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { useRef, useState } from 'react';
|
|
|
|
import { useStudioStore } from '../../store/studioStore';
|
|
import '../../styles/studio-layers.css';
|
|
import { Tooltip } from '../shared/Tooltip';
|
|
import { PanelHead } from './StudioPanelHead';
|
|
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows';
|
|
import { useStudioShortcut } from './studioShortcut';
|
|
|
|
/**
|
|
* The layers of the open page, front to back (journey-studio WP6).
|
|
*
|
|
* One row per element and one per group, its members indented under it. A row
|
|
* selects what it stands for; a member's row selects the member alone, inside
|
|
* its group, the way a double-click on the sheet does. Rows drag to restack:
|
|
* the upper half of a row puts what is dragged in front of it, the lower half
|
|
* behind it, and a member's row takes it into that group. Lock and visibility
|
|
* sit at the end of every row, always shown when they are on so a locked or
|
|
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
|
|
* since this panel is the way back to it. Every change is one undo step.
|
|
*/
|
|
|
|
const KIND_ICONS: Record<string, LucideIcon> = {
|
|
photo: ImageIcon,
|
|
text: Type,
|
|
shape: Square,
|
|
icon: Sparkles,
|
|
map: MapIcon,
|
|
stats: ChartColumn,
|
|
countries: Globe,
|
|
badge: Award,
|
|
list: List,
|
|
};
|
|
|
|
/** How far, in pixels, a press on a row travels before it is a drag rather than a click. */
|
|
const DRAG_PX = 4;
|
|
|
|
interface Drag {
|
|
ids: string[];
|
|
startY: number;
|
|
active: boolean;
|
|
drop: LayerDrop | null;
|
|
/** The row the pointer is over and which half, for the insertion line. */
|
|
overKey: string | null;
|
|
upper: boolean;
|
|
}
|
|
|
|
type T = (k: string, params?: Record<string, string | number>) => string;
|
|
|
|
export function StudioLayersPanel({ t }: { t: T }) {
|
|
const spreadIndex = useStudioStore((s) => s.activeSpread);
|
|
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
|
|
const selection = useStudioStore((s) => s.selection);
|
|
const enteredGroup = useStudioStore((s) => s.enteredGroup);
|
|
const readOnly = useStudioStore((s) => s.readOnly);
|
|
const pick = useStudioStore((s) => s.pick);
|
|
const selectGroup = useStudioStore((s) => s.selectGroup);
|
|
const enterGroup = useStudioStore((s) => s.enterGroup);
|
|
const restack = useStudioStore((s) => s.restack);
|
|
const setHidden = useStudioStore((s) => s.setHidden);
|
|
const setLocked = useStudioStore((s) => s.setLocked);
|
|
const renameLayer = useStudioStore((s) => s.renameLayer);
|
|
const group = useStudioStore((s) => s.group);
|
|
const ungroup = useStudioStore((s) => s.ungroup);
|
|
const keys = useStudioShortcut();
|
|
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
const [renaming, setRenaming] = useState<string | null>(null);
|
|
const [drag, setDrag] = useState<Drag | null>(null);
|
|
const list = useRef<HTMLDivElement>(null);
|
|
/** A drag ends in a click on the row it started from; that click is not a choice. */
|
|
const justDragged = useRef(false);
|
|
|
|
if (!spread) return null;
|
|
const rows = layerRows(spread, collapsed);
|
|
const selected = new Set(selection);
|
|
const movable = spread.elements.filter((e) => selected.has(e.id) && !e.locked);
|
|
const canGroup =
|
|
movable.length >= 2 &&
|
|
!isWholeGroup(
|
|
spread,
|
|
movable.map((e) => e.id)
|
|
);
|
|
const canUngroup = spread.elements.some((e) => selected.has(e.id) && e.group);
|
|
|
|
const toggleCollapsed = (groupId: string) =>
|
|
setCollapsed((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(groupId)) next.delete(groupId);
|
|
else next.add(groupId);
|
|
return next;
|
|
});
|
|
|
|
const choose = (row: LayerRow, additive: boolean) => {
|
|
if (justDragged.current) {
|
|
justDragged.current = false;
|
|
return;
|
|
}
|
|
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
|
|
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
|
|
else pick(spreadIndex, row.el.id, additive);
|
|
};
|
|
|
|
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
|
|
const dragIds = (row: LayerRow) => {
|
|
const own = rowIds(row);
|
|
return own.every((id) => selected.has(id))
|
|
? spread.elements.filter((e) => selected.has(e.id)).map((e) => e.id)
|
|
: own;
|
|
};
|
|
|
|
const rowAt = (clientY: number): { row: LayerRow; upper: boolean } | null => {
|
|
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
|
|
for (const node of nodes) {
|
|
const r = node.getBoundingClientRect();
|
|
if (clientY < r.top || clientY > r.bottom) continue;
|
|
const row = rows.find((x) => x.key === node.dataset.layerKey);
|
|
if (row) return { row, upper: clientY < r.top + r.height / 2 };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const onPointerDown = (e: React.PointerEvent, row: LayerRow) => {
|
|
if (e.button !== 0 || readOnly) return;
|
|
if ((e.target as HTMLElement).closest('button, input')) return;
|
|
setDrag({ ids: dragIds(row), startY: e.clientY, active: false, drop: null, overKey: null, upper: true });
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
if (!drag) return;
|
|
if (!drag.active && Math.abs(e.clientY - drag.startY) < DRAG_PX) return;
|
|
if (!drag.active) {
|
|
try {
|
|
list.current?.setPointerCapture?.(e.pointerId);
|
|
} catch {
|
|
// A pointer that is gone already cannot be captured; the drag still follows the list.
|
|
}
|
|
}
|
|
const at = rowAt(e.clientY);
|
|
const over = at && !rowIds(at.row).some((id) => drag.ids.includes(id)) ? at : null;
|
|
setDrag({
|
|
...drag,
|
|
active: true,
|
|
drop: over ? dropOn(over.row, over.upper) : null,
|
|
overKey: over?.row.key ?? null,
|
|
upper: over?.upper ?? true,
|
|
});
|
|
};
|
|
|
|
const onPointerUp = (e: React.PointerEvent) => {
|
|
if (!drag) return;
|
|
try {
|
|
list.current?.releasePointerCapture?.(e.pointerId);
|
|
} catch {
|
|
// Released already, or never taken: a click that did not move.
|
|
}
|
|
justDragged.current = drag.active;
|
|
if (drag.active && drag.drop) restack(spreadIndex, drag.ids, drag.drop.anchorId, drag.drop.where, drag.drop.join);
|
|
setDrag(null);
|
|
};
|
|
|
|
const commitName = (row: LayerRow, value: string) => {
|
|
setRenaming(null);
|
|
const current = row.type === 'group' ? (row.name ?? '') : (row.el.name ?? '');
|
|
if (value.trim() === current) return;
|
|
renameLayer(spreadIndex, row.type === 'group' ? { groupId: row.groupId } : { elementId: row.el.id }, value);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PanelHead label={t('journey.studio.layers')} count={spread.elements.length} />
|
|
<div className="st-panel-scroll st-layers-scroll">
|
|
{rows.length === 0 ? (
|
|
<p className="st-hint">{t('journey.studio.layers.empty')}</p>
|
|
) : (
|
|
<>
|
|
<p className="st-hint st-layers-hint">{t('journey.studio.layers.hint')}</p>
|
|
<div
|
|
ref={list}
|
|
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
|
|
role="tree"
|
|
aria-label={t('journey.studio.layers')}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={() => setDrag(null)}
|
|
>
|
|
{rows.map((row) => (
|
|
<LayerRowView
|
|
key={row.key}
|
|
row={row}
|
|
t={t}
|
|
selected={rowIds(row).every((id) => selected.has(id))}
|
|
inside={row.type === 'element' && row.inGroup && row.el.group === enteredGroup}
|
|
collapsed={row.type === 'group' && collapsed.has(row.groupId)}
|
|
renaming={renaming === row.key}
|
|
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
|
|
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
|
|
readOnly={readOnly}
|
|
onPointerDown={(e) => onPointerDown(e, row)}
|
|
onChoose={(additive) => choose(row, additive)}
|
|
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
|
|
onRename={() => !readOnly && setRenaming(row.key)}
|
|
onCommitName={(v) => commitName(row, v)}
|
|
onCancelName={() => setRenaming(null)}
|
|
onLock={(locked) => setLocked(spreadIndex, rowIds(row), locked)}
|
|
onHide={(hidden) => setHidden(spreadIndex, rowIds(row), hidden)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!readOnly && rows.length > 0 && (
|
|
<div className="st-layers-foot">
|
|
<Tooltip label={t('journey.studio.layers.groupAction')} shortcut={keys('G')} placement="top">
|
|
<button type="button" className="st-chip" disabled={!canGroup} onClick={() => group(spreadIndex)}>
|
|
<Group size={14} />
|
|
{t('journey.studio.layers.groupAction')}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('journey.studio.layers.ungroup')} shortcut={keys('⇧', 'G')} placement="top">
|
|
<button type="button" className="st-chip" disabled={!canUngroup} onClick={() => ungroup(spreadIndex)}>
|
|
<Ungroup size={14} />
|
|
{t('journey.studio.layers.ungroup')}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface RowProps {
|
|
row: LayerRow;
|
|
t: T;
|
|
selected: boolean;
|
|
inside: boolean;
|
|
collapsed: boolean;
|
|
renaming: boolean;
|
|
dragged: boolean;
|
|
dropAt: 'above' | 'below' | null;
|
|
readOnly: boolean;
|
|
onPointerDown: (e: React.PointerEvent) => void;
|
|
onChoose: (additive: boolean) => void;
|
|
onToggleCollapsed: () => void;
|
|
onRename: () => void;
|
|
onCommitName: (value: string) => void;
|
|
onCancelName: () => void;
|
|
onLock: (locked: boolean) => void;
|
|
onHide: (hidden: boolean) => void;
|
|
}
|
|
|
|
function rowState(row: LayerRow): { locked: boolean; hidden: boolean } {
|
|
const els: BookElement[] = row.type === 'group' ? row.members : [row.el];
|
|
return { locked: els.every((e) => e.locked), hidden: els.every((e) => e.hidden) };
|
|
}
|
|
|
|
/** The row's classes: what it is, whether it is picked, hidden, dragged, or where a drop would land. */
|
|
function rowClasses(p: RowProps, hidden: boolean): string {
|
|
const { row } = p;
|
|
return [
|
|
'st-layer',
|
|
row.type === 'group' ? 'is-group' : '',
|
|
row.type === 'element' && row.inGroup ? 'is-member' : '',
|
|
p.selected ? 'is-on' : '',
|
|
p.inside ? 'is-inside' : '',
|
|
hidden ? 'is-hidden' : '',
|
|
p.dragged ? 'is-dragged' : '',
|
|
p.dropAt ? `is-drop-${p.dropAt}` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
}
|
|
|
|
/** What the row says it is, and whether that is a name somebody gave it. */
|
|
function rowLabel(row: LayerRow, t: T): { label: string; named: boolean } {
|
|
if (row.type === 'group') return { label: row.name || t('journey.studio.layers.group'), named: !!row.name };
|
|
return { label: layerLabel(row.el, t(`journey.studio.kind.${row.el.kind}`)), named: !!row.el.name };
|
|
}
|
|
|
|
function LayerRowView(p: RowProps) {
|
|
const { row, t } = p;
|
|
const { locked, hidden } = rowState(p.row);
|
|
const Icon = row.type === 'group' ? Group : (KIND_ICONS[row.el.kind] ?? Shapes);
|
|
const { label, named } = rowLabel(row, t);
|
|
|
|
return (
|
|
<div
|
|
className={rowClasses(p, hidden)}
|
|
data-layer-key={row.key}
|
|
role="treeitem"
|
|
aria-selected={p.selected}
|
|
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
|
|
onPointerDown={p.onPointerDown}
|
|
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
|
|
>
|
|
{!p.readOnly && <GripVertical size={12} className="st-layer-grip" aria-hidden />}
|
|
{row.type === 'group' && <RowCaret collapsed={p.collapsed} onToggle={p.onToggleCollapsed} t={t} />}
|
|
<Icon size={13} className="st-layer-icon" aria-hidden />
|
|
<RowName
|
|
label={label}
|
|
named={named}
|
|
renaming={p.renaming}
|
|
t={t}
|
|
onRename={p.onRename}
|
|
onCommit={p.onCommitName}
|
|
onCancel={p.onCancelName}
|
|
/>
|
|
{row.type === 'group' && <span className="st-layer-count">{row.members.length}</span>}
|
|
{!p.readOnly && <RowToggles locked={locked} hidden={hidden} t={t} onLock={p.onLock} onHide={p.onHide} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A click that does something on its own and must not also select the row. */
|
|
const own = (fn: () => void) => (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
fn();
|
|
};
|
|
|
|
function RowCaret({ collapsed, onToggle, t }: { collapsed: boolean; onToggle: () => void; t: T }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`st-layer-caret ${collapsed ? '' : 'is-open'}`}
|
|
onClick={own(onToggle)}
|
|
aria-label={t(collapsed ? 'journey.studio.layers.expand' : 'journey.studio.layers.collapse')}
|
|
>
|
|
<ChevronRight size={12} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function RowName(p: {
|
|
label: string;
|
|
named: boolean;
|
|
renaming: boolean;
|
|
t: T;
|
|
onRename: () => void;
|
|
onCommit: (value: string) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
if (!p.renaming) {
|
|
return (
|
|
<span
|
|
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
|
|
title={p.t('journey.studio.layers.rename')}
|
|
onDoubleClick={own(p.onRename)}
|
|
>
|
|
{p.label}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<input
|
|
className="st-input st-layer-input"
|
|
autoFocus
|
|
defaultValue={p.named ? p.label : ''}
|
|
placeholder={p.label}
|
|
maxLength={60}
|
|
aria-label={p.t('journey.studio.layers.rename')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onBlur={(e) => p.onCommit(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
|
if (e.key !== 'Escape') return;
|
|
// Escape here leaves the name as it was, and must not close Studio.
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
p.onCancel();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RowToggles(p: {
|
|
locked: boolean;
|
|
hidden: boolean;
|
|
t: T;
|
|
onLock: (locked: boolean) => void;
|
|
onHide: (hidden: boolean) => void;
|
|
}) {
|
|
const lockLabel = p.t(p.locked ? 'journey.studio.unlock' : 'journey.studio.lock');
|
|
const hideLabel = p.t(p.hidden ? 'journey.studio.layers.show' : 'journey.studio.layers.hide');
|
|
return (
|
|
<span className="st-layer-acts">
|
|
<button
|
|
type="button"
|
|
className={`st-act ${p.locked ? 'is-on is-kept' : ''}`}
|
|
onClick={own(() => p.onLock(!p.locked))}
|
|
title={lockLabel}
|
|
aria-label={lockLabel}
|
|
aria-pressed={p.locked}
|
|
>
|
|
{p.locked ? <Lock size={12} /> : <Unlock size={12} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`st-act ${p.hidden ? 'is-on is-kept' : ''}`}
|
|
onClick={own(() => p.onHide(!p.hidden))}
|
|
title={hideLabel}
|
|
aria-label={hideLabel}
|
|
aria-pressed={p.hidden}
|
|
>
|
|
{p.hidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|