feat: enhance layer masking functionality and brush controls

- Refactor LayersSheet component to support mask editing state and improve layer visibility handling.
- Introduce functions to collect mask layer IDs and count display layers excluding masks.
- Update BrushControls to include mask view mode options and a Done button for exiting mask editing.
- Modify brush session handling to support brush previews when editing masks.
- Implement a new BrushPreviewRenderer for rendering brush strokes with visual feedback.
- Add document geometry utilities for transforming points and resolving layer bounds.
- Ensure proper cleanup of brush preview on pointer leave and other interactions.
This commit is contained in:
syntaxbullet
2026-07-03 20:49:29 +02:00
parent daaa2a6667
commit 4ad0bb8b2c
33 changed files with 1889 additions and 351 deletions

107
input/document-geometry.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { ImageDocument } from "@core/document";
import type { Rect, Size, Vec2D } from "@core/geometry";
import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
export type InputViewportState = {
center: Vec2D;
zoom: number;
rotation?: number;
size: Size;
};
export type InputSelectionState = {
artboardId?: ArtboardId;
layerIds: LayerId[];
};
export type InputTransformTarget =
| { type: "artboard"; id: ArtboardId }
| { type: "layer"; id: LayerId };
export function selectedTransformTarget(_document: ImageDocument, selection: InputSelectionState): InputTransformTarget | undefined {
if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
return undefined;
}
export function resolveTransformTargetBounds(document: ImageDocument, target: InputTransformTarget): Rect | undefined {
switch (target.type) {
case "artboard":
return document.artboards.find((artboard) => artboard.id === target.id)?.bounds;
case "layer": {
const layer = findLayer(document, target.id);
return layer ? resolveLayerBounds(document, layer) : undefined;
}
}
}
export function viewportPointToDocumentPoint(point: Vec2D, viewport: InputViewportState): Vec2D {
return {
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
};
}
export function documentRectToViewportRect(rect: Rect, viewport: InputViewportState): Rect {
return {
x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom,
y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom,
w: rect.w * viewport.zoom,
h: rect.h * viewport.zoom,
};
}
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return layer;
}
return undefined;
}
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayerInTree(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}
function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undefined {
switch (layer.type) {
case "group":
return unionRects(layer.children.flatMap((child) => {
const bounds = resolveLayerBounds(document, child);
return bounds ? [bounds] : [];
}));
case "image":
case "raster": {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
return {
x: layer.transform.position.x,
y: layer.transform.position.y,
w: asset.intrinsicSize.w * layer.transform.scale.x,
h: asset.intrinsicSize.h * layer.transform.scale.y,
};
}
}
}
function unionRects(rects: Rect[]): Rect | undefined {
if (rects.length === 0) return undefined;
const minX = Math.min(...rects.map((rect) => rect.x));
const minY = Math.min(...rects.map((rect) => rect.y));
const maxX = Math.max(...rects.map((rect) => rect.x + rect.w));
const maxY = Math.max(...rects.map((rect) => rect.y + rect.h));
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

View File

@@ -3,7 +3,6 @@ import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { SelectionState } from "@editor/state";
import type { KeybindEvent } from "./keyboard";
export type LayerInfo = {
@@ -17,7 +16,12 @@ export type LayerDropTarget = {
layer: Layer;
};
export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: SelectionState; dispatch: Dispatch }): boolean {
export type DeleteSelectionState = {
artboardId?: ArtboardId;
layerIds: LayerId[];
};
export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: DeleteSelectionState; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false;

View File

@@ -1,30 +1,23 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { createInitialAppState } from "@editor/initial-state";
import type { ImageDocument } from "@core/document";
import { handleArtboardSelection } from "./selection";
import type { PointerInputEvent } from "./pointer";
const ignoredState = undefined as never;
const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
describe("selection input", () => {
test("selects artboard hit by left click", () => {
const state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
},
};
const document = createDocument({
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
});
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document: state.document,
viewport: state.editor.viewport,
document,
viewport,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
@@ -36,45 +29,37 @@ describe("selection input", () => {
});
test("selects topmost image layer before artboard", () => {
const state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: -100, y: -100, w: 200, h: 200 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
{
id: "l1",
type: "image",
name: "Image",
visible: true,
locked: false,
opacity: 1,
assetId: "asset-1",
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
],
},
],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
},
};
const document = createDocument({
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: -100, y: -100, w: 200, h: 200 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
{
id: "l1",
type: "image",
name: "Image",
visible: true,
locked: false,
opacity: 1,
assetId: "asset-1",
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
],
},
],
});
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document: state.document,
viewport: state.editor.viewport,
document,
viewport,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
@@ -85,14 +70,70 @@ describe("selection input", () => {
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]);
});
test("clears selection when clicking outside artboards", () => {
const state = createInitialAppState("Test");
test("ignores mask layers during canvas hit testing", () => {
const document = createDocument({
assets: [
{ id: "target-asset", name: "Target", mimeType: "image/png", source: "target", intrinsicSize: { w: 50, h: 50 } },
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 50, h: 50 } },
],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: -100, y: -100, w: 200, h: 200 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
{
id: "target",
type: "image",
name: "Target",
visible: true,
locked: false,
opacity: 1,
assetId: "target-asset",
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
clippingMask: { maskLayerId: "mask" },
},
{
id: "mask",
type: "raster",
name: "Mask",
visible: true,
locked: false,
opacity: 1,
assetId: "mask-asset",
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
],
},
],
});
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document: state.document,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
document,
viewport,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["target"] } }]);
});
test("clears selection when clicking outside artboards", () => {
const document = createDocument();
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document,
viewport,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
@@ -104,6 +145,17 @@ describe("selection input", () => {
});
});
function createDocument(overrides: Partial<ImageDocument> = {}): ImageDocument {
return {
id: "d1",
name: "Test",
version: 1,
assets: [],
artboards: [],
...overrides,
};
}
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
return {
pointerId: 1,

View File

@@ -2,14 +2,13 @@ import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { ViewportState } from "@editor/state";
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
import type { PointerInputEvent } from "./pointer";
export function handleArtboardSelection(options: {
event: PointerInputEvent;
document: ImageDocument;
viewport: ViewportState;
viewport: InputViewportState;
dispatch: Dispatch;
}): boolean {
if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false;
@@ -37,20 +36,21 @@ export function handleArtboardSelection(options: {
}
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
const maskLayerIds = collectMaskLayerIds(document.artboards.flatMap((artboard) => artboard.layers));
for (const artboard of [...document.artboards].reverse()) {
if (!artboard.visible || artboard.locked) continue;
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point, maskLayerIds);
if (layerId) return { artboardId: artboard.id, layerId };
}
return undefined;
}
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined {
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
for (const layer of layers) {
if (!layer.visible || layer.locked) continue;
if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue;
if (layer.type === "group") {
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point, maskLayerIds);
if (childId) return childId;
}
@@ -63,9 +63,11 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
return undefined;
}
function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) {
return {
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
};
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}

