/** * The rows of a vector art shape (#129, stage 4, answer 8): the list of shapes with a preview each, * the four buttons that move one in the drawing order, and the style of the selected one – fill, * opacity and outline. The canvas size is an ordinary property and stands in the asset's own rows. * * The list is built for both selections: picking the asset shows the shapes without any of them * being chosen, picking a shape shows the same list with its style underneath. That is why the * asset's signature has to name the shapes too – a shape added, removed or renamed changes the * rows, and only a rebuild puts the new list on screen. */ import { AddVectorLayerCommand, CombineVectorShapesCommand, DuplicateVectorShapeCommand, 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 { allShapes, canOrderItem, findVectorItem, isVectorGroup, placeOf, selectedItems, type ShapeOrderMove, shapesIn, treeRows, } from '../../core/model/vectorTree'; import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean'; import { FILL_KINDS, type FillKind, fillKind, fillPaintFor, MIN_STOPS, paintExtent, STROKE_KINDS, type StrokeKind, strokeKind, strokePaintFor, withoutStop, withStopChanged, } from '../../core/model/vectorPaint'; import type { PropertyDef } from '../../core/properties/PropertySchema'; 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 { 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'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; import { addTransformRows, hasShapeFrame, refreshTransformRows } from './shapeTransformRows'; /** The outline a shape gets when one is switched on: black, two canvas pixels wide. */ const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 }; /** What a style row writes while it is being dragged; nothing is ever taken away by a preview. */ interface PreviewStyle { fill?: number; opacity?: number; paint?: VectorPaint; stroke?: VectorStroke; } /** Whose gradient a bar and its stop rows edit: the fill's or the outline's (#186). */ export type PaintPart = 'fill' | 'stroke'; const FILL_KIND_LABELS: Record = { solid: 'Solid color', linear: 'Linear gradient', radial: 'Radial gradient', texture: 'Texture', }; const FILL_TYPE_DEF: PropertyDef = { key: 'fillType', label: 'Fill type', kind: 'enum', default: 'solid', options: FILL_KINDS.map((value) => ({ value, label: FILL_KIND_LABELS[value], })), }; const LINE_TYPE_DEF: PropertyDef = { key: 'lineType', label: 'Line type', kind: 'enum', default: 'solid', options: STROKE_KINDS.map((value) => ({ value, label: FILL_KIND_LABELS[value], })), }; const TEXTURE_DEF: PropertyDef = { key: 'fillTexture', label: 'Texture', kind: 'texture', default: '', uploadedOnly: true, }; /** The three rows of the picked stop; the outline's carry a prefix so both sets can stand at once. */ function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } { const prefix = part === 'fill' ? 'fillStop' : 'lineStop'; return { color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff }, alpha: { key: `${prefix}Alpha`, label: 'Stop opacity', kind: 'number', default: 1, min: 0, max: 1, step: 0.05, decimals: 2, }, offset: { key: `${prefix}Offset`, label: 'Stop position', kind: 'number', default: 0, min: 0, max: 1, step: 0.01, decimals: 2, }, }; } /** * The stop picked in each bar, by asset, shape and part. It outlives a rebuild of the rows – undo * rebuilds them – and falls back to the first stop when the one it names has gone. */ const pickedStops = new Map(); function pickedStop( assetId: string, shapeId: string, part: PaintPart, gradient: VectorGradient | undefined, ): number { const index = pickedStops.get(`${assetId}/${shapeId}/${part}`) ?? 0; return gradient && index < gradient.stops.length ? index : 0; } /** The bars as they were last built, for the check scripts. */ const builtBars = new Map(); export function currentGradientBar(part: PaintPart): GradientBar | null { return builtBars.get(part) ?? null; } const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [ { move: 'front', label: 'To front' }, { move: 'forward', label: 'Forward' }, { move: 'backward', label: 'Backward' }, { move: 'back', label: 'To back' }, ]; const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [ { op: 'union', label: 'Union' }, { op: 'subtract', label: 'Subtract' }, { op: 'intersect', label: 'Intersect' }, { 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', label: 'Opacity', kind: 'number', default: 1, min: 0, max: 1, step: 0.05, decimals: 2, }; const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false }; const STROKE_COLOR_DEF: PropertyDef = { key: 'strokeColor', label: 'Line color', kind: 'color', default: DEFAULT_STROKE.color, }; const STROKE_WIDTH_DEF: PropertyDef = { key: 'strokeWidth', label: 'Line width', kind: 'number', default: DEFAULT_STROKE.width, min: 0.1, step: 0.5, decimals: 1, }; /** 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; } /** Null while no vector art is in the inspector. */ export function currentLayerList(): SortableList | null { return builtLayers; } /** * 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. */ export function shapeSignature( project: Project, assetId: string, shapeId: string, shapeIds: readonly string[], ): string { const asset = findAssetOf(project, assetId, 'vectorart'); const shape = asset && findShape(asset, shapeId); if (!asset || !shape) return 'missing'; const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? ''; const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`; // A shape gets its TRANSFORM rows with its second point: before that it has no size to show. const framed = hasShapeFrame(asset, shapeIds) ? 'framed' : 'bare'; return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}|${framed}`; } /** The selected shapes in drawing order, the lowest first – the order they are combined in. */ function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] { 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 { addLayerList(host, asset, ''); addShapeList(host, asset, '', []); addNote(host, 'The top row is drawn over the ones below it.', 'small'); // No button adds a shape (#293): one without points could not be given any. addNote(host, 'The Pen draws a new shape.', 'small'); } /** * 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, assetId: string, shapeId: string, shapeIds: readonly string[], ): void { const asset = findAssetOf(host.state.project, assetId, 'vectorart'); const shape = asset && findShape(asset, shapeId); if (!asset || !shape) return; 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: () => void host.state.execute(new OrderVectorShapeCommand(assetId, ordered, move)), }); // 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); addTransformRows(host, assetId, shapeIds); addCombineButtons(host, asset, shapeIds); addTitle(host, 'VECTOR SHAPE'); addShapeName(host, assetId, shape); addLayerRow(host, asset, shapeId, shapeIds); fillRows(host, assetId, shape); styleRow( host, assetId, shapeId, OPACITY_DEF, (s) => s.opacity, (input) => ({ opacity: Number(input) }), 'Set opacity', ); outlineRow(host, assetId, shapeId, shape); if (shape.stroke) { kindRow(host, assetId, shape, 'stroke'); if (strokeKind(shape) === 'solid') styleRow( host, assetId, shapeId, STROKE_COLOR_DEF, (s) => s.stroke?.color ?? DEFAULT_STROKE.color, (input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }), 'Set line color', ); else gradientRows(host, assetId, shapeId, 'stroke'); styleRow( host, assetId, shapeId, STROKE_WIDTH_DEF, (s) => s.stroke?.width ?? DEFAULT_STROKE.width, (input, s) => ({ stroke: { ...strokeOf(s), width: Math.max(0.1, Number(input)) } }), 'Set line width', ); } else addNote(host, 'Without an outline only the fill is drawn.', 'small'); // One shape goes by the cross on its row; several at once only from here. There is no button // that adds one (#293): a shape without points could not be given any, the Pen draws a new one. if (shapeIds.length > 1) { addGap(host); host.body.add( new Button('Delete shapes', { variant: 'danger', onClick: () => removeShapes(host.state, assetId, shapeIds), }), ); } } /** * 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. */ function addCombineButtons(host: InspectorHost, asset: VectorArtAsset, shapeIds: readonly string[]): void { const shapes = selectedShapes(asset, shapeIds); const blocked = combineBlocker(shapes); addTitle(host, 'COMBINE'); for (let start = 0; start < COMBINE_BUTTONS.length; start += 2) { const row = new HStack({ gap: Theme.gap }); for (const { op, label } of COMBINE_BUTTONS.slice(start, start + 2)) { const button = new Button(label, { variant: 'ghost', onClick: () => { const command = new CombineVectorShapesCommand(asset.id, shapeIds, op); if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId])); }, }); button.setEnabled(blocked === null); row.add(button, 1); } host.body.add(row); } addNote(host, blocked ?? `The result keeps "${shapes[0]?.name ?? ''}", the lowest.`, 'small'); } /** Pushes the shape's current name and style into the rows. False when it is gone (rebuild). */ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeId: string): boolean { 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 ?? ''); refreshTransformRows(host, assetId, host.state.selectedShapeIds); 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)); host.fields.get(TEXTURE_DEF.key)?.setValue(shape.paint?.type === 'texture' ? shape.paint.texture : ''); refreshStopRows(host, assetId, shape, 'fill'); refreshStopRows(host, assetId, shape, 'stroke'); host.fields.get('opacity')?.setValue(shape.opacity); host.fields.get('outline')?.setValue(Boolean(shape.stroke)); host.fields.get('strokeColor')?.setValue(shape.stroke?.color ?? DEFAULT_STROKE.color); host.fields.get('strokeWidth')?.setValue(shape.stroke?.width ?? DEFAULT_STROKE.width); return true; } /** 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, 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 { 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 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, itemId)) || !wasSelected) return; host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId }); } /** * The name row. Not `addNameField`: the shape's name also stands in the list right above it, and a * blank one is refused by the command, which cannot put the old text back into the field. */ function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape): void { const row = createFieldRow(host, { key: 'name', label: 'Name', kind: 'text', default: '' }, shape.name, { onInput: () => {}, onChange: (value) => { const name = textOf(value).trim(); if (name && name !== shape.name) host.state.execute(new RenameVectorShapeCommand(assetId, shape.id, name)); else host.fields.get('name')?.setValue(shape.name); }, }); host.fields.set('name', row); host.nameField = null; host.body.add(row.row); } /** 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), { onInput: () => {}, onChange: (value) => { const current = currentShape(host, assetId, shapeId); if (!current) return; const stroke = value ? (current.stroke ?? DEFAULT_STROKE) : null; host.state.execute( new SetVectorShapeStyleCommand( assetId, shapeId, { stroke }, value ? 'Add outline' : 'Remove outline', ), ); }, }); host.fields.set('outline', row); host.body.add(row.row); } /** * One style row, previewing into the shape while a colour is dragged and committing on release – * the same rule every other row follows. `toStyle` is asked on the shape as it is at that moment, * so a line width typed after a colour does not take the colour back. */ function styleRow( host: InspectorHost, assetId: string, shapeId: string, def: PropertyDef, read: (shape: VectorShape) => number, toStyle: (input: PropertyValue, shape: VectorShape) => PreviewStyle, label: string, ): void { const shape = currentShape(host, assetId, shapeId); const handlers: FieldHandlers = previewingHandlers(host, { target: () => currentShape(host, assetId, shapeId), read, write: (target, value) => applyPreview(target, toStyle(value, target)), commit: (value) => new SetVectorShapeStyleCommand( assetId, shapeId, toStyle(value, shapeNow(host, assetId, shapeId)), label, ), }); const row = createFieldRow(host, def, shape ? read(shape) : def.default, handlers); host.fields.set(def.key, row); host.body.add(row.row); } /** Writes a previewed style straight into the shape; the command records the value from before. */ function applyPreview(shape: VectorShape, style: PreviewStyle): void { if (style.fill !== undefined) shape.fill = style.fill; if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity)); if (style.paint !== undefined) shape.paint = style.paint; if (style.stroke !== undefined) shape.stroke = { ...style.stroke }; } function strokeOf(shape: VectorShape): VectorStroke { return shape.stroke ?? DEFAULT_STROKE; } function currentShape(host: InspectorHost, assetId: string, shapeId: string): VectorShape | undefined { const asset = findAssetOf(host.state.project, assetId, 'vectorart'); return asset ? findShape(asset, shapeId) : undefined; } /** The shape as it is right now, or an empty stand-in – a commit built after the selection went. */ function shapeNow(host: InspectorHost, assetId: string, shapeId: string): VectorShape { return ( currentShape(host, assetId, shapeId) ?? { id: shapeId, name: '', rings: [], fill: SHAPE_FILL_DEFAULT, opacity: 1, } ); } /** * The fill: its kind, and under it what that kind is made of – the colour, the gradient's bar and * stops, or the picture. The colour row goes while a paint fills the shape; the colour itself stays * on the shape and is what "Solid colour" brings back. */ function fillRows(host: InspectorHost, assetId: string, shape: VectorShape): void { kindRow(host, assetId, shape, 'fill'); const kind = fillKind(shape); if (kind === 'solid') styleRow( host, assetId, shape.id, FILL_DEF, (s) => s.fill, (input) => ({ fill: Number(input) }), 'Set fill', ); else if (kind === 'texture') textureRow(host, assetId, shape); else gradientRows(host, assetId, shape.id, 'fill'); } /** "Fill type" or "Line type"; what picking one does is `setPaintKind`. */ function kindRow(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void { const def = part === 'fill' ? FILL_TYPE_DEF : LINE_TYPE_DEF; const value = part === 'fill' ? fillKind(shape) : strokeKind(shape); const row = createFieldRow(host, def, value, { onInput: () => {}, onChange: (input) => setPaintKind(host.state, assetId, shape.id, part, textOf(input)), }); host.fields.set(def.key, row); host.body.add(row.row); } /** * Switches the fill or the outline to another kind: the paint is laid out over the shape anew, a * gradient keeps its colours when it only changes between linear and radial, and it is one undo * step. Exported because the row's menu is the only other way in, and a test has no menus. */ export function setPaintKind( state: EditorState, assetId: string, shapeId: string, part: PaintPart, kind: string, ): void { const asset = findAssetOf(state.project, assetId, 'vectorart'); const shape = asset && findShape(asset, shapeId); if (!asset || !shape) return; const extent = paintExtent(shapeExtent(shape), vectorCanvasSize(asset.props)); if (part === 'fill') { if (kind === fillKind(shape) || !(FILL_KINDS as readonly string[]).includes(kind)) return; const paint = fillPaintFor(shape, kind as FillKind, extent); state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill type')); } else if (shape.stroke) { if (kind === strokeKind(shape) || !(STROKE_KINDS as readonly string[]).includes(kind)) return; const paint = strokePaintFor(shape, kind as StrokeKind, extent); const { color, width } = shape.stroke; const stroke: VectorStroke = paint ? { color, width, paint } : { color, width }; state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { stroke }, 'Line type')); } } /** The picture of a texture fill; only uploaded textures and their frames are offered. */ function textureRow(host: InspectorHost, assetId: string, shape: VectorShape): void { const value = shape.paint?.type === 'texture' ? shape.paint.texture : ''; const row = createFieldRow(host, TEXTURE_DEF, value, { onInput: () => {}, onChange: (input) => setFillTexture(host.state, assetId, shape.id, textOf(input)), }); host.fields.set(TEXTURE_DEF.key, row); host.body.add(row.row); addNote(host, 'Paint tool: drag the corners of one tile.', 'small'); } /** Puts a picture into a texture fill; exported for the same reason as `setPaintKind`. */ export function setFillTexture(state: EditorState, assetId: string, shapeId: string, texture: string): void { const asset = findAssetOf(state.project, assetId, 'vectorart'); const shape = asset && findShape(asset, shapeId); if (shape?.paint?.type !== 'texture') return; const paint = { ...shape.paint, texture }; state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill texture')); } /** * The bar of a gradient and the three rows of its picked stop, then the button that takes the * picked stop out – there to be seen with two stops too, switched off, since a gradient needs two. */ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, part: PaintPart): void { const key = `${assetId}/${shapeId}/${part}`; const gradientOf = (): VectorGradient | undefined => { const shape = currentShape(host, assetId, shapeId); return shape ? gradientIn(shape, part) : undefined; }; const perFrame = new FramePreview(host.state, 'gradient-stop'); const bar = new GradientBar(host.state, { gradient: gradientOf, selected: () => pickedStop(assetId, shapeId, part, gradientOf()), select: (index) => { pickedStops.set(key, index); const shape = currentShape(host, assetId, shapeId); if (shape) refreshStopRows(host, assetId, shape, part); bar.invalidate(); }, preview: (gradient) => { const shape = currentShape(host, assetId, shapeId); if (!shape) return; applyPreview(shape, withGradient(shape, part, gradient)); perFrame.request(); }, commit: (before, after, label) => { perFrame.cancel(); const shape = currentShape(host, assetId, shapeId); if (!shape) return; applyPreview(shape, withGradient(shape, part, before)); host.state.execute( new SetVectorShapeStyleCommand(assetId, shapeId, withGradient(shape, part, after), label), ); }, }); builtBars.set(part, bar); host.body.add(bar); const defs = stopDefs(part); const stopRow = ( def: PropertyDef, read: (stop: { color: number; alpha: number; offset: number }) => number, field: 'color' | 'alpha' | 'offset', label: string, ) => styleRow( host, assetId, shapeId, def, (s) => { const gradient = gradientIn(s, part); const stop = gradient?.stops[pickedStop(assetId, shapeId, part, gradient)]; return stop ? read(stop) : Number(def.default); }, (input, s) => { const gradient = gradientIn(s, part); if (!gradient) return {}; const index = pickedStop(assetId, shapeId, part, gradient); const next = withStopChanged(gradient, index, { [field]: Number(input) }); return next ? withGradient(s, part, next) : {}; }, label, ); stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop color'); stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity'); stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop'); const remove = new Button('Remove stop', { variant: 'ghost', onClick: () => { const shape = currentShape(host, assetId, shapeId); const gradient = shape && gradientIn(shape, part); if (!shape || !gradient) return; const next = withoutStop(gradient, pickedStop(assetId, shapeId, part, gradient)); if (!next) return; pickedStops.set(key, 0); host.state.execute( new SetVectorShapeStyleCommand( assetId, shapeId, withGradient(shape, part, next), 'Remove color stop', ), ); }, }); remove.setEnabled((gradientOf()?.stops.length ?? 0) > MIN_STOPS); removeButtons.set(part, remove); host.body.add(remove); addNote(host, 'Click the bar to add a stop. Paint tool: drag the ends.', 'small'); } /** The "Remove stop" buttons as last built; a stop added or taken out switches them. */ const removeButtons = new Map(); /** The picked stop's values into its three rows, after a pick, an undo or a drag on the bar. */ function refreshStopRows(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void { const gradient = gradientIn(shape, part); if (!gradient) return; const stop = gradient.stops[pickedStop(assetId, shape.id, part, gradient)]; const defs = stopDefs(part); if (stop) { host.fields.get(defs.color.key)?.setValue(stop.color); host.fields.get(defs.alpha.key)?.setValue(stop.alpha); host.fields.get(defs.offset.key)?.setValue(stop.offset); } removeButtons.get(part)?.setEnabled(gradient.stops.length > MIN_STOPS); } function gradientIn(shape: VectorShape, part: PaintPart): VectorGradient | undefined { const paint = part === 'fill' ? shape.paint : shape.stroke?.paint; return paint && paint.type !== 'texture' ? paint : undefined; } /** A style that puts `gradient` where `part` says: the fill's paint, or the outline's. */ function withGradient(shape: VectorShape, part: PaintPart, gradient: VectorGradient): PreviewStyle { return part === 'fill' ? { paint: gradient } : { stroke: { ...strokeOf(shape), paint: gradient } }; }