Put the shapes of a vector drawing on layers and in groups (#190)
A vector drawing was one flat list of shapes. With many shapes the list got hard to read, and several shapes could not be kept or moved together. Now the shapes stand on layers, as in pixel art, and can be put into groups that nest. - A drawing has layers, each with a name and an eye. A hidden layer is neither in the tab nor in the picture a sprite wears, and its shapes cannot be pressed. One layer is the one worked on: the pen and Add shape draw onto it, and SHAPES lists what is on it. - The inspector has a list LAYERS over the shapes. Its plus adds a layer, the cross on a row removes it with all on it, the last one stays. A double-click renames, a drag sorts. - Group (Ctrl+G) puts the selected shapes into a group, Ungroup (Ctrl+Shift+G) takes it away. A group stands over its rows in the list, the rows one step in, and a row is dragged into a group or out of it. - In the tab a press on a shape takes its outermost group, and a drag moves the group as one undo step. - The model has layers with a tree of shapes and groups instead of a flat shapes array. The rules are pure functions in core/model/vectorTree.ts. A drawing saved before opens with all its shapes on one layer. - SortableList takes rows with a depth and drops into a tree. The flat lists of tilemap and pixel art run as before. Decided here: - A group has no transformation of its own and cannot be hidden or locked. Rotating and scaling is the follow-up #291. - A group is never empty: the one that loses its last shape goes with it. - Several selected shapes are dragged together, grouped or not, and a click on a shape of the selection keeps the selection. This is what #217 asked for. - Shift+click selects like Ctrl+click. With Select, Shift pans only beside the shapes, as in the scene view. The middle button pans anywhere, and with the pen and Paint Shift pans as before. - A row of the list picks one shape of a group alone. - Shapes change their layer through a row Layer in the inspector, which is there from two layers on. Rows are not dragged between layers. - A group cannot span two layers, the button is off then. - The pen does not draw onto a hidden layer, the hint says so. - Groups are always shown unfolded. - The four order buttons work inside the own group or layer. Tests: - Gate: 204 files, 3067 tests (3008 + 59), exit 0 - e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change: 209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned and CI measures it. - 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in tests/editor/vector-layers.test.ts - Six e2e checks with a real mouse in block 47j - 47 unit counter-probes for layers and groups and three for the Shift click all fail tests, run on stein2 - Three browser counter-probes fail their checks - Two screenshots looked at (group in the list, hidden layer) - Only unit tested: the row Layer, sorting and removing layers, copying a group, the Shift pan beside the shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393caadc9d
commit
2ff3a78607
30 files changed
+2625
-408
No files matched your search
@@ -1,10 +1,23 @@
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { currentGradientBar, currentShapeList } from '../inspector/shapeSection';
|
||||
import { allShapes, treeRows } from '../../core/model/vectorTree';
|
||||
import { currentGradientBar, currentLayerList, currentShapeList } from '../inspector/shapeSection';
|
||||
import { activeLayerOf } from '../vectorart/activeLayer';
|
||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The Vector art tab (#129). */
|
||||
export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
const { vectorArtEditor } = editor;
|
||||
const { vectorArtEditor, inspector } = editor;
|
||||
/** The two lists, scrolled into view first: since #190 the layers stand over the shapes. */
|
||||
const shapeList = () => {
|
||||
const list = currentShapeList();
|
||||
if (list) inspector.reveal(list);
|
||||
return list;
|
||||
};
|
||||
const layerList = () => {
|
||||
const list = currentLayerList();
|
||||
if (list) inspector.reveal(list);
|
||||
return list;
|
||||
};
|
||||
return {
|
||||
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
|
||||
vectorArt: {
|
||||
@@ -27,11 +40,11 @@ export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
enabled: vectorArtEditor.pointButton.enabled,
|
||||
kind: vectorArtEditor.canvas.pickedKind,
|
||||
}),
|
||||
/** The shapes as the model holds them, so a check can read points and handles exactly. */
|
||||
/** The shapes as the model holds them, in drawing order, so a check can read points and handles exactly. */
|
||||
shapes: (assetId: string) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset || asset.type !== 'vectorart') return null;
|
||||
return asset.shapes.map((shape) => ({
|
||||
return allShapes(asset).map((shape) => ({
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
fill: shape.fill,
|
||||
@@ -50,6 +63,28 @@ export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
})),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* The layers (#190), lowest first: which one is worked on, and the tree of each as the list
|
||||
* shows it – top row first, a group's rows one step deeper.
|
||||
*/
|
||||
layers: (assetId: string) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset || asset.type !== 'vectorart') return null;
|
||||
const active = activeLayerOf(state, asset)?.id ?? '';
|
||||
return asset.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
hidden: layer.hidden === true,
|
||||
active: layer.id === active,
|
||||
rows: treeRows(layer.items),
|
||||
}));
|
||||
},
|
||||
/** The layer list in the inspector: the order it shows, top first, and where its parts sit. */
|
||||
layerRows: () => currentLayerList()?.shownIds ?? null,
|
||||
layerRowAt: (layerId: string) => layerList()?.rowGlobal(layerId) ?? null,
|
||||
layerEyeAt: (layerId: string) => layerList()?.eyeGlobal(layerId) ?? null,
|
||||
layerRemoveAt: (layerId: string) => layerList()?.removeGlobal(layerId) ?? null,
|
||||
layerAddAt: () => layerList()?.addGlobal() ?? null,
|
||||
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
|
||||
selectedIds: () => state.selectedShapeIds,
|
||||
/**
|
||||
@@ -57,12 +92,12 @@ export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
* row sits. A row carries a name that also stands in the tab, so a check cannot click it by text.
|
||||
*/
|
||||
shapeRows: () => currentShapeList()?.shownIds ?? null,
|
||||
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
|
||||
shapeRowAt: (shapeId: string) => shapeList()?.rowGlobal(shapeId) ?? null,
|
||||
/** The cross on a shape's row. */
|
||||
shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null,
|
||||
shapeRemoveAt: (shapeId: string) => shapeList()?.removeGlobal(shapeId) ?? null,
|
||||
/** The duplicate mark on a shape's row, and where a double-click edits its name. */
|
||||
shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null,
|
||||
shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null,
|
||||
shapeDuplicateAt: (shapeId: string) => shapeList()?.duplicateGlobal(shapeId) ?? null,
|
||||
shapeNameAt: (shapeId: string) => shapeList()?.nameGlobal(shapeId) ?? null,
|
||||
/** The field a shape's name is being edited in, with what it holds; null while none is. */
|
||||
shapeRenaming: () => {
|
||||
const field = currentShapeList()?.renameField;
|
||||
|
||||
@@ -9,31 +9,42 @@
|
||||
* rows, and only a rebuild puts the new list on screen.
|
||||
*/
|
||||
import {
|
||||
AddVectorLayerCommand,
|
||||
AddVectorShapeCommand,
|
||||
CombineVectorShapesCommand,
|
||||
DuplicateVectorShapeCommand,
|
||||
MoveVectorShapeCommand,
|
||||
MoveVectorLayerCommand,
|
||||
MoveVectorShapesCommand,
|
||||
OrderVectorShapeCommand,
|
||||
RemoveVectorLayerCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RenameVectorLayerCommand,
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorLayerHiddenCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../core/commands/vectorArtCommands';
|
||||
import type {
|
||||
VectorArtAsset,
|
||||
VectorGradient,
|
||||
VectorItem,
|
||||
VectorPaint,
|
||||
VectorShape,
|
||||
VectorStroke,
|
||||
} from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findShape, SHAPE_FILL_DEFAULT, shapeExtent, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import {
|
||||
findShape,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
shapeExtent,
|
||||
allShapes,
|
||||
canOrderItem,
|
||||
findVectorItem,
|
||||
isVectorGroup,
|
||||
placeOf,
|
||||
selectedItems,
|
||||
type ShapeOrderMove,
|
||||
shapeOrderTarget,
|
||||
vectorCanvasSize,
|
||||
} from '../../core/model/vectorart';
|
||||
shapesIn,
|
||||
treeRows,
|
||||
} from '../../core/model/vectorTree';
|
||||
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
|
||||
import {
|
||||
FILL_KINDS,
|
||||
@@ -50,10 +61,13 @@ import {
|
||||
withStopChanged,
|
||||
} from '../../core/model/vectorPaint';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, HStack, Theme } from '../../ui';
|
||||
import { Button, HStack, SortableList, Theme } from '../../ui';
|
||||
import { removeShapes } from '../actions';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import { listFold } from '../sectionFolds';
|
||||
import { type EditorState, shapeSelection } from '../state';
|
||||
import { activeLayerOf, activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer';
|
||||
import { groupBlocker, groupShapes, groupsToDissolve, ungroupShapes } from '../vectorart/grouping';
|
||||
import { GradientBar } from '../vectorart/GradientBar';
|
||||
import { ShapeList } from '../vectorart/ShapeList';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
@@ -174,6 +188,9 @@ const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [
|
||||
{ op: 'exclude', label: 'Exclude' },
|
||||
];
|
||||
|
||||
/** The layer the selected shapes stand on (#190); picking another one moves them there. */
|
||||
const LAYER_KEY = 'shapeLayer';
|
||||
|
||||
const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
|
||||
const OPACITY_DEF: PropertyDef = {
|
||||
key: 'opacity',
|
||||
@@ -202,21 +219,33 @@ const STROKE_WIDTH_DEF: PropertyDef = {
|
||||
decimals: 1,
|
||||
};
|
||||
|
||||
/** The list as it was last built, so a check can address a row that carries a name, not a widget. */
|
||||
/** The lists as they were last built, so a check can address a row that carries a name, not a widget. */
|
||||
let built: ShapeList | null = null;
|
||||
let builtLayers: SortableList | null = null;
|
||||
|
||||
/** Null while no vector art shapes are on screen. */
|
||||
export function currentShapeList(): ShapeList | null {
|
||||
return built;
|
||||
}
|
||||
|
||||
/** What the shapes of an asset make of the rows: their order, their names, and who is drawn. */
|
||||
export function shapeListSignature(asset: VectorArtAsset): string {
|
||||
return asset.shapes.map((shape) => `${shape.id}:${shape.name}`).join(',');
|
||||
/** Null while no vector art is in the inspector. */
|
||||
export function currentLayerList(): SortableList | null {
|
||||
return builtLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure-defining facts of a shape selection: the list above it, whether the shape has an
|
||||
* What a drawing makes of the rows: its layers, which of them is worked on, and the tree of that
|
||||
* one – order, names and who stands in which group. `shapeId` is the selected shape, if one is.
|
||||
*/
|
||||
export function shapeListSignature(asset: VectorArtAsset, shapeId = ''): string {
|
||||
const active = activeVectorLayer(asset, shapeId);
|
||||
const layers = asset.layers.map((layer) => `${layer.id}:${layer.name}:${layer.hidden ? 'h' : 's'}`);
|
||||
const tree = treeRows(active?.items ?? []).map((row) => `${row.id}:${row.name}:${row.depth}`);
|
||||
return `${layers.join(',')}@${active?.id ?? ''}[${tree.join(',')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure-defining facts of a shape selection: the lists above it, whether the shape has an
|
||||
* outline – its colour and width are rows of their own and appear with it – and whether the
|
||||
* selected shapes can be combined, which switches the four buttons and the note under them.
|
||||
*/
|
||||
@@ -231,25 +260,26 @@ export function shapeSignature(
|
||||
if (!asset || !shape) return 'missing';
|
||||
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
|
||||
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
|
||||
return `${shapeListSignature(asset)}|${paints}|${shapeIds.join(',')}|${blocked}`;
|
||||
return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}`;
|
||||
}
|
||||
|
||||
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
|
||||
function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] {
|
||||
return asset.shapes.filter((shape) => shapeIds.includes(shape.id));
|
||||
return allShapes(asset).filter((shape) => shapeIds.includes(shape.id));
|
||||
}
|
||||
|
||||
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
|
||||
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
|
||||
addShapeList(host, asset, []);
|
||||
addLayerList(host, asset, '');
|
||||
addShapeList(host, asset, '', []);
|
||||
addNote(host, 'The top row is drawn over the ones below it.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(addShapeButton(host, asset.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Name, place in the drawing order and style of the primary shape, under the list it is picked
|
||||
* from, and the buttons that combine all selected shapes into one (#187).
|
||||
* Name, place in the drawing order and style of the primary shape, under the lists it is picked
|
||||
* from, and the buttons that combine all selected shapes into one (#187) or group them (#190).
|
||||
*/
|
||||
export function buildShapeSection(
|
||||
host: InspectorHost,
|
||||
@@ -261,28 +291,34 @@ export function buildShapeSection(
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return;
|
||||
|
||||
addShapeList(host, asset, shapeIds);
|
||||
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
|
||||
addLayerList(host, asset, shapeId);
|
||||
addShapeList(host, asset, shapeId, shapeIds);
|
||||
addNote(host, 'Drag a row to draw it earlier or later, or into a group.', 'small');
|
||||
|
||||
// A group picked as a whole is what the buttons move; otherwise it is the primary shape.
|
||||
const items = selectedItems(asset.layers, shapeIds);
|
||||
const ordered = items.length === 1 ? items[0]! : shapeId;
|
||||
// Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width.
|
||||
for (let start = 0; start < ORDER_BUTTONS.length; start += 2) {
|
||||
const order = new HStack({ gap: Theme.gap });
|
||||
for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) {
|
||||
const button = new Button(label, {
|
||||
variant: 'ghost',
|
||||
onClick: () => moveShape(host, assetId, shapeId, move),
|
||||
onClick: () => void host.state.execute(new OrderVectorShapeCommand(assetId, ordered, move)),
|
||||
});
|
||||
// A shape already at the front has nowhere to go forwards.
|
||||
button.setEnabled(shapeOrderTarget(asset.shapes, shapeId, move) >= 0);
|
||||
// A shape already at the front of its list has nowhere to go forwards.
|
||||
button.setEnabled(canOrderItem(asset.layers, ordered, move));
|
||||
order.add(button, 1);
|
||||
}
|
||||
host.body.add(order);
|
||||
}
|
||||
|
||||
addGroupButtons(host, asset, shapeId, shapeIds);
|
||||
addCombineButtons(host, asset, shapeIds);
|
||||
|
||||
addTitle(host, 'VECTOR SHAPE');
|
||||
addShapeName(host, assetId, shape);
|
||||
addLayerRow(host, asset, shapeId, shapeIds);
|
||||
fillRows(host, assetId, shape);
|
||||
styleRow(
|
||||
host,
|
||||
@@ -330,6 +366,67 @@ export function buildShapeSection(
|
||||
host.body.add(addShapeButton(host, assetId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group and Ungroup (#190). Group puts what is selected into a new group, a group picked as a whole
|
||||
* going in as one. Ungroup takes the picked groups away, or the group the primary shape stands in.
|
||||
*/
|
||||
function addGroupButtons(
|
||||
host: InspectorHost,
|
||||
asset: VectorArtAsset,
|
||||
shapeId: string,
|
||||
shapeIds: readonly string[],
|
||||
): void {
|
||||
const row = new HStack({ gap: Theme.gap });
|
||||
const group = new Button('Group', { variant: 'ghost', onClick: () => groupShapes(host.state, asset.id) });
|
||||
const ungroup = new Button('Ungroup', {
|
||||
variant: 'ghost',
|
||||
onClick: () => ungroupShapes(host.state, asset.id),
|
||||
});
|
||||
group.setEnabled(groupBlocker(asset, shapeIds) === null);
|
||||
ungroup.setEnabled(groupsToDissolve(asset, shapeId, shapeIds).length > 0);
|
||||
row.add(group, 1);
|
||||
row.add(ungroup, 1);
|
||||
host.body.add(row);
|
||||
}
|
||||
|
||||
/** The layer the selection stands on, as a row; with one layer there is nothing to choose. */
|
||||
function addLayerRow(
|
||||
host: InspectorHost,
|
||||
asset: VectorArtAsset,
|
||||
shapeId: string,
|
||||
shapeIds: readonly string[],
|
||||
): void {
|
||||
if (asset.layers.length < 2) return;
|
||||
const def: PropertyDef = {
|
||||
key: LAYER_KEY,
|
||||
label: 'Layer',
|
||||
kind: 'enum',
|
||||
default: '',
|
||||
options: [...asset.layers].reverse().map((layer) => ({ value: layer.id, label: layer.name })),
|
||||
};
|
||||
const current = placeOf(asset.layers, shapeId)?.layer.id ?? '';
|
||||
const row = createFieldRow(host, def, current, {
|
||||
onInput: () => {},
|
||||
onChange: (input) => moveShapesToLayer(host.state, asset.id, shapeIds, textOf(input)),
|
||||
});
|
||||
host.fields.set(LAYER_KEY, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/** Moves the selected shapes, groups as wholes, on top of another layer. Exported as `setPaintKind` is. */
|
||||
export function moveShapesToLayer(
|
||||
state: EditorState,
|
||||
assetId: string,
|
||||
shapeIds: readonly string[],
|
||||
layerId: string,
|
||||
): void {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
if (!asset) return;
|
||||
const items = selectedItems(asset.layers, shapeIds);
|
||||
if (items.every((id) => placeOf(asset.layers, id)?.layer.id === layerId)) return;
|
||||
state.execute(new MoveVectorShapesCommand(assetId, items, layerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Union, subtract, intersect, exclude – two rows of two, like the order buttons. They are there with
|
||||
* one shape selected too, switched off, so the note under them can say how to get a second one.
|
||||
@@ -361,6 +458,8 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
if (!shape) return false;
|
||||
host.fields.get('name')?.setValue(shape.name);
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
if (asset) host.fields.get(LAYER_KEY)?.setValue(placeOf(asset.layers, shapeId)?.layer.id ?? '');
|
||||
host.fields.get('fill')?.setValue(shape.fill);
|
||||
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
|
||||
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
|
||||
@@ -374,49 +473,130 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
|
||||
return true;
|
||||
}
|
||||
|
||||
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: readonly string[]): void {
|
||||
if (asset.shapes.length === 0) {
|
||||
/** The layers of the drawing, the top one drawn last; the marked one is the one being worked on. */
|
||||
function addLayerList(host: InspectorHost, asset: VectorArtAsset, shapeId: string): void {
|
||||
const { state } = host;
|
||||
const active = activeVectorLayer(asset, shapeId);
|
||||
const list = new SortableList({
|
||||
title: 'Layers',
|
||||
...listFold('inspector', 'Layers'),
|
||||
reversed: true,
|
||||
keepOne: true,
|
||||
onPick: (layerId) => pickVectorLayer(state, asset.id, layerId),
|
||||
onMove: (from, to) => void state.execute(new MoveVectorLayerCommand(asset.id, from, to)),
|
||||
onToggleHidden: (layerId) => {
|
||||
const layer = asset.layers.find((l) => l.id === layerId);
|
||||
if (layer) state.execute(new SetVectorLayerHiddenCommand(asset.id, layerId, !layer.hidden));
|
||||
},
|
||||
onAdd: () => {
|
||||
const command = new AddVectorLayerCommand(asset.id, active?.id ?? '');
|
||||
if (state.execute(command)) pickVectorLayer(state, asset.id, command.layerId);
|
||||
},
|
||||
onRemove: (layerId) => void state.execute(new RemoveVectorLayerCommand(asset.id, layerId)),
|
||||
onRename: (layerId, name) => void state.execute(new RenameVectorLayerCommand(asset.id, layerId, name)),
|
||||
});
|
||||
list.setItems(
|
||||
asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden === true })),
|
||||
active ? [active.id] : [],
|
||||
);
|
||||
builtLayers = list;
|
||||
host.body.add(list);
|
||||
}
|
||||
|
||||
/** The tree of the active layer; a group whose shapes are all selected is marked with them. */
|
||||
function addShapeList(
|
||||
host: InspectorHost,
|
||||
asset: VectorArtAsset,
|
||||
shapeId: string,
|
||||
selectedIds: readonly string[],
|
||||
): void {
|
||||
const layer = activeVectorLayer(asset, shapeId);
|
||||
const rows = treeRows(layer?.items ?? []);
|
||||
if (!layer || rows.length === 0) {
|
||||
built = null;
|
||||
addTitle(host, 'SHAPES');
|
||||
addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.');
|
||||
addNote(
|
||||
host,
|
||||
allShapes(asset).length === 0
|
||||
? 'Nothing drawn yet – take the Pen in the Vector art tab.'
|
||||
: 'Nothing on this layer yet – the Pen draws onto it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const list = new ShapeList(
|
||||
host.state,
|
||||
asset.id,
|
||||
asset.shapes.map((shape) => shape.id),
|
||||
selectedIds,
|
||||
{
|
||||
onPick: (shapeId, toggle) =>
|
||||
toggle
|
||||
? host.state.toggleShape(asset.id, shapeId)
|
||||
: host.state.select(shapeSelection(asset.id, [shapeId])),
|
||||
onReorder: (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
|
||||
onDuplicate: (shapeId) => {
|
||||
const command = new DuplicateVectorShapeCommand(asset.id, shapeId);
|
||||
if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId]));
|
||||
},
|
||||
onRemove: (shapeId) => removeShape(host, asset.id, shapeId),
|
||||
onRename: (shapeId, name) =>
|
||||
void host.state.execute(new RenameVectorShapeCommand(asset.id, shapeId, name)),
|
||||
const { state } = host;
|
||||
const list = new ShapeList(state, asset.id, rows, markedRows(asset, selectedIds), {
|
||||
onPick: (itemId, toggle) => pickShapeRow(state, asset.id, itemId, toggle),
|
||||
onDrop: (itemId, drop) =>
|
||||
void state.execute(new MoveVectorShapesCommand(asset.id, [itemId], layer.id, drop)),
|
||||
onDuplicate: (itemId) => {
|
||||
const command = new DuplicateVectorShapeCommand(asset.id, itemId);
|
||||
if (state.execute(command)) pickShapeRow(state, asset.id, command.shapeId, false);
|
||||
},
|
||||
);
|
||||
onRemove: (itemId) => removeShape(host, asset.id, itemId),
|
||||
onRename: (itemId, name) => void state.execute(new RenameVectorShapeCommand(asset.id, itemId, name)),
|
||||
});
|
||||
built = list;
|
||||
host.body.add(list);
|
||||
}
|
||||
|
||||
/** The cross on a row: the shape goes, and with it out of the selection – the rest stays selected. */
|
||||
function removeShape(host: InspectorHost, assetId: string, shapeId: string): void {
|
||||
const rest = host.state.selectedShapeIds.filter((id) => id !== shapeId);
|
||||
/** The selected shapes, and every group all of whose shapes are selected. */
|
||||
function markedRows(asset: VectorArtAsset, shapeIds: readonly string[]): string[] {
|
||||
const picked = new Set(shapeIds);
|
||||
const groups: string[] = [];
|
||||
const walk = (items: readonly VectorItem[]): void => {
|
||||
for (const item of items) {
|
||||
if (!isVectorGroup(item)) continue;
|
||||
if (shapesIn(item.items).every((shape) => picked.has(shape.id))) groups.push(item.id);
|
||||
walk(item.items);
|
||||
}
|
||||
};
|
||||
for (const layer of asset.layers) walk(layer.items);
|
||||
return [...shapeIds, ...groups];
|
||||
}
|
||||
|
||||
/** The shapes a row stands for: itself, or all that is in the group. */
|
||||
function shapeIdsOf(state: EditorState, assetId: string, itemId: string): string[] {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
const item = asset && findVectorItem(asset, itemId);
|
||||
if (!item) return [];
|
||||
return isVectorGroup(item) ? shapesIn(item.items).map((shape) => shape.id) : [item.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* A row pressed. A group's row selects all that is in it. With Ctrl or Shift the row's shapes join
|
||||
* the selection, or leave it when all of them were in it already. Exported as `setPaintKind` is.
|
||||
*/
|
||||
export function pickShapeRow(state: EditorState, assetId: string, itemId: string, toggle: boolean): void {
|
||||
const ids = shapeIdsOf(state, assetId, itemId);
|
||||
if (ids.length === 0) return;
|
||||
if (!toggle) {
|
||||
state.select(shapeSelection(assetId, ids));
|
||||
return;
|
||||
}
|
||||
const held =
|
||||
state.selection?.kind === 'shape' && state.selection.assetId === assetId ? state.selectedShapeIds : [];
|
||||
const all = ids.every((id) => held.includes(id));
|
||||
const next = all
|
||||
? held.filter((id) => !ids.includes(id))
|
||||
: [...held, ...ids.filter((id) => !held.includes(id))];
|
||||
state.select(shapeSelection(assetId, next) ?? { kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
/** The cross on a row: the item goes, and with it out of the selection – the rest stays selected. */
|
||||
function removeShape(host: InspectorHost, assetId: string, itemId: string): void {
|
||||
const gone = shapeIdsOf(host.state, assetId, itemId);
|
||||
const rest = host.state.selectedShapeIds.filter((id) => !gone.includes(id));
|
||||
const wasSelected = rest.length !== host.state.selectedShapeIds.length;
|
||||
if (!host.state.execute(new RemoveVectorShapeCommand(assetId, shapeId)) || !wasSelected) return;
|
||||
if (!host.state.execute(new RemoveVectorShapeCommand(assetId, itemId)) || !wasSelected) return;
|
||||
host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
function addShapeButton(host: InspectorHost, assetId: string): Button {
|
||||
return new Button('Add shape', {
|
||||
onClick: () => {
|
||||
const command = new AddVectorShapeCommand(assetId);
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
const layer = asset && activeLayerOf(host.state, asset);
|
||||
const command = new AddVectorShapeCommand(assetId, [], undefined, layer?.id);
|
||||
if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId]));
|
||||
},
|
||||
});
|
||||
@@ -441,15 +621,6 @@ function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape):
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
function moveShape(host: InspectorHost, assetId: string, shapeId: string, move: ShapeOrderMove): void {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
if (!asset) return;
|
||||
const from = asset.shapes.findIndex((shape) => shape.id === shapeId);
|
||||
const to = shapeOrderTarget(asset.shapes, shapeId, move);
|
||||
if (from < 0 || to < 0) return;
|
||||
host.state.execute(new MoveVectorShapeCommand(assetId, from, to));
|
||||
}
|
||||
|
||||
/** An outline switched on gets a black line; switched off it is taken away, not made invisible. */
|
||||
function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void {
|
||||
const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ui } from '../ui';
|
||||
import { createContainer, removeSelection } from './actions';
|
||||
import type { EditorState } from './state';
|
||||
import { groupShapes, ungroupShapes } from './vectorart/grouping';
|
||||
|
||||
export interface ShortcutActions {
|
||||
play(): void;
|
||||
@@ -52,6 +53,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
||||
if (mod && key === 'g') {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
|
||||
// On the shapes of a vector drawing it groups them, and with Shift takes the group away (#190).
|
||||
else if (s?.kind === 'shape') (event.shiftKey ? ungroupShapes : groupShapes)(state, s.assetId);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace')
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@ import { CommandStack } from '../core/commands/CommandStack';
|
||||
import type { Asset, SceneAsset } from '../core/model/Asset';
|
||||
import { findAnnotation, type Graph } from '../core/graph';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { allShapes } from '../core/model/vectorTree';
|
||||
import { resolvedHolder } from '../core/model/instance';
|
||||
import {
|
||||
createEmptyProject,
|
||||
@@ -719,7 +720,8 @@ export class EditorState {
|
||||
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
|
||||
if (!asset) this.select(null);
|
||||
else {
|
||||
const alive = s.shapeIds.filter((id) => asset.shapes.some((shape) => shape.id === id));
|
||||
const shapes = allShapes(asset);
|
||||
const alive = s.shapeIds.filter((id) => shapes.some((shape) => shape.id === id));
|
||||
if (alive.length !== s.shapeIds.length)
|
||||
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { imageToLocal, type SheetView, zoomAbout } from './sheetView';
|
||||
/**
|
||||
* The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art
|
||||
* and Vector art tabs. It owns the ground, the clip, the hint and the view: a middle drag or a
|
||||
* Shift drag pans, the wheel zooms, Fit fits, and each document keeps its view. What a left press
|
||||
* Shift drag pans (unless the tab takes the Shift press itself), the wheel zooms, Fit fits, and each document keeps its view. What a left press
|
||||
* does is the tab's; it only gets one while `pressTarget` has something to press on.
|
||||
*/
|
||||
export abstract class SheetCanvas<A> extends Widget {
|
||||
@@ -44,6 +44,13 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
/** The pointer moved while no pan runs. */
|
||||
protected abstract move(local: Vec2): void;
|
||||
protected abstract release(): void;
|
||||
/**
|
||||
* Whether a Shift press here is the tab's own rather than a pan: the vector art tab takes it on a
|
||||
* shape, where it adds to the selection, as the scene view does on an object.
|
||||
*/
|
||||
protected shiftPresses(_target: A, _local: Vec2): boolean {
|
||||
return false;
|
||||
}
|
||||
/** A right-click; a tab with a menu for what lies under the pointer opens it here. */
|
||||
protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {}
|
||||
|
||||
@@ -101,7 +108,8 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
const target = this.pressTarget();
|
||||
if (!target) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
|
||||
if (e.button === 1 || pans) {
|
||||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* The shapes of a vector art asset as a list with a preview each (#129, stage 4). It stands in the
|
||||
* inspector, over the rows that colour the selected shape – a panel of its own is #191.
|
||||
* The shapes of one layer of a vector art asset as a list with a preview each (#129, stage 4). It
|
||||
* stands in the inspector, over the rows that colour the selected shape – a panel of its own is #191.
|
||||
*
|
||||
* Each row draws the shape itself, with the same commands the rasteriser uses, placed on the canvas
|
||||
* it sits on: two shapes of the same colour are told apart by where they are, which a name rarely
|
||||
* says. The list is the drawing order, top row drawn last, and a row is dragged to reorder it.
|
||||
* says. The list is the drawing order, top row drawn last. A group (#190) stands over what is in it,
|
||||
* its rows one step in, and shows all of it in its picture. A row is dragged to another place; the
|
||||
* marker starts where the row would, and dragging right or left picks the group at the end of one.
|
||||
*
|
||||
* The rows are fixed when the list is built: the inspector section rebuilds whenever a shape is
|
||||
* added, removed or renamed. A shape merely edited repaints its preview, which is why the shapes
|
||||
@@ -15,6 +17,15 @@ import type { VectorArtAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { shapeDrawing } from '../../core/model/vectorPath';
|
||||
import {
|
||||
findVectorItem,
|
||||
isVectorGroup,
|
||||
shapesIn,
|
||||
type TreeDrop,
|
||||
treeDrop,
|
||||
treeDropDepth,
|
||||
type TreeRow,
|
||||
} from '../../core/model/vectorTree';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { SortableList, type ThumbClip, type Widget } from '../../ui';
|
||||
@@ -27,39 +38,49 @@ export class ShapeList extends SortableList {
|
||||
private readonly stopPictures: () => void;
|
||||
|
||||
/**
|
||||
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with
|
||||
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and
|
||||
* `tree` is the layer's tree as rows, top first, and `selectedIds` the rows marked. `onPick` is told
|
||||
* the row pressed and whether it was pressed with Ctrl or Shift, which adds the shape to the
|
||||
* selection or takes it out (#187). `onDrop` is told where a dragged row landed. `onDuplicate` and
|
||||
* `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no
|
||||
* plus: a shape is drawn with the Pen, and "Add shape" stays below.
|
||||
*/
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly assetId: string,
|
||||
shapeIds: readonly string[],
|
||||
tree: readonly TreeRow[],
|
||||
selectedIds: readonly string[],
|
||||
rows: {
|
||||
onPick: (shapeId: string, toggle: boolean) => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
onDuplicate: (shapeId: string) => void;
|
||||
onRemove: (shapeId: string) => void;
|
||||
onRename: (shapeId: string, name: string) => void;
|
||||
onPick: (itemId: string, toggle: boolean) => void;
|
||||
onDrop: (itemId: string, drop: TreeDrop) => void;
|
||||
onDuplicate: (itemId: string) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onRename: (itemId: string, name: string) => void;
|
||||
},
|
||||
) {
|
||||
super({
|
||||
title: 'Shapes',
|
||||
...listFold('inspector', 'Shapes'),
|
||||
reversed: true,
|
||||
multiSelect: true,
|
||||
onPick: rows.onPick,
|
||||
onMove: rows.onReorder,
|
||||
onMove: () => {},
|
||||
onDrop: (id, gap, depth) => {
|
||||
const drop = treeDrop(tree, id, gap, depth);
|
||||
if (drop) rows.onDrop(id, drop);
|
||||
},
|
||||
dropDepth: (id, gap, depth) => treeDropDepth(tree, id, gap, depth),
|
||||
onDuplicate: rows.onDuplicate,
|
||||
onRemove: rows.onRemove,
|
||||
onRename: rows.onRename,
|
||||
thumb: (id, art, size) => this.paintSwatch(id, art, size),
|
||||
});
|
||||
const asset = this.asset();
|
||||
this.setItems(
|
||||
shapeIds.map((id) => ({ id, label: asset?.shapes.find((s) => s.id === id)?.name ?? '' })),
|
||||
// Nothing else tells a group's row from a shape's at the same depth.
|
||||
tree.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.name,
|
||||
depth: row.depth,
|
||||
...(row.group ? { hint: 'group' } : {}),
|
||||
})),
|
||||
selectedIds,
|
||||
);
|
||||
// A point dragged in the canvas changes no row, only what a row shows.
|
||||
@@ -87,12 +108,12 @@ export class ShapeList extends SortableList {
|
||||
/**
|
||||
* The shape alone, on the canvas it sits on: the whole canvas is scaled into the swatch, so a
|
||||
* shape in the corner is drawn in the corner. What lies outside the canvas is cut off here as it
|
||||
* is in the picture.
|
||||
* is in the picture. A group shows all its shapes.
|
||||
*/
|
||||
private paintSwatch(shapeId: string, art: Graphics, size: number): ThumbClip | void {
|
||||
private paintSwatch(itemId: string, art: Graphics, size: number): ThumbClip | void {
|
||||
const asset = this.asset();
|
||||
const shape = asset?.shapes.find((s) => s.id === shapeId);
|
||||
if (!asset || !shape) return;
|
||||
const item = asset && findVectorItem(asset, itemId);
|
||||
if (!asset || !item) return;
|
||||
const { width, height } = vectorCanvasSize(asset.props);
|
||||
const scale = Math.min(size / width, size / height);
|
||||
const clip = {
|
||||
@@ -104,8 +125,10 @@ export class ShapeList extends SortableList {
|
||||
art.position.x += clip.x;
|
||||
art.position.y += clip.y;
|
||||
art.scale.set(scale);
|
||||
const drawing = shapeDrawing(shape, this.state.project);
|
||||
if (drawing) paintDrawing(art, drawing, this.paints);
|
||||
for (const shape of isVectorGroup(item) ? shapesIn(item.items) : [item]) {
|
||||
const drawing = shapeDrawing(shape, this.state.project);
|
||||
if (drawing) paintDrawing(art, drawing, this.paints);
|
||||
}
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
* the Texture tab edits a collision outline.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
paintsOf,
|
||||
@@ -29,6 +30,7 @@ import { findAsset } from '../../core/model/Project';
|
||||
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
||||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||||
import { allShapes, drawnShapes, outermostGroup, shapesIn } from '../../core/model/vectorTree';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui';
|
||||
@@ -55,6 +57,7 @@ import { type EditorState, shapeSelection } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
import { SheetCanvas } from '../texture/SheetCanvas';
|
||||
import { fitView, imageToLocal, localToImage } from '../texture/sheetView';
|
||||
import { activeLayerOf } from './activeLayer';
|
||||
|
||||
/**
|
||||
* Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint
|
||||
@@ -68,12 +71,20 @@ export type PaintTarget = 'fill' | 'stroke';
|
||||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||||
const SNAP_STEP = 8;
|
||||
|
||||
/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */
|
||||
interface ShapeDrag {
|
||||
kind: 'point' | 'handle' | 'shape' | 'pen';
|
||||
/** One shape as a drag moves it. `before` is what it read when the drag began – the undo step's other side. */
|
||||
interface MovedShape {
|
||||
shapeId: string;
|
||||
before: VectorRing[];
|
||||
rings: VectorRing[];
|
||||
/** Its paints as they were – they travel with a whole shape. */
|
||||
paints: ReturnType<typeof paintsOf>;
|
||||
}
|
||||
|
||||
/** A gesture in progress, on the shape it began on. */
|
||||
interface ShapeDrag extends MovedShape {
|
||||
kind: 'point' | 'handle' | 'shape' | 'pen';
|
||||
/** The other selected shapes, which a whole shape dragged takes along (#190). */
|
||||
others: MovedShape[];
|
||||
/** Which ring and which of its points the gesture is about; -1 for a whole shape. */
|
||||
ring: number;
|
||||
index: number;
|
||||
@@ -83,9 +94,8 @@ interface ShapeDrag {
|
||||
at: Vec2;
|
||||
moved: boolean;
|
||||
label: string;
|
||||
/** How far a whole shape has been dragged, and its paints as they were – they travel with it. */
|
||||
/** How far a whole shape has been dragged. */
|
||||
delta: Vec2;
|
||||
paints: ReturnType<typeof paintsOf>;
|
||||
}
|
||||
|
||||
/** A paint handle being dragged; `before` is the paint as it was, the undo step's other side. */
|
||||
@@ -329,6 +339,12 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shift on a shape adds it to the selection; on the ground beside the shapes it still pans. */
|
||||
protected override shiftPresses(asset: VectorArtAsset, local: Vec2): boolean {
|
||||
if (this.tool !== 'select') return false;
|
||||
return this.shapeAt(asset, localToImage(this.view, local.x, local.y)) !== undefined;
|
||||
}
|
||||
|
||||
protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||||
const at = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = POINT_SIZE / this.view.zoom;
|
||||
@@ -347,7 +363,15 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const point = this.snapped(at);
|
||||
const open = this.openRing();
|
||||
if (!open) {
|
||||
const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]);
|
||||
const layer = activeLayerOf(this.state, asset);
|
||||
// A line drawn onto a hidden layer could not be seen; the hint says to show the layer first.
|
||||
if (!layer || layer.hidden) return;
|
||||
const command = new AddVectorShapeCommand(
|
||||
asset.id,
|
||||
[{ points: [point], closed: false }],
|
||||
undefined,
|
||||
layer.id,
|
||||
);
|
||||
if (!this.state.execute(command)) return;
|
||||
this.state.select(shapeSelection(asset.id, [command.shapeId]));
|
||||
this.pen = { shapeId: command.shapeId, ring: 0 };
|
||||
@@ -393,6 +417,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
label: 'Draw shape',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
others: [],
|
||||
};
|
||||
this.preview();
|
||||
}
|
||||
@@ -402,8 +427,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
* drawn later: a press on a point picks it and drags it, Alt makes it round or square again, a
|
||||
* press on a handle drags that, and a double-click on a line puts a point there (#288). Inside a
|
||||
* shape's fill, and on its line, the whole shape is dragged; on nothing the selection is cleared.
|
||||
* Ctrl adds a shape to the selection or takes it out again, which is how shapes are picked for
|
||||
* combining (#187).
|
||||
* Ctrl or Shift adds a shape to the selection or takes it out again, as on a row of the list,
|
||||
* which is how shapes are picked for combining (#187). A shape that stands in a group brings the
|
||||
* whole group along, and a shape of the selection carries all of it (#190) – so a group is moved
|
||||
* by dragging any of its shapes.
|
||||
*/
|
||||
private pressSelect(
|
||||
asset: VectorArtAsset,
|
||||
@@ -412,7 +439,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
tolerance: number,
|
||||
e: FederatedPointerEvent,
|
||||
): void {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
const hit = this.shapeAt(asset, at);
|
||||
if (hit) {
|
||||
this.pick(null);
|
||||
@@ -432,7 +459,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const shape = this.shapeAt(asset, at);
|
||||
if (shape) {
|
||||
this.pick(null);
|
||||
this.state.select(shapeSelection(asset.id, [shape.id]));
|
||||
const held = this.selectedShapeIds();
|
||||
const ids = held.includes(shape.id) ? held : this.withGroup(asset, shape.id);
|
||||
this.state.select(shapeSelection(asset.id, ids, shape.id));
|
||||
this.dragShape(shape, at, local);
|
||||
return;
|
||||
}
|
||||
@@ -441,21 +470,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The shape with all that shares its outermost group; alone when it stands in none. */
|
||||
private withGroup(asset: VectorArtAsset, shapeId: string): string[] {
|
||||
const group = outermostGroup(asset.layers, shapeId);
|
||||
return group ? shapesIn(group.items).map((shape) => shape.id) : [shapeId];
|
||||
}
|
||||
|
||||
/** Begins dragging `shape` and, with it, every other selected shape. */
|
||||
private dragShape(shape: VectorShape, at: Vec2, local: Vec2): void {
|
||||
const moved = (one: VectorShape): MovedShape => ({
|
||||
shapeId: one.id,
|
||||
before: one.rings,
|
||||
rings: one.rings.map(cloneVectorRing),
|
||||
paints: paintsOf(one),
|
||||
});
|
||||
const others = this.selectedShapeIds()
|
||||
.filter((id) => id !== shape.id)
|
||||
.flatMap((id) => this.shape(id) ?? [])
|
||||
.map(moved);
|
||||
this.drag = {
|
||||
kind: 'shape',
|
||||
shapeId: shape.id,
|
||||
before: shape.rings,
|
||||
rings: shape.rings.map(cloneVectorRing),
|
||||
...moved(shape),
|
||||
others,
|
||||
ring: -1,
|
||||
index: -1,
|
||||
side: 'out',
|
||||
pointer: { x: local.x, y: local.y },
|
||||
at,
|
||||
moved: false,
|
||||
label: 'Move shape',
|
||||
label: others.length > 0 ? 'Move shapes' : 'Move shape',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: paintsOf(shape),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,8 +536,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
|
||||
/** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */
|
||||
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
|
||||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
||||
const shape = asset.shapes[i]!;
|
||||
// Only what is drawn: a shape on a hidden layer is not there to be pressed.
|
||||
const shapes = drawnShapes(asset);
|
||||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||||
const shape = shapes[i]!;
|
||||
if (shape.rings.some((ring) => insideRing(ring, at))) return shape;
|
||||
}
|
||||
return undefined;
|
||||
@@ -543,6 +589,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
label: grip.kind === 'handle' ? 'Bend shape' : 'Move point',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
others: [],
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
@@ -564,9 +611,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const ring = drag.rings[drag.ring];
|
||||
if (drag.kind === 'shape') {
|
||||
const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y });
|
||||
const before = drag.before;
|
||||
drag.delta = delta;
|
||||
drag.rings = before.map((r) => ringMoved(r, delta.x, delta.y));
|
||||
for (const part of [drag, ...drag.others])
|
||||
part.rings = part.before.map((r) => ringMoved(r, delta.x, delta.y));
|
||||
} else if (ring && drag.kind === 'handle') {
|
||||
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
||||
} else if (ring && drag.kind === 'pen') {
|
||||
@@ -618,15 +665,17 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
return;
|
||||
}
|
||||
const drag = this.drag;
|
||||
const shape = drag && this.shape(drag.shapeId);
|
||||
if (!drag || !shape) return;
|
||||
shape.rings = drag.rings;
|
||||
// A whole shape takes its gradients along (#186), handles and all.
|
||||
if (drag.kind === 'shape') {
|
||||
if (!drag || !this.shape(drag.shapeId)) return;
|
||||
for (const part of [drag, ...drag.others]) {
|
||||
const shape = this.shape(part.shapeId);
|
||||
if (!shape) continue;
|
||||
shape.rings = part.rings;
|
||||
// A whole shape takes its gradients along (#186), handles and all.
|
||||
if (drag.kind !== 'shape') continue;
|
||||
const delta = drag.delta;
|
||||
if (drag.paints.paint) shape.paint = paintMoved(drag.paints.paint, delta.x, delta.y);
|
||||
if (drag.paints.strokePaint && shape.stroke)
|
||||
shape.stroke.paint = paintMoved(drag.paints.strokePaint, delta.x, delta.y);
|
||||
if (part.paints.paint) shape.paint = paintMoved(part.paints.paint, delta.x, delta.y);
|
||||
if (part.paints.strokePaint && shape.stroke)
|
||||
shape.stroke.paint = paintMoved(part.paints.strokePaint, delta.x, delta.y);
|
||||
}
|
||||
this.state.preview();
|
||||
}
|
||||
@@ -640,30 +689,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
this.drag = null;
|
||||
// The gesture is over, so a preview still waiting for its frame has nothing left to write.
|
||||
cancelFrameWork(this.frameKey);
|
||||
const shape = drag && this.shape(drag.shapeId);
|
||||
if (!drag || !shape) return;
|
||||
// The preview moved it already; put it back so the command records the state that was there.
|
||||
shape.rings = drag.before;
|
||||
if (!drag || !this.shape(drag.shapeId)) return;
|
||||
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
|
||||
const moved = drag.kind === 'shape' ? this.movedPaints(drag) : null;
|
||||
if (moved) {
|
||||
if (drag.paints.paint) shape.paint = drag.paints.paint;
|
||||
if (drag.paints.strokePaint && shape.stroke) shape.stroke.paint = drag.paints.strokePaint;
|
||||
const commands: SetVectorShapeRingsCommand[] = [];
|
||||
for (const part of [drag, ...drag.others]) {
|
||||
const shape = this.shape(part.shapeId);
|
||||
if (!shape) continue;
|
||||
// The preview moved it already; put it back so the command records the state that was there.
|
||||
shape.rings = part.before;
|
||||
const moved = drag.kind === 'shape' ? this.movedPaints(part, drag.delta) : null;
|
||||
if (moved) {
|
||||
if (part.paints.paint) shape.paint = part.paints.paint;
|
||||
if (part.paints.strokePaint && shape.stroke) shape.stroke.paint = part.paints.strokePaint;
|
||||
}
|
||||
if (SetVectorShapeRingsCommand.changes(shape, part.rings))
|
||||
commands.push(
|
||||
new SetVectorShapeRingsCommand(this.assetId, part.shapeId, part.rings, drag.label, moved),
|
||||
);
|
||||
}
|
||||
if (SetVectorShapeRingsCommand.changes(shape, drag.rings))
|
||||
this.state.execute(
|
||||
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
|
||||
);
|
||||
// Several shapes dragged together are one undo step.
|
||||
if (commands.length > 0) this.state.execute(oneStep(drag.label, commands));
|
||||
else this.state.preview();
|
||||
}
|
||||
|
||||
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
|
||||
private movedPaints(drag: ShapeDrag): ReturnType<typeof paintsOf> | null {
|
||||
const { paint, strokePaint } = drag.paints;
|
||||
private movedPaints(part: MovedShape, delta: Vec2): ReturnType<typeof paintsOf> | null {
|
||||
const { paint, strokePaint } = part.paints;
|
||||
if (!paint && !strokePaint) return null;
|
||||
return {
|
||||
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}),
|
||||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.delta.y) } : {}),
|
||||
...(paint ? { paint: paintMoved(paint, delta.x, delta.y) } : {}),
|
||||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, delta.x, delta.y) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -706,7 +761,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
// Its line carries the selected shape as its fill does; a point or a handle is its own grip.
|
||||
const onLine = grips.includes('segment');
|
||||
const onGrip = grips.includes('point') || grips.includes('handle');
|
||||
const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at)));
|
||||
const inside = this.shapeAt(asset, at) !== undefined;
|
||||
this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor;
|
||||
}
|
||||
|
||||
@@ -860,10 +915,12 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
: this.tool === 'pen'
|
||||
? this.drawing
|
||||
? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.'
|
||||
: 'Pen: click to start a shape.'
|
||||
: activeLayerOf(this.state, asset)?.hidden
|
||||
? 'Pen: this layer is hidden – show it in the inspector to draw on it.'
|
||||
: 'Pen: click to start a shape.'
|
||||
: this.tool === 'paint'
|
||||
? this.paintHint()
|
||||
: asset.shapes.length === 0
|
||||
: allShapes(asset).length === 0
|
||||
? 'Nothing drawn yet – take the Pen and click on the canvas.'
|
||||
: '';
|
||||
this.hint.text = text;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { allShapes } from '../../core/model/vectorTree';
|
||||
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
@@ -95,7 +96,7 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
|
||||
protected override infoText(asset: VectorArtAsset): string {
|
||||
const { width, height } = vectorCanvasSize(asset.props);
|
||||
const count = asset.shapes.length;
|
||||
const count = allShapes(asset).length;
|
||||
return `${width} × ${height} px · ${count} ${count === 1 ? 'shape' : 'shapes'}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Which layer of a vector drawing is the one being worked on (#190): the pen draws onto it and the
|
||||
* inspector lists its shapes. A selected shape says it by where it stands; while none is selected it
|
||||
* is the layer picked last, and the topmost one before any was picked.
|
||||
*
|
||||
* Kept by asset and outside the selection, so picking the drawing itself does not forget it.
|
||||
*/
|
||||
import type { VectorArtAsset, VectorLayer } from '../../core/model/Asset';
|
||||
import { layerOfItem } from '../../core/model/vectorTree';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
const picked = new Map<string, string>();
|
||||
|
||||
/** `shapeId` is the selected shape of this drawing, if one is. */
|
||||
export function activeVectorLayer(asset: VectorArtAsset, shapeId = ''): VectorLayer | undefined {
|
||||
const selected = shapeId ? layerOfItem(asset, shapeId) : undefined;
|
||||
if (selected) {
|
||||
picked.set(asset.id, selected.id);
|
||||
return selected;
|
||||
}
|
||||
return (
|
||||
asset.layers.find((layer) => layer.id === picked.get(asset.id)) ?? asset.layers[asset.layers.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
/** The same, read off the selection. */
|
||||
export function activeLayerOf(state: EditorState, asset: VectorArtAsset): VectorLayer | undefined {
|
||||
const s = state.selection;
|
||||
return activeVectorLayer(asset, s?.kind === 'shape' && s.assetId === asset.id ? s.shapeId : '');
|
||||
}
|
||||
|
||||
/** Makes a layer the active one; the shapes of another layer cannot stay selected then. */
|
||||
export function pickVectorLayer(state: EditorState, assetId: string, layerId: string): void {
|
||||
picked.set(assetId, layerId);
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'shape' && s.assetId === assetId) state.select({ kind: 'asset', assetId });
|
||||
state.preview();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Grouping the selected shapes of a vector drawing (#190) – what the inspector's two buttons and
|
||||
* Ctrl+G both do.
|
||||
*/
|
||||
import { GroupVectorShapesCommand, UngroupVectorShapesCommand } from '../../core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { findVectorItem, isVectorGroup, placeOf, selectedItems } from '../../core/model/vectorTree';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Why the selected shapes cannot be grouped, or null when they can. */
|
||||
export function groupBlocker(asset: VectorArtAsset, shapeIds: readonly string[]): string | null {
|
||||
const items = selectedItems(asset.layers, shapeIds);
|
||||
if (items.length === 0) return 'Nothing is selected.';
|
||||
const layers = new Set(items.map((id) => placeOf(asset.layers, id)?.layer.id));
|
||||
return layers.size > 1 ? 'Shapes of different layers cannot be grouped.' : null;
|
||||
}
|
||||
|
||||
/** The groups Ungroup takes away: those picked as a whole, else the one the primary shape is in. */
|
||||
export function groupsToDissolve(
|
||||
asset: VectorArtAsset,
|
||||
shapeId: string,
|
||||
shapeIds: readonly string[],
|
||||
): string[] {
|
||||
const picked = selectedItems(asset.layers, shapeIds).filter((id) => {
|
||||
const item = findVectorItem(asset, id);
|
||||
return item !== undefined && isVectorGroup(item);
|
||||
});
|
||||
if (picked.length > 0) return picked;
|
||||
const parent = placeOf(asset.layers, shapeId)?.parent;
|
||||
return parent ? [parent.id] : [];
|
||||
}
|
||||
|
||||
/** Groups the selection; the shapes stay selected, so the new group reads as picked. */
|
||||
export function groupShapes(state: EditorState, assetId: string): boolean {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
const s = state.selection;
|
||||
if (!asset || s?.kind !== 'shape' || s.assetId !== assetId) return false;
|
||||
if (groupBlocker(asset, s.shapeIds) !== null) return false;
|
||||
return state.execute(new GroupVectorShapesCommand(assetId, selectedItems(asset.layers, s.shapeIds)));
|
||||
}
|
||||
|
||||
export function ungroupShapes(state: EditorState, assetId: string): boolean {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
const s = state.selection;
|
||||
if (!asset || s?.kind !== 'shape' || s.assetId !== assetId) return false;
|
||||
const groups = groupsToDissolve(asset, s.shapeId, s.shapeIds);
|
||||
return groups.length > 0 && state.execute(new UngroupVectorShapesCommand(assetId, groups));
|
||||
}
|
||||
Reference in new issue
Block a user