View File

@@ -1,35 +1,30 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { createInitialAppState } from "@editor/initial-state";
import type { ImageDocument } from "@core/document";
import type { PointerInputEvent } from "./pointer";
import { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls";
const ignoredState = undefined as never;
const defaultViewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
const defaultTools = {
activeTool: "select" as const,
interactionMode: { type: "tool" as const, tool: "select" as const },
};
describe("transform controls input", () => {
test("hit tests artboard handles and body", () => {
const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
const bounds = { x: -50, y: -50, w: 100, h: 100 };
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, viewport)).toBe("nw");
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, viewport)).toBe("body");
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined();
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, defaultViewport)).toBe("nw");
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, defaultViewport)).toBe("body");
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, defaultViewport)).toBeUndefined();
});
test("does not transform while temporary pan is active", () => {
const state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
selection: { artboardId: "a1", layerIds: [] },
tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const }, brush: { color: "#111827", size: 8, hardness: 100 } },
},
};
const state = createState({
selection: { artboardId: "a1", layerIds: [] },
tools: { activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } },
});
const dispatched: unknown[] = [];
const controller = createTransformControlsInputController({
getDocument: () => state.document,
@@ -45,19 +40,10 @@ describe("transform controls input", () => {
});
test("crop tool resizes from handles but does not move body", () => {
const state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
selection: { artboardId: "a1", layerIds: [] },
tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const }, brush: { color: "#111827", size: 8, hardness: 100 } },
},
};
const state = createState({
selection: { artboardId: "a1", layerIds: [] },
tools: { activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } },
});
const dispatched: unknown[] = [];
const controller = createTransformControlsInputController({
getDocument: () => state.document,
@@ -73,25 +59,14 @@ describe("transform controls input", () => {
});
test("dispatches transform lifecycle for selected artboard", () => {
let state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
selection: { artboardId: "a1", layerIds: [] },
},
};
let state = createState({ selection: { artboardId: "a1", layerIds: [] } });
const dispatched: unknown[] = [];
const controller = createTransformControlsInputController({
getDocument: () => state.document,
getEditor: () => state.editor,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload as never } };
if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload } };
return ignoredState;
},
});
@@ -108,6 +83,24 @@ describe("transform controls input", () => {
});
});
function createState(editorOverrides: Partial<TransformControlsEditorState> = {}): { document: ImageDocument; editor: TransformControlsEditorState } {
return {
document: {
id: "d1",
name: "Test",
version: 1,
assets: [],
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
},
editor: {
viewport: defaultViewport,
selection: { layerIds: [] },
tools: defaultTools,
...editorOverrides,
},
};
}
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
return {
pointerId: 1,

View File

@@ -2,13 +2,36 @@ import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { Rect, Vec2D } from "@core/geometry";
import type { EditorState } from "@editor/state";
import { isPanInteractionMode } from "@editor/tools";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { TransformHandle } from "@editor/transform";
import {
documentRectToViewportRect,
resolveTransformTargetBounds,
selectedTransformTarget,
viewportPointToDocumentPoint,
type InputSelectionState,
type InputTransformTarget,
type InputViewportState,
} from "./document-geometry";
import { findLayerInfoInDocument } from "./layers-panel";
import type { PointerInputEvent } from "./pointer";
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan";
type InputInteractionMode =
| { type: "tool"; tool: InputToolId }
| { type: "temporary-pan"; previousTool: InputToolId };
export type TransformControlsEditorState = {
viewport: InputViewportState;
selection: InputSelectionState;
tools: {
activeTool: InputToolId;
interactionMode: InputInteractionMode;
};
transformSession?: unknown;
};
export type TransformControlsInputController = {
pointerDown(event: PointerInputEvent): boolean;
pointerMove(event: PointerInputEvent): boolean;
@@ -17,7 +40,7 @@ export type TransformControlsInputController = {
export function createTransformControlsInputController(options: {
getDocument: () => ImageDocument;
getEditor: () => EditorState;
getEditor: () => TransformControlsEditorState;
dispatch: Dispatch;
}): TransformControlsInputController {
return {
@@ -62,7 +85,7 @@ export function createTransformControlsInputController(options: {
};
}
export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: EditorState["viewport"]): TransformHandle | undefined {
export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: InputViewportState): TransformHandle | undefined {
const rect = documentRectToViewportRect(bounds, viewport);
const handles = transformHandleRects(rect);
const handle = handles.find((candidate) => pointInRect(position, candidate.rect));
@@ -71,7 +94,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
return undefined;
}
function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) {
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
if (target.type === "artboard") {
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
return !artboard || !artboard.visible || artboard.locked;
@@ -97,20 +120,8 @@ function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect
return points.map(({ handle, point }) => ({ handle, rect: { x: point.x - half, y: point.y - half, w: size, h: size } }));
}
function viewportPointToDocumentPoint(point: Vec2D, viewport: EditorState["viewport"]): Vec2D {
return {
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
};
}
function documentRectToViewportRect(rect: Rect, viewport: EditorState["viewport"]): Rect {
return {
x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom,
y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom,
w: rect.w * viewport.zoom,
h: rect.h * viewport.zoom,
};
function isPanInteractionMode(interactionMode: InputInteractionMode): boolean {
return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan");
}
function pointInRect(point: Vec2D, rect: Rect) {

View File

@@ -1,61 +1,101 @@
import { describe, expect, test } from "bun:test";
import type { Dispatch } from "@commands/dispatcher";
import { commandIds } from "@commands/ids";
import { toolCommands } from "@commands/tool";
import { viewportCommands } from "@commands/viewport";
import { createCommandRegistry } from "@commands/registry";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import type { PointerInputEvent } from "./pointer";
import { createViewportPanInputController } from "./viewport-pan";
const registry = createCommandRegistry([...toolCommands, ...viewportCommands]);
const ignoredState = undefined as never;
describe("viewport pan store integration", () => {
test("space key input updates actual store tool mode", () => {
const store = createAppStore(createInitialAppState("Test"), registry);
test("space key input updates actual tool mode", () => {
const store = createInputStore();
const controller = createController(store);
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
expect(store.getState().editor.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" });
expect(store.state.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" });
expect(controller.keyUp(keyEvent("Space"))).toBe(true);
expect(store.getState().editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" });
expect(store.state.tools.interactionMode).toEqual({ type: "tool", tool: "select" });
});
test("left-drag only pans while temporary pan is active", () => {
const store = createAppStore(createInitialAppState("Test"), registry);
const store = createInputStore();
const controller = createController(store);
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(false);
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(false);
expect(store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 });
expect(store.state.viewport.center).toEqual({ x: 0, y: 0 });
store.dispatch(commandIds.toolEnterTemporaryPan, undefined);
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(true);
expect(store.getState().editor.viewport.center).toEqual({ x: -10, y: 0 });
expect(store.state.viewport.center).toEqual({ x: -10, y: 0 });
});
test("left-drag pans while pan tool is active", () => {
const store = createAppStore(createInitialAppState("Test"), registry);
const store = createInputStore();
const controller = createController(store);
store.dispatch(commandIds.toolSetActive, { tool: "pan" });
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 4, y: -2 } }))).toBe(true);
expect(store.getState().editor.viewport.center).toEqual({ x: -4, y: 2 });
expect(store.state.viewport.center).toEqual({ x: -4, y: 2 });
});
});
function createController(store: ReturnType<typeof createAppStore>) {
type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan";
type InputStore = ReturnType<typeof createInputStore>;
function createInputStore() {
const store = {
state: {
viewport: { center: { x: 0, y: 0 }, zoom: 1 },
tools: {
activeTool: "select" as InputToolId,
interactionMode: { type: "tool" as const, tool: "select" as InputToolId },
},
},
dispatch: ((commandId, payload) => {
switch (commandId) {
case commandIds.toolSetActive:
store.state.tools.activeTool = payload.tool;
store.state.tools.interactionMode = { type: "tool", tool: payload.tool };
break;
case commandIds.toolEnterTemporaryPan:
store.state.tools.interactionMode = { type: "temporary-pan", previousTool: store.state.tools.activeTool };
break;
case commandIds.toolExitTemporaryPan: {
const mode = store.state.tools.interactionMode;
if (mode.type === "temporary-pan") {
store.state.tools.activeTool = mode.previousTool;
store.state.tools.interactionMode = { type: "tool", tool: mode.previousTool };
}
break;
}
case commandIds.viewportPan:
store.state.viewport.center = {
x: store.state.viewport.center.x + payload.delta.x,
y: store.state.viewport.center.y + payload.delta.y,
};
break;
}
return ignoredState;
}) as Dispatch,
};
return store;
}
function createController(store: InputStore) {
return createViewportPanInputController({
globalKeyConsumer: () => false,
globalPointerConsumer: () => false,
getCurrentZoom: () => store.getState().editor.viewport.zoom,
getCurrentZoom: () => store.state.viewport.zoom,
isPanMode: () => {
const mode = store.getState().editor.tools.interactionMode;
const mode = store.state.tools.interactionMode;
return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan");
},
dispatch: store.dispatch,