diff --git a/apps/.gitkeep b/apps/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index a7933b7..9c5b666 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,6 +1,6 @@ import "./style.css" -import { WebDrawingEngine, Tools } from "@libs/drawing-engine" +import { WebDrawingEngine, ToolNames } from "@libs/drawing-engine" import { ColorPicker } from "@libs/color-picker" import { Color } from "@libs/shared" @@ -24,9 +24,8 @@ function main() { }) const tools = [ - { value: Tools.brush, label: "Brush" }, - { value: Tools.pressureSensitiveBrush, label: "Pressure-Sensitive Brush" }, - { value: Tools.eyedropper, label: "Grab Color" }, + { value: ToolNames.line, label: "Brush" }, + { value: ToolNames.eyedropper, label: "Grab Color" }, // { value: "erase", label: "Eraser" }, ] as const @@ -37,7 +36,7 @@ function main() { state, initialColor: engine.getCurrentColor(), initialOpacity: (engine.getOpacity() * 100) / 255, - initialWeight: engine.lineWeight, + initialWeight: engine.tools.line.getLineWeight(), onClear() { engine.clearCanvas() @@ -46,7 +45,7 @@ function main() { engine.setOpacity((opacity * 255) / 100) }, onSetLineWeight(weight) { - engine.setLineWeight(weight) + engine.tools.line.setLineWeight(weight) }, onSetColor(color) { engine.setColor(color) @@ -65,7 +64,7 @@ function main() { link.click() }, tools: tools, - initialTool: engine.getCurrentTool(), + initialTool: engine.activeTool.toolName, addListener: engine.addListener.bind(engine), }) diff --git a/libs/drawing-engine/src/engine/DrawingEngine.ts b/libs/drawing-engine/src/engine/DrawingEngine.ts index 7b1dc77..69fdb10 100644 --- a/libs/drawing-engine/src/engine/DrawingEngine.ts +++ b/libs/drawing-engine/src/engine/DrawingEngine.ts @@ -1,32 +1,24 @@ -import { LineDrawingProgram, DrawLineOptions, DrawType, LineInfo } from "../programs/LineDrawingProgram" import { TextureDrawingProgram } from "../programs/TextureDrawingProgram" import type { Vec2 } from "@libs/shared" -import { BaseProgram, Color } from "@libs/shared" +import { Color } from "@libs/shared" import { Layer } from "./Layer" import { SourceImage } from "../utils/image/SourceImage" +import { ToolName, ToolNames } from "../exports" +import { LineHistoryEntry, LineTool } from "../tools/LineTool" +import { InputPoint } from "../tools/InputPoint" +import { EyeDropperHistoryEntry, EyeDropperTool } from "../tools/EyeDropperTool" -interface AvailablePrograms { - lineDrawing: LineDrawingProgram - textureDrawing: TextureDrawingProgram -} - -interface HistoryItem { - path: Readonly - tool: Tool - options: Required> -} +type HistoryItem = LineHistoryEntry | EyeDropperHistoryEntry -interface DrawingState { +interface DrawingEngineState { color: Color opacity: number - currentPath: LineInfo - lineWeight: number - isPressed: boolean pixelDensity: number width: number height: number - tool: Tool - prevTool: Tool + tool: ToolName + prevTool: ToolName + isPressed: boolean } export interface DrawingEngineOptions { @@ -40,8 +32,14 @@ export interface DrawingEngineEventMap { pickColor: { color: Color } previewColor: { color: Color | null } clear: undefined - changeTool: { tool: Tool } + changeTool: { tool: ToolName } + + press: { position: Readonly } + move: { positions: ReadonlyArray; isPressed: boolean } + release: { position: Readonly } + cancel: undefined } + export type DrawingEngineEvent = { eventName: T } & (DrawingEngineEventMap[T] extends undefined ? {} : DrawingEngineEventMap[T]) @@ -52,29 +50,12 @@ type DrawingEventListeners = { [Key in DrawingEngineEventName]: Array> } -export const Tools = { - brush: "brush", - // todo: eraser: "eraser", - pressureSensitiveBrush: "pressureSensitiveBrush", - eyedropper: "eyedropper", -} as const - -const defaultTool = Tools.pressureSensitiveBrush - -export type Tool = (typeof Tools)[keyof typeof Tools] - -export interface ToolBindings { - onPress?: (position: Readonly, pressure: Readonly<[number]>) => { hideCursor?: boolean } | void - onMove?: (positions: ReadonlyArray, pressure: ReadonlyArray) => void - onRelease?: (position: Readonly, pressure: Readonly<[number]>) => void - onCancel?: () => void - onCommit?: () => void -} +const defaultTool = ToolNames.line export class DrawingEngine { - protected state: DrawingState - protected programs: AvailablePrograms - protected drawingHistory: Array + protected state: DrawingEngineState + protected program: TextureDrawingProgram + protected history: Array /** * Saved drawing layer is the layer that actions, like brush strokes, are saved to after the user finishes drawing * (e.g. releases the mouse, lifts the stylus, etc). @@ -87,7 +68,10 @@ export class DrawingEngine { */ protected activeDrawingLayer: Layer - protected tools: Record + public readonly tools: { + [ToolNames.line]: LineTool + [ToolNames.eyedropper]: EyeDropperTool + } private listeners: Partial = {} @@ -99,8 +83,6 @@ export class DrawingEngine { ...options, color: Color.BLACK, opacity: 255, - currentPath: { points: [] }, - lineWeight: 20, isPressed: false, pixelDensity: options.pixelDensity ?? 1, tool: defaultTool, @@ -110,70 +92,18 @@ export class DrawingEngine { this.savedDrawingLayer = new Layer(gl) this.activeDrawingLayer = new Layer(gl, { clearBeforeDrawing: true }) - this.programs = { - lineDrawing: new LineDrawingProgram(gl, this.state.pixelDensity), - textureDrawing: new TextureDrawingProgram(gl, this.state.pixelDensity), - } + this.program = new TextureDrawingProgram(gl, this.state.pixelDensity) - this.drawingHistory = [] - - const brushBindings: ToolBindings = { - onPress: (position, pressure) => { - this.addPosition(position, pressure) - return { hideCursor: true } - }, - onMove: (positions, pressure) => { - this.addPositions(positions, pressure) - }, - onRelease: () => { - this.commitToSavedLayer() - }, - onCommit: () => { - const path = this.clearCurrentPath() - - this.state.isPressed = false - if (path.points.length === 0) { - return - } - - this.drawingHistory.push({ - path, - tool: this.getCurrentTool(), - options: this.getLineOptions(), - }) - }, - } + this.history = [] this.tools = { - [Tools.brush]: brushBindings, - [Tools.pressureSensitiveBrush]: brushBindings, - [Tools.eyedropper]: { - onCancel: () => { - this.setTool(this.state.prevTool ?? defaultTool) - this.callListeners("previewColor", { color: null }) - }, - onPress: (pos) => { - this.pickColor(pos) - }, - onMove: (positions) => { - const color = BaseProgram.getColorAtPosition(this.gl, positions[positions.length - 1]) - if (!color) { - return - } - this.callListeners("previewColor", { color }) - }, - onRelease: (position) => { - if (this.pickColor(position)) { - const prevTool = this.state.prevTool ?? defaultTool - this.setTool(prevTool === Tools.eyedropper ? defaultTool : prevTool) - } else { - this.handleCancel() - } - }, - }, + [ToolNames.line]: new LineTool(this), + [ToolNames.eyedropper]: new EyeDropperTool(this), } + + this.callListeners("changeTool", { tool: this.state.tool }) } - protected get pixelDensity() { + public get pixelDensity() { return this.state.pixelDensity } @@ -185,7 +115,7 @@ export class DrawingEngine { return this.state.color } - public getCurrentTool() { + public getCurrentToolName() { return this.state.tool } @@ -193,7 +123,7 @@ export class DrawingEngine { return this.tools[this.state.tool] } - public setTool(tool: Tool) { + public setTool(tool: ToolName) { if (this.state.tool === tool) { return } @@ -203,6 +133,10 @@ export class DrawingEngine { this.callListeners("changeTool", { tool }) } + public usePrevTool() { + this.setTool(this.state.prevTool) + } + public setColor(color: Color) { this.commitToSavedLayer() this.state.color = color @@ -217,20 +151,11 @@ export class DrawingEngine { return this.state.opacity } - public get lineWeight(): number { - return this.state.lineWeight - } - - public setLineWeight(weight: number): typeof this { - this.state.lineWeight = weight - return this - } - public clearCanvas() { this.savedDrawingLayer.clear() this.activeDrawingLayer.clear() this.clearCurrent() - this.programs.textureDrawing.draw(this.activeDrawingLayer, this.savedDrawingLayer) + this.program.draw(this.activeDrawingLayer, this.savedDrawingLayer) this.callListeners("clear", undefined) } @@ -240,84 +165,33 @@ export class DrawingEngine { this.gl.clear(this.gl.COLOR_BUFFER_BIT) } - protected getLineOptions(): Required> { - return { - color: this.state.color, - opacity: this.state.opacity, - diameter: this.lineWeight, - } - } - - protected handlePointerDown(position: Readonly, pressure: Readonly<[number]>) { + protected handlePointerDown(position: Readonly) { this.state.isPressed = true - return this.tools[this.state.tool].onPress?.(position, pressure) + this.callListeners("press", { position }) } - protected handlePointerUp(position: Readonly, pressure: Readonly<[number]>) { + protected handlePointerUp(position: Readonly) { this.state.isPressed = false - return this.tools[this.state.tool].onRelease?.(position, pressure) + this.callListeners("release", { position }) } - protected handlePointerMove(positions: ReadonlyArray, pressure: ReadonlyArray) { - return this.tools[this.state.tool].onMove?.(positions, pressure) + protected handlePointerMove(positions: ReadonlyArray) { + this.callListeners("move", { positions, isPressed: this.state.isPressed }) } protected handleCancel() { - this.tools[this.state.tool].onCancel?.() - } - - protected pickColor(position: Readonly) { - const color = BaseProgram.getColorAtPosition(this.gl, position) - if (!color) { - return false - } - this.setColor(color) - this.callListeners("pickColor", { color }) - return true - } - - public addPosition(position: Readonly, pressure: Readonly<[number]>) { - this.addPositions([[...position]], pressure) - } - - public addPositions(positions: ReadonlyArray, pressure: ReadonlyArray) { - if (this.state.isPressed) { - this.state.currentPath.points.push(...positions.flat()) - if (pressure) this.addPressure(pressure) - this.updateActivePath() - } + this.callListeners("cancel", undefined) } - protected isPositionInCanvas(position: Readonly) { + public isPositionInCanvas(position: Readonly) { return position[0] >= 0 && position[0] <= this.state.width && position[1] >= 0 && position[1] <= this.state.height } - private addPressure(pressure: ReadonlyArray) { - if (this.state.tool !== Tools.pressureSensitiveBrush) { - return - } - if (!this.state.currentPath.pressure) { - this.state.currentPath.pressure = [] - } - this.state.currentPath.pressure.push(...pressure) - } - - protected updateActivePath() { - if (this.state.currentPath.points.length > 0) { - this.drawLine(this.activeDrawingLayer, this.state.currentPath, DrawType.STATIC_DRAW) - } - } - - public drawLine(layer: Layer, path: LineInfo, drawType?: DrawLineOptions["drawType"]) { - const options = this.getLineOptions() - this.programs.textureDrawing.createTextureImage(layer, () => { - this.programs.lineDrawing.draw(path, { - drawType, - ...options, - }) - }) + public draw(drawCallback: () => void): this { + const layer = this.activeDrawingLayer + this.program.createTextureImage(layer, drawCallback) this.render() - this.callListeners("draw", { path, options, tool: this.state.tool }) + return this } public loadImage(image: SourceImage) { @@ -339,7 +213,7 @@ export class DrawingEngine { } protected render() { - this.programs.textureDrawing.draw(this.activeDrawingLayer, this.savedDrawingLayer) + this.program.draw(this.activeDrawingLayer, this.savedDrawingLayer) } public addListener(eventName: E, cb: DrawingEventHandler) { @@ -354,27 +228,26 @@ export class DrawingEngine { public removeListener(eventName: E, cb: DrawingEventHandler) { const index = this.listeners[eventName]?.indexOf(cb) ?? -1 + if (index === -1) { + return this + } this.listeners[eventName]?.splice(index, 1) return this } - protected callListeners(eventName: E, data: DrawingEngineEventMap[E]) { + public callListeners(eventName: E, data: DrawingEngineEventMap[E]) { this.listeners[eventName]?.forEach((listener) => listener({ eventName, ...data })) } - protected commitToSavedLayer() { - const copy = this.programs.textureDrawing.mergeDown(this.activeDrawingLayer, this.savedDrawingLayer) + public commitToSavedLayer() { + const copy = this.program.mergeDown(this.activeDrawingLayer, this.savedDrawingLayer) this.savedDrawingLayer.clear() this.activeDrawingLayer.clear() this.savedDrawingLayer = copy this.render() - - this.activeTool.onCommit?.() } - private clearCurrentPath(): Readonly { - const copy = this.state.currentPath - this.state.currentPath = { points: [] } - return copy + public addHistory(historyItem: HistoryItem) { + this.history.push(historyItem) } } diff --git a/libs/drawing-engine/src/engine/WebDrawingEngine.ts b/libs/drawing-engine/src/engine/WebDrawingEngine.ts index b84a7c8..84a1eb7 100644 --- a/libs/drawing-engine/src/engine/WebDrawingEngine.ts +++ b/libs/drawing-engine/src/engine/WebDrawingEngine.ts @@ -1,7 +1,9 @@ import { Color, getEventPosition } from "@libs/shared" -import { DrawingEngine, DrawingEngineOptions, Tools } from "./DrawingEngine" +import { DrawingEngine, DrawingEngineOptions } from "./DrawingEngine" +import { ToolNames } from "../tools/Tools" import { Vec2 } from "@libs/shared" import { SourceImage } from "../utils/image/SourceImage" +import { InputPoint } from "../tools/InputPoint" interface IWebDrawingEngine { canvas: Readonly @@ -109,8 +111,8 @@ export class WebDrawingEngine extends DrawingEngine implements IWebDrawingEngine if (event.isPrimary === false) { return } - const handle = this.handlePointerDown(position, [event.pressure]) - if (handle?.hideCursor) { + this.handlePointerDown(position) + if (this.activeTool.toolName === ToolNames.line) { this.canvas.style.setProperty("cursor", "none") } }) @@ -118,21 +120,21 @@ export class WebDrawingEngine extends DrawingEngine implements IWebDrawingEngine if (event.isPrimary === false) { return } - let positions = [position] - let pressure = [event.pressure] + let positions: InputPoint[] = [[...position, event.pressure]] try { - positions = event.getCoalescedEvents().map((coalsecedEvent) => this.getCanvasPosition(coalsecedEvent)) - pressure = event.getCoalescedEvents().map((coalsecedEvent) => coalsecedEvent.pressure) + positions = event + .getCoalescedEvents() + .map((coalsecedEvent) => [...this.getCanvasPosition(coalsecedEvent), coalsecedEvent.pressure]) } catch (error) { console.warn("Could not get coalesced events", error) } - this.handlePointerMove(positions, pressure) + this.handlePointerMove(positions) }) this.listenOnPositionEvent("pointerup", ({ position, event }) => { if (event.isPrimary === false) { return } - this.handlePointerUp(position, [event.pressure]) + this.handlePointerUp(position) this.canvas.style.removeProperty("cursor") }) @@ -142,7 +144,7 @@ export class WebDrawingEngine extends DrawingEngine implements IWebDrawingEngine window.addEventListener("keydown", (event) => { if (event.key === "Control" && !this.state.isPressed) { - this.setTool(Tools.eyedropper) + this.setTool(ToolNames.eyedropper) return } }) @@ -150,7 +152,7 @@ export class WebDrawingEngine extends DrawingEngine implements IWebDrawingEngine window.addEventListener("keyup", (event) => { if (event.key === "Escape") { this.handleCancel() - } else if (event.key === "Control" && this.state.tool === Tools.eyedropper) { + } else if (event.key === "Control" && this.state.tool === ToolNames.eyedropper) { this.handleCancel() } }) diff --git a/libs/drawing-engine/src/exports.ts b/libs/drawing-engine/src/exports.ts index a96051f..16cb83c 100644 --- a/libs/drawing-engine/src/exports.ts +++ b/libs/drawing-engine/src/exports.ts @@ -1,3 +1,3 @@ export * from "./engine/WebDrawingEngine" -export type { Tool } from "./engine/DrawingEngine" -export { Tools } from "./engine/DrawingEngine" +export type { ToolName } from "./tools/Tools" +export { ToolNames } from "./tools/Tools" diff --git a/libs/drawing-engine/src/tools/EyeDropperTool.ts b/libs/drawing-engine/src/tools/EyeDropperTool.ts new file mode 100644 index 0000000..5f0527b --- /dev/null +++ b/libs/drawing-engine/src/tools/EyeDropperTool.ts @@ -0,0 +1,75 @@ +import { BaseProgram, Color } from "@libs/shared" +import { DrawingEngine, DrawingEngineEvent } from "../engine/DrawingEngine" +import { InputPoint } from "./InputPoint" +import { ToolNames } from "./Tools" + +export type EyeDropperHistoryEntry = { + tool: "eyedropper" + color: Readonly + previousColor: Readonly +} + +export class EyeDropperTool { + public static readonly TOOL_NAME = ToolNames.eyedropper + public readonly toolName = EyeDropperTool.TOOL_NAME + constructor(public readonly engine: DrawingEngine) { + this.engine = engine + + const listeners = { + press: this.onPress.bind(this), + move: this.onMove.bind(this), + release: this.onRelease.bind(this), + cancel: this.onCancel.bind(this), + } + + this.engine.addListener("changeTool", ({ tool }) => { + if (tool === this.toolName) { + this.engine.addListener("press", listeners.press) + this.engine.addListener("move", listeners.move) + this.engine.addListener("release", listeners.release) + this.engine.addListener("cancel", listeners.cancel) + } else { + this.engine.removeListener("press", listeners.press) + this.engine.removeListener("move", listeners.move) + this.engine.removeListener("release", listeners.release) + this.engine.removeListener("cancel", listeners.cancel) + } + }) + } + + onCancel() { + this.engine.usePrevTool() + this.engine.callListeners("previewColor", { color: null }) + } + + onPress({ position }: DrawingEngineEvent<"press">) { + this.pickColor(position) + } + + onMove({ positions }: DrawingEngineEvent<"move">) { + const [x, y] = positions[positions.length - 1] ?? [0, 0] + const color = BaseProgram.getColorAtPosition(this.engine.gl, [x, y]) + if (!color) { + return + } + this.engine.callListeners("previewColor", { color }) + } + + onRelease({ position }: DrawingEngineEvent<"release">) { + if (this.pickColor(position)) { + this.engine.usePrevTool() + } else { + this.onCancel() + } + } + + protected pickColor([x, y]: Readonly) { + const color = BaseProgram.getColorAtPosition(this.engine.gl, [x, y]) + if (!color) { + return false + } + this.engine.setColor(color) + this.engine.callListeners("pickColor", { color }) + return true + } +} diff --git a/libs/drawing-engine/src/tools/InputPoint.ts b/libs/drawing-engine/src/tools/InputPoint.ts new file mode 100644 index 0000000..753ee4b --- /dev/null +++ b/libs/drawing-engine/src/tools/InputPoint.ts @@ -0,0 +1 @@ +export type InputPoint = [x: number, y: number, pressure?: number] diff --git a/libs/drawing-engine/src/tools/LineTool.ts b/libs/drawing-engine/src/tools/LineTool.ts new file mode 100644 index 0000000..e413bc9 --- /dev/null +++ b/libs/drawing-engine/src/tools/LineTool.ts @@ -0,0 +1,142 @@ +import { InputPoint } from "./InputPoint" +import { DrawLineOptions, LineDrawingProgram } from "../programs/LineDrawingProgram" +import { DrawingEngine, DrawingEngineEvent } from "../engine/DrawingEngine" +import { ToolNames } from "./Tools" + +export type LineHistoryEntry = { + tool: "line" + path: InputPoint[] + options: Required> +} + +export class LineTool { + static readonly TOOL_NAME = ToolNames.line + public readonly toolName = LineTool.TOOL_NAME + private currentPath: InputPoint[] = [] + private readonly program: LineDrawingProgram + private options = { + pressureEnabled: true, + lineWeight: 5, + } + + constructor(protected readonly engine: DrawingEngine) { + this.program = new LineDrawingProgram(engine.gl, engine.pixelDensity) + this.setupListeners() + } + + public setLineWeight(lineWeight: number) { + this.options.lineWeight = lineWeight + } + + public getLineWeight() { + return this.options.lineWeight + } + + protected setupListeners() { + const listeners = { + press: this.onPress.bind(this), + move: this.onMove.bind(this), + release: this.onRelease.bind(this), + cancel: this.onCancel.bind(this), + changeTool: this.onChangeTool.bind(this), + } + + this.engine.addListener("changeTool", ({ tool }) => { + if (tool === this.toolName) { + this.engine.addListener("press", listeners.press) + this.engine.addListener("move", listeners.move) + this.engine.addListener("release", listeners.release) + this.engine.addListener("cancel", listeners.cancel) + this.engine.addListener("changeTool", listeners.changeTool) + } else { + this.engine.removeListener("press", listeners.press) + this.engine.removeListener("move", listeners.move) + this.engine.removeListener("release", listeners.release) + this.engine.removeListener("cancel", listeners.cancel) + this.engine.removeListener("changeTool", listeners.changeTool) + } + }) + } + + protected onPress({ position }: DrawingEngineEvent<"press">) { + this.addPosition(position) + this.draw() + return { hideCursor: true } + } + + protected onMove({ positions, isPressed }: DrawingEngineEvent<"move">) { + if (isPressed) { + this.addPositions(positions) + this.draw() + } + } + + protected onRelease({ position }: DrawingEngineEvent<"release">) { + this.addPosition(position) + this.draw() + this.commit() + } + + protected onCancel() { + this.currentPath = [] + } + + protected onChangeTool() { + this.commit() + } + + protected commit() { + if (this.currentPath.length < 2) { + return + } + this.engine.commitToSavedLayer() + this.engine.addHistory(this.getHistoryEntry()) + this.currentPath = [] + } + + private getHistoryEntry(): LineHistoryEntry { + return { + path: structuredClone(this.currentPath), + options: this.getLineOptions(), + tool: this.toolName, + } + } + + private draw() { + if (this.currentPath.length < 2) { + return + } + const path = this.currentPath + this.engine.draw(() => { + const pressure = this.options.pressureEnabled ? path.map(([, , pressure]) => pressure ?? 1.0) : undefined + this.program.draw( + { + points: path.map(([x, y]) => [x, y]).flat(), + pressure: pressure && this.hasPressure(pressure) ? pressure : undefined, + }, + this.getLineOptions(), + ) + return this.getHistoryEntry() + }) + } + + private hasPressure([_, ...points]: number[]) { + return !points.some((point) => point === 0) + } + + protected getLineOptions(): Required> { + return { + color: this.engine.getCurrentColor(), + opacity: this.engine.getOpacity(), + diameter: this.options.lineWeight, + } + } + + private addPosition(position: Readonly) { + this.addPositions([[...position]]) + } + + private addPositions(positions: ReadonlyArray) { + this.currentPath.push(...positions) + } +} diff --git a/libs/drawing-engine/src/tools/Tools.ts b/libs/drawing-engine/src/tools/Tools.ts new file mode 100644 index 0000000..4ad20ca --- /dev/null +++ b/libs/drawing-engine/src/tools/Tools.ts @@ -0,0 +1,5 @@ +export const ToolNames = { + line: "line", + eyedropper: "eyedropper", +} as const +export type ToolName = (typeof ToolNames)[keyof typeof ToolNames] diff --git a/package.json b/package.json index a26ff66..4073377 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "pnpm --filter web run dev", "build": "pnpm --filter web run build", "preview": "pnpm --filter web run preview", - "type-check": "pnpm recursive run type-check && echo Type-check passed && exit 0 || exit 1", + "type-check": "pnpm --recursive --parallel --aggregate-output run type-check && echo Type-check passed && exit 0 || exit 1", "create:lib": "pnpm --filter @tools/create-lib generate", "test": "vitest", "lint": "prettier --check .",