Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds an undo/redo tool #52

Merged
merged 2 commits into from
Dec 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ function main() {
link.href = url
link.click()
},
onUndo() {
engine.undo()
},
onRedo() {
engine.redo()
},
tools: tools,
initialTool: engine.getCurrentToolName(),

Expand Down Expand Up @@ -103,6 +109,9 @@ function makeToolbar<T extends string>(
onExport: (name: string) => void
onLoadImage: (image: HTMLImageElement) => void

onUndo: () => void
onRedo: () => void

addListener: WebDrawingEngine["addListener"]
},
) {
Expand Down Expand Up @@ -225,6 +234,39 @@ function makeToolbar<T extends string>(
})
inputTray.append(exportButton)

const undoButton = document.createElement("button")
const redoButton = document.createElement("button")
undoButton.classList.add("undo-button")
redoButton.classList.add("redo-button")
undoButton.innerText = "Undo"
redoButton.innerText = "Redo"
undoButton.disabled = true
redoButton.disabled = true
inputTray.append(undoButton)
inputTray.append(redoButton)
undoButton.addEventListener("click", (e) => {
e.preventDefault()
options.onUndo()
})
redoButton.addEventListener("click", (e) => {
e.preventDefault()
options.onRedo()
})
options.addListener("draw", () => {
undoButton.disabled = false
redoButton.disabled = true
})
options.addListener("undo", ({ undosLeft }) => {
console.log({ undosLeft })
undoButton.disabled = undosLeft === 0
redoButton.disabled = false
})
options.addListener("redo", ({ redosLeft }) => {
console.log({ redosLeft })
undoButton.disabled = false
redoButton.disabled = redosLeft === 0
})

const clearButton = document.createElement("button")
clearButton.classList.add("clear-button")
clearButton.innerText = "Clear"
Expand Down
144 changes: 144 additions & 0 deletions libs/drawing-engine/src/engine/CanvasHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { LineDrawInfo } from "../tools/LineTool"
import { DrawingEngine } from "./DrawingEngine"

type ToolInfo = LineDrawInfo

export interface HistoryState {
toolInfo: ToolInfo
imageData: string | null
}

interface HistoryOptions {
maxHistory: number
}

export class CanvasHistory {
protected redoHistory: Array<Readonly<HistoryState>> = []
protected history: Array<Readonly<HistoryState>> = []
protected hasTruncated = false

constructor(
protected readonly engine: DrawingEngine,
protected options: HistoryOptions,
) {
if (!options.maxHistory || options.maxHistory < 1) {
options.maxHistory = 10
}
}

public setOptions(options: Partial<HistoryOptions>) {
this.options = {
...this.options,
...options,
}
return this
}

public getOptions(): Readonly<HistoryOptions> {
return this.options
}

protected async getBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
throw new Error("Could not get blob from canvas")
}
resolve(blob)
})
})
}

public save(toolInfo: ToolInfo) {
const canvas = this.engine.gl.canvas
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error("Canvas is not an HTMLCanvasElement")
}
const tool = this.engine.tools[toolInfo.tool]
if (!tool) {
throw new Error(`Tool ${toolInfo.tool} not found`)
}
this.addHistory({
toolInfo,
imageData: tool.updatesImageData ? canvas.toDataURL() : null,
})
}
public async undo() {
if (!this.canUndo()) {
return
}
const undoneState = this.history.pop()
if (!undoneState) {
return
}
const currentState = this.history[this.history.length - 1]
if (currentState) this.engine.setTool(currentState.toolInfo.tool)
this.redoHistory.push(undoneState)
return await this.drawState(currentState)
}

public async redo() {
if (!this.canRedo()) {
return
}
const state = this.redoHistory.pop()
if (!state) {
return
}
this.history.push(state)
this.engine.setTool(state.toolInfo.tool)
return await this.drawState(state)
}

protected addHistory(state: HistoryState) {
if (this.history.length >= this.options.maxHistory) {
this.hasTruncated = true
this.history.shift()
}
this.history.push(state)
this.redoHistory = []
}

protected drawState(state: Readonly<HistoryState> | null) {
if (!state) {
if (!this.hasTruncated) this.engine.clearCanvas()
return Promise.resolve(null)
}
const { toolInfo, imageData } = state
if (!imageData) {
return Promise.resolve(toolInfo)
}
return new Promise<HistoryState["toolInfo"]>((resolve, reject) => {
const image = new Image()
image.onload = () => {
this.engine._clear()
this.engine.loadImage(image)
resolve(toolInfo)
}
image.src = imageData
image.onerror = () => {
reject(new Error("Could not load image"))
}
})
}

public clear() {
this.history = []
}

public getHistory(): Readonly<{ undo: ReadonlyArray<HistoryState>; redo: ReadonlyArray<HistoryState> }> {
return {
undo: this.history,
redo: this.redoHistory,
}
}

public canUndo() {
const minStates = this.hasTruncated ? 1 : 0
return this.history.length > minStates
}

public canRedo() {
return this.redoHistory.length > 0
}
}
54 changes: 43 additions & 11 deletions libs/drawing-engine/src/engine/DrawingEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ import { Color } from "@libs/shared"
import { Layer, LayerSettings } from "./Layer"
import { SourceImage } from "../utils/image/SourceImage"
import { ToolName, ToolNames } from "../exports"
import { LineHistoryEntry, LineTool } from "../tools/LineTool"
import { LineDrawInfo, LineTool } from "../tools/LineTool"
import { InputPoint } from "../tools/InputPoint"
import { EyeDropperHistoryEntry, EyeDropperTool } from "../tools/EyeDropperTool"

type HistoryItem = LineHistoryEntry | EyeDropperHistoryEntry
import { EyeDropperTool } from "../tools/EyeDropperTool"
import { CanvasHistory, HistoryState } from "./CanvasHistory"

interface DrawingEngineState {
color: Color
Expand All @@ -28,8 +27,12 @@ export interface DrawingEngineOptions {
pixelDensity?: number
}

type ToolInfo = LineDrawInfo

export interface DrawingEngineEventMap {
draw: HistoryItem
draw: ToolInfo
undo: { toolInfo: HistoryState["toolInfo"]; undosLeft: number }
redo: { toolInfo: HistoryState["toolInfo"]; redosLeft: number }
pickColor: { color: Color }
previewColor: { color: Color | null }
clear: undefined
Expand All @@ -56,7 +59,6 @@ const defaultTool = ToolNames.brush
export class DrawingEngine {
protected state: DrawingEngineState
protected program: TextureDrawingProgram
protected history: Array<HistoryItem>
/**
* 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).
Expand All @@ -76,6 +78,7 @@ export class DrawingEngine {
}

private listeners: Partial<DrawingEventListeners> = {}
protected history: CanvasHistory

constructor(
public gl: WebGLRenderingContext,
Expand All @@ -91,12 +94,15 @@ export class DrawingEngine {
prevTool: defaultTool,
}

this.history = new CanvasHistory(this, {
maxHistory: 10,
})

this.savedDrawingLayer = this.makeLayer()
this.activeDrawingLayer = this.makeLayer({ clearBeforeDrawing: true })

this.program = new TextureDrawingProgram(gl, this.state.pixelDensity)

this.history = []
const lineProgram = new LineDrawingProgram(gl, this.state.pixelDensity)
this.tools = {
[ToolNames.brush]: new LineTool(this, lineProgram, ToolNames.brush),
Expand Down Expand Up @@ -167,10 +173,14 @@ export class DrawingEngine {
return this.state.opacity
}

public clearCanvas() {
public _clear() {
this.savedDrawingLayer.clear()
this.activeDrawingLayer.clear()
this.clearCurrent()
}

public clearCanvas() {
this._clear()
this.program.draw(this.activeDrawingLayer, this.savedDrawingLayer)

this.callListeners("clear", undefined)
Expand Down Expand Up @@ -203,7 +213,7 @@ export class DrawingEngine {
return position[0] >= 0 && position[0] <= this.state.width && position[1] >= 0 && position[1] <= this.state.height
}

public draw(drawCallback: () => HistoryItem | undefined): this {
public draw(drawCallback: () => DrawingEngineEventMap["draw"] | undefined): this {
const layer = this.activeDrawingLayer
this.program.createTextureImage(layer, () => {
const drawData = drawCallback()
Expand Down Expand Up @@ -268,7 +278,29 @@ export class DrawingEngine {
this.render("draw")
}

public addHistory(historyItem: HistoryItem) {
this.history.push(historyItem)
public addHistory(toolInfo: ToolInfo) {
this.history.save(toolInfo)
}

public async undo() {
const toolInfo = await this.history.undo()
if (!toolInfo) {
return
}
const undosLeft = this.history.getHistory().undo.length
this.callListeners("undo", { toolInfo, undosLeft })
}

public async redo() {
const toolInfo = await this.history.redo()
if (!toolInfo) {
return
}
const redosLeft = this.history.getHistory().redo.length
this.callListeners("redo", { toolInfo, redosLeft })
}

public getHistory() {
return this.history.getHistory()
}
}
3 changes: 2 additions & 1 deletion libs/drawing-engine/src/tools/EyeDropperTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { DrawingEngine, DrawingEngineEvent } from "../engine/DrawingEngine"
import { InputPoint } from "./InputPoint"
import { ToolNames } from "./Tools"

export type EyeDropperHistoryEntry = {
export type EyeDropperInfo = {
tool: "eyedropper"
color: Readonly<Color>
previousColor: Readonly<Color>
}

export class EyeDropperTool {
public readonly updatesImageData = false
public static readonly TOOL_NAME = ToolNames.eyedropper
public readonly toolName = EyeDropperTool.TOOL_NAME
constructor(public readonly engine: DrawingEngine) {
Expand Down
9 changes: 5 additions & 4 deletions libs/drawing-engine/src/tools/LineTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { DrawLineOptions, LineDrawingProgram } from "../programs/LineDrawingProg
import { DrawingEngine, DrawingEngineEvent } from "../engine/DrawingEngine"
import { ToolName } from "./Tools"

export type LineHistoryEntry = {
export type LineDrawInfo = {
tool: ToolName
path: InputPoint[]
options: Required<Omit<DrawLineOptions, "drawType">>
}

export class LineTool {
public readonly updatesImageData = true
private currentPath: InputPoint[] = []
private options = {
pressureEnabled: true,
Expand Down Expand Up @@ -90,11 +91,11 @@ export class LineTool {
return
}
this.engine.commitToSavedLayer()
this.engine.addHistory(this.getHistoryEntry())
this.engine.addHistory(this.getToolInfo())
this.currentPath = []
}

private getHistoryEntry(): LineHistoryEntry {
private getToolInfo(): LineDrawInfo {
return {
path: structuredClone(this.currentPath),
options: this.getLineOptions(),
Expand All @@ -116,7 +117,7 @@ export class LineTool {
},
this.getLineOptions(),
)
return this.getHistoryEntry()
return this.getToolInfo()
})
}

Expand Down