Skip to content

๐Ÿชต 4. Canvas Undo, Redo ๊ตฌํ˜„ โ€ path ๋‹จ์œ„ ๊ด€๋ฆฌ

D.Joung edited this page Dec 5, 2024 · 1 revision

12/02 ๊ธฐ์ค€ ํ˜„ํ™ฉ

  • ์ˆ˜์ •๋‹˜ CRDT ์ž‘์—…์— ํ•ฉ์ณ์ ธ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (์ตœ์ข… : stroke ๋‹จ์œ„๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ๋กœ ํ•จ)
  • ์•„๋ž˜ ์ฝ”๋“œ๋Š” undo / redo ํ”„๋กœํ† ํƒ€์ž…์œผ๋กœ ์‹ค์ œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.
  • ์•„๋ž˜๋กœ ์ด์–ด์ง€๋Š” ์ฝ”๋“œ๋Š” ์•„์ง CRDT๊ฐ€ ๊ตฌํ˜„ ์ค‘์ธ ๋‹จ๊ณ„์—์„œ, Undo & Redo์˜ ํ”„๋กœํ† ํƒ€์ž…์„ ์„ ๊ตฌํ˜„ ํ•ด๋ณธ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.

ํ˜„์žฌ ์ƒํ™ฉ

  • stroke id๋ฅผ key๊ฐ’์œผ๋กœ, stroke์— ํ•ด๋‹นํ•˜๋Š” peerId, timestamp, {x,y}[], width, color ๊ฐ€ ์ €์žฅ ๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค.
Stroke = {
  points: {x,y}[];
  style: {color, width};
};

LWWRegister = {
	peerId,
	state: {
		peerId,
		timeStamp,
		value: Stroke 
	}
}

LWWMap = {
	strokeId: LWWRegister
}[]

๊ตฌํ˜„ ์กฐ๊ฑด

  • ๋‚˜์˜ ๊ทธ๋ฆฌ๊ธฐ๋งŒ undo, redo ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.. (์ƒ๋Œ€ ๊ฒƒ์€ ๊ทธ๋Œ€๋กœ ๋‚จ์•„์žˆ์–ด์•ผ ํ•จ)
  • mousemove ํŠธ๋ฆฌ๊ฑฐ ์‹œ๋งˆ๋‹ค ์ƒˆ๋กœ์šด stroke, ์ฆ‰ ์ƒˆ๋กœ์šด LWWRegister ๊ฐ์ฒด๊ฐ€ ์ƒ์„ฑ ๋ฉ๋‹ˆ๋‹ค.

์•„์ด๋””์–ด Memo

  • onMouseMove(๋งˆ์šฐ์Šค ์›€์ง์ผ ๋•Œ)
    • ๋ฐ์ดํ„ฐ ๋ณด๋‚ด๊ธฐ (key, x,y,color,width)
      • undo ๋ฐฐ์—ด์— ๋“ค์–ด๊ฐˆ key ๊ฐ’.
    • ๋ฐฐ์—ด์— {x,y}[] push
  • onMouseLeave?(๋งˆ์šฐ์Šค ๋—์„ ๋•Œ)
    • undo ๋ฐฐ์—ด์— {key, {x,y}[], color, width} push
  • undo ํ–ˆ์„๋•Œ
    • undo ๋ฐฐ์—ด์—์„œ popํ•ด์„œ, key์— ํ•ด๋‹นํ•˜๋Š” {x,y}๋ฅผ null๋กœ ๋ฐ”๊พธ๊ธฐ
    • redo ๋ฐฐ์—ด์— {key, {x,y}[], color, width} push
  • redo ํ–ˆ์„ ๋•Œ
    • redo ๋ฐฐ์—ด์—์„œ popํ•ด์„œ, key์— ํ•ด๋‹นํ•˜๋Š” {x, y}[]๋ฅผ color, width๋กœ ๋ฐ”๊พธ๊ธฐ

์„ค๊ณ„

<๊ทœ์น™>

  • ๋ชจ๋“  Path๋Š” pathStack(Map)์— ์ €์žฅ๋˜์–ด ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
  • ๋ชจ๋“  Path๋Š” pathID์™€ strokeID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค.
  • UNDO, REDO๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ๋งˆ๋‹ค, ์บ”๋ฒ„์Šค ์ดˆ๊ธฐํ™” ํ›„ pathStack์˜ 0๋ฒˆ์งธ ๋ฐ์ดํ„ฐ๋ถ€ํ„ฐ ๋‹ค์‹œ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.
    • UNDO : ํ˜„์žฌ strokeID๋ฅผ ๊ฐ€์ง„ path๋ฅผ pathStack์—์„œ ์ „๋ถ€ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.
    • REDO : ๋‹ค์Œ strokeID๋ฅผ ๊ฐ€์ง„ path๋ฅผ pathStack์— ์ „๋ถ€ ๋ณต๊ตฌ์‹œํ‚ต๋‹ˆ๋‹ค.
  • ๊ด€๋ฆฌ ๋‹จ์œ„
    • path : move ์ด๋ฒคํŠธ๋กœ ์ƒˆ ์ขŒํ‘œ๊ฐ€ ์ถ”๊ฐ€๋  ๋•Œ ๋งˆ๋‹ค pathStack์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.
    • stroke : undo / redo์˜ ๊ธฐ๋ณธ ๋‹จ์œ„. up ๋˜๋Š” leave ์ด๋ฒคํŠธ ๋ฐœ์ƒ ์‹œ strokeID๊ฐ€ myStrokeIDStack์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

<์†์„ฑ>

  • pathStack : map ๊ฐ์ฒด. <pathID, PathData> ์Œ์œผ๋กœ path ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • myStrokeIDStack : ์ˆœ์„œ ์ง€์ผœ์„œ array ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • myStrokeIDIndex : ํ˜„์žฌ stroke id๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • deletedPath : map ๊ฐ์ฒด. pop๋œ pathData๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. <pathID, value> ์Œ์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • PathData ๊ฐ์ฒด
    • userID : ์‚ฌ์šฉ์ž๋ฅผ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ณ ์œ ํ•œ ID ์ž…๋‹ˆ๋‹ค.
    • strokeID : Stroke๋ฅผ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ณ ์œ ํ•œ ID ์ž…๋‹ˆ๋‹ค.
    • path : ๋“œ๋กœ์ž‰ ๊ฒฝ๋กœ๋ฅผ ์ €์žฅํ•˜๊ณ  ์žˆ๋Š” path2D ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค.
    • style : ๊ฒฝ๋กœ์˜ ์ƒ‰์ƒ, ๋‘๊ป˜, ํƒ€์ž… ์ •๋ณด๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

<๋ฉ”์†Œ๋“œ>

  • pushPath : ์ƒˆ path ๋ฐ์ดํ„ฐ๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ pathStack์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
  • popPath : pathID๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ ํ•ด๋‹นํ•˜๋Š” path์˜ pathData๋ฅผ pathStack์—์„œ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. (null ๋ฐฐ์ •)
  • restorePath : ๋ณต๊ตฌํ•  pathID์™€ PathData๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ pathStack์—์„œ ๋ณต๊ตฌํ•ฉ๋‹ˆ๋‹ค. (null โ†’ PathData)
  • pushMyStroke : strokeID ๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„ myStrokeIDStack์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • popStroke : ํ˜„์žฌ strokeID๋ฅผ ๊ฐ€์ง„ path์˜ pathData๋ฅผ pathStack์—์„œ ๋ชจ๋‘ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.
  • restoreStroke : ๋‹ค์Œ strokeID๋ฅผ ๊ฐ€์ง„ path์˜ pathData๋ฅผ pathStack์—์„œ ๋ชจ๋‘ ๋ณต๊ตฌํ•ฉ๋‹ˆ๋‹ค.
  • undo : undo ๊ธฐ๋Šฅ ์‹คํ–‰ ํ›„ ์บ”๋ฒ„์Šค ๋‹ค์‹œ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.
  • redo : redo ๊ธฐ๋Šฅ ์‹คํ–‰ ํ›„ ์บ”๋ฒ„์Šค ๋‹ค์‹œ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

๊ตฌํ˜„

path ๋‹จ์œ„๋กœ ๊ด€๋ฆฌํ•œ redo & undo ์‹œ์—ฐ

  • redo, undo ์‹œ์—๋„ ๋“œ๋กœ์ž‰ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ์ง€์ผœ์ง€๋Š” ๊ฒƒ์„ ํ™•์ธ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

Type & Class

PathStyle

  • PathData์— ํฌํ•จ๋  path์˜ ์Šคํƒ€์ผ์ž…๋‹ˆ๋‹ค.
    • color : ์ƒ‰์ƒ (16์ง„์ˆ˜ ํ‘œ๊ธฐ๋ฒ•)
    • lineWidth : ์„  ๋‘๊ป˜
    • type
      • path : ์„ ์„ ๊ทธ๋ฆด ๊ฒฝ์šฐ
      • shape : ๋ชจ์–‘์„ ๊ทธ๋ฆด ๊ฒฝ์šฐ (rect, arc ๋“ฑ์œผ๋กœ ๋„ํ˜•์„ ๊ทธ๋ ธ์„ ๊ฒฝ์šฐ)
export interface PathStyle {
  color: string;
  lineWidth: number;
  type: 'path' | 'shape';
}

PathData

  • Path๋ฅผ ๊ทธ๋ฆฌ๋Š”๋ฐ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋“ค์„ ๊ฐ–์Šต๋‹ˆ๋‹ค.
    • path : ๊ฒฝ๋กœ๊ฐ€ ์ €์žฅ๋œ path2D ๊ฐ์ฒด
    • userID : ๊ทธ๋ฆฐ ํ”Œ๋ ˆ์ด์–ด์˜ ID
    • strokeID : path๊ฐ€ ์†Œ์†๋œ ์„ ์˜ ID.
    • style : ์„ ์˜ style ๊ฐ์ฒด๋ฅผ ์ €์žฅ
class PathData {
  path: Path2D;
  userID: number;
  strokeID: string;
  style: PathStyle;
  constructor(userId: number, strokeID: string, path: Path2D, pathStyle: PathStyle) {
    this.userID = userId;
    this.strokeID = strokeID;
    this.path = path;
    this.style = pathStyle;
  }
}

StrokeManager

  • undo, redo์™€ ๊ด€๋ จ๋œ ์ „์ฒด path ๋ฐ stroke ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • undo, redo ํ•จ์ˆ˜๋Š” ์‹คํ–‰๋  ๋•Œ ๋งˆ๋‹ค ์บ”๋ฒ„์Šค๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๊ณ  ์ „์ฒด path๋ฅผ ๋‹ค์‹œ ๊ทธ๋ฆฌ๋Š” ์ž‘์—…์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.
  • pathStack
    • ๋ชจ๋“  pathData๋ฅผ <pathID, pathData> ์Œ์œผ๋กœ ๊ฐ–์Šต๋‹ˆ๋‹ค.
    • ๋ฆฌ๋“œ๋กœ์šฐ ์‹œ ํ•ด๋‹น pathStack์˜ ์ฒซ ๋ฒˆ์งธ path๋ถ€ํ„ฐ ๋งˆ์ง€๋ง‰ path๊นŒ์ง€ ๋‹ค์‹œ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.
  • myStrokeIDStack
    • ๋‚ด๊ฐ€ ๊ทธ๋ฆฐ Stroke๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฐ์—ด์ž…๋‹ˆ๋‹ค.
    • ์„  ํ•˜๋‚˜๊ฐ€ ์™„์„ฑ๋˜๋ฉด (์ผ๋ฐ˜์ ์œผ๋กœ up ํ˜น์€ leave ์ด๋ฒคํŠธ) myStrokeIDStack์— ํ˜„์žฌ์˜ strokeID๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
    • myStrokeIDIter๋Š” undo, redo ๋ฐœ์ƒ ์‹œ ๋งˆ๋‹ค ์ฆ๊ฐํ•˜๋ฉฐ, myStrokeIDStack์—์„œ ํ˜„์žฌ ์–ด๋Š ์œ„์น˜๋ฅผ ๊ฐ€๋ฆฌ์ผœ์•ผ ํ•˜๋Š” ์ง€๋ฅผ ์•Œ๋ ค์ค๋‹ˆ๋‹ค.
    • undo ๋ฐœ์ƒ ์‹œ ์œ„ stack์—์„œ myStrokeIDIter๋ฅผ ๋’ค๋กœ ์˜ฎ๊ฒจ ํ•˜์—ฌ ํ•ด๋‹น strokeID์— ์†ํ•œ path๋ฅผ ์ „๋ถ€ popPath ํ•ฉ๋‹ˆ๋‹ค.
    • redo ๋ฐœ์ƒ ์‹œ ์œ„ stack์—์„œ myStrokeIDIter๋ฅผ ์•ž์œผ๋กœ ๋‹น๊ฒจ ํ•ด๋‹น strokeID์— ์†ํ•œ path๋ฅผ ์ „๋ถ€ restorePath ํ•ฉ๋‹ˆ๋‹ค.
export class StrokeManager {
  pathStack: Map<string, PathData | null>; //pathID, PathData ์Œ์œผ๋กœ ์ €์žฅ
  myStrokeIDStack: string[];
  myStrokeIDIter: number;

  deletedPathStack: Map<string, PathData | null>;

  constructor() {
    this.pathStack = new Map<string, PathData>();
    this.deletedPathStack = new Map<string, PathData>();
    this.myStrokeIDStack = [];
    this.myStrokeIDIter = 0;
  }
  
  void pushPath(userID: number, strokeID: string, path: Path2D, pathStyle: PathStyle)
  void popPath(pathID: string)
  void restorePath(pathID: string, pathData: PathData)
  void pushMyStroke(strokeID: string)
  void popStroke()
  void restoreStroke()
  void undo(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D)
  void redo(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D)
}

Method

path ์ œ์–ด ๋ฉ”์†Œ๋“œ

pushPath(userID: number, strokeID: string, path: Path2D, pathStyle: PathStyle) {
  if (!this.pathStack) return;

	// ์ƒˆ pathData ๊ฐ์ฒด ์ƒ์„ฑํ•œ๋‹ค.
  const newPath = new PathData(userID, strokeID, path, pathStyle);
  // ์ƒ์„ฑํ•œ pathData์™€ ์Œ์„ ์ด๋ฃฐ pathID๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
  const newPathID = [userID, '-', Date.now()].join('');

	// pathStack Map์— ์ €์žฅํ•œ๋‹ค.
  this.pathStack.set(newPathID, newPath);
}

popPath(pathID: string) {
	// popํ•  pathID์˜ pathData๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.
  const pathData = this.pathStack.get(pathID);
  if (!pathData) return;

	// pathData๋ฅผ ์‚ญ์ œํ•ด์ค€๋‹ค.
  this.pathStack.set(pathID, null);
  
  // ํ›„์— ๋ณต๊ตฌ ํ•  ์ƒํ™ฉ์„ ์œ„ํ•˜์—ฌ deletedPathStack์— ์‚ญ์ œํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐฑ์—…ํ•ด์ค€๋‹ค.
  this.deletedPathStack.set(pathID, pathData);
}

restorePath(pathID: string, pathData: PathData) {
	// ์‚ญ์ œ๋˜์—ˆ๋˜ pathData๋ฅผ ๋ณต๊ตฌํ•ด์ค€๋‹ค.
  this.pathStack.set(pathID, pathData);
}

stroke ์ œ์–ด ๋ฉ”์†Œ๋“œ

pushMyStroke(strokeID: string) {
	// ์ƒˆ stroke์ด ๋“ค์–ด์™”์œผ๋ฏ€๋กœ, ํ˜„์žฌ myStrokeIDIter ์ดํ›„์˜ ID๋“ค์„ ์‚ญ์ œํ•œ๋‹ค. (๋ณต๊ตฌ ๋ถˆ๊ฐ€)
  this.myStrokeIDStack.length = this.myStrokeIDIter + 1;
  
  // ์ƒˆ๋กœ์šด srokeID๋ฅผ ์ €์žฅํ•ด์ค€๋‹ค.
  this.myStrokeIDStack.push(strokeID);

	// ์ƒˆ strokeID๊ฐ€ ๋“ค์–ด์™”์œผ๋‹ˆ, myStrokeIDIter๋ฅผ +1 ํ•ด์ค€๋‹ค.
  this.myStrokeIDIter++;
}

popStroke() {
	//popํ•  strokeID๋ฅผ myStrokeIDStack์—์„œ ๊บผ๋‚ด์˜จ๋‹ค.
  const currentStrokeID = this.myStrokeIDStack[this.myStrokeIDIter--];
  
  //์œ„์—์„œ ๊บผ๋‚ธ strokeID๋ฅผ ๊ฐ€์ง„ path๋ฅผ ์ „๋ถ€ popPush ํ•ด์ค€๋‹ค.
  //myStrokeIDStack์€ '๋‚ด stroke' ๋งŒ๋“ค ๊ด€๋ฆฌํ•˜๋ฏ€๋กœ, ํƒ€์ธ์˜ ์„ ์ด ์‚ญ์ œ๋  ์ผ์€ ์—†๋‹ค.
  for (const [pathID, pathData] of this.pathStack) {
    if (!pathData) continue;
    if (pathData.strokeID === currentStrokeID) this.popPath(pathID);
  }
}

restoreStroke() {
	//๋ณต๊ตฌํ•  StrokeID๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.
  const nextStrokeID = this.myStrokeIDStack[++this.myStrokeIDIter];

	//pathData๋ฅผ ๋ฐฑ์—… ์Šคํƒ์„ ์ˆœํšŒํ•˜์—ฌ ์œ„์—์„œ ๊ฐ€์ ธ์˜จ strokeID์— ํ•ด๋‹นํ•˜๋Š” pathData๋ฅผ ๋ณต๊ตฌํ•œ๋‹ค.
  for (const [pathID, pathData] of this.deletedPathStack) {
    if (!pathData) return;
    if (pathData.strokeID === nextStrokeID) this.restorePath(pathID, pathData);
  }
}

undo & redo ๊ตฌํ˜„

  undo(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
    if (this.myStrokeIDIter <= 0) {
      console.log('๋” undoํ•  ์„ ์ด ์—†์Œ');

      return;
    }
    
    //popStroke ์ž‘๋™
    this.popStroke();

		//์บ”๋ฒ„์Šค ์ดˆ๊ธฐํ™”
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // pathStack์˜ ์ฒซ pathData๋ถ€ํ„ฐ ์ฐจ๋ก€๋Œ€๋กœ ๊ทธ๋ฆฐ๋‹ค.
    for (const pathData of this.pathStack.values()) {
      if (!pathData) continue;
      ctx.beginPath();
      ctx.fillStyle = pathData.style.color;
      ctx.strokeStyle = pathData.style.color;
      ctx.lineWidth = pathData.style.lineWidth;
      if (pathData.style.type === 'path') ctx.stroke(pathData.path);
      if (pathData.style.type === 'shape') ctx.fill(pathData.path);
    }
  }

  redo(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
    if (this.myStrokeIDIter >= this.myStrokeIDStack.length - 1) {
      console.log('๋” redoํ•  ์„ ์ด ์—†์Œ');
      return;
    }
    
    //restoreStroke ์ž‘๋™
    this.restoreStroke();

		//์บ”๋ฒ„์Šค ์ดˆ๊ธฐํ™”
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // pathStack์˜ ์ฒซ pathData๋ถ€ํ„ฐ ์ฐจ๋ก€๋Œ€๋กœ ๊ทธ๋ฆฐ๋‹ค.
    for (const pathData of this.pathStack.values()) {
      if (!pathData) continue;
      ctx.beginPath();
      ctx.fillStyle = pathData.style.color;
      ctx.strokeStyle = pathData.style.color;
      ctx.lineWidth = pathData.style.lineWidth;
      if (pathData.style.type === 'path') ctx.stroke(pathData.path);
      if (pathData.style.type === 'shape') ctx.fill(pathData.path);
    }
  }

StrokeManager ์ ์šฉ ์˜ˆ

useRef ์‚ฌ์šฉ

  • strokeManager๋Š” ์•ฑ ๋‚ด์—์„œ ๋”ฑ ํ•˜๋‚˜๋งŒ ๋งŒ๋“ค์–ด์ง€๋ฉด ๋ฉ๋‹ˆ๋‹ค.
  • ๊ทธ๋ฆฌ๊ธฐ๊ฐ€ ์‹œ์ž‘๋˜๋Š” startDrawing๋ถ€ํ„ฐ ๋๋‚˜๋Š” stopDrawing๊นŒ์ง€ ๊ฐ™์€ path2D ๊ฐ์ฒด์— ๊ฒฝ๋กœ๋ฅผ ์ €์žฅํ•ด์•ผํ•˜๋ฏ€๋กœ ref๋กœ ์„ ์–ธํ•ฉ๋‹ˆ๋‹ค.
  • currentID ๋˜ํ•œ startDrawing์™€ draw ํ•จ์ˆ˜์—์„œ ๋งŒ๋“ค์–ด์ง€๋Š” ๋ชจ๋“  path๊ฐ€ ๊ฐ™์€ strokeID๋ฅผ ๋ฐฐ์ •๋ฐ›์•„์•ผ ํ•˜๋ฏ€๋กœ ref๋กœ ์„ ์–ธํ•ฉ๋‹ˆ๋‹ค.
  • path2D๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ด์œ 
    • path2D๋Š” ์ €์žฅํ•œ ๊ฒฝ๋กœ๋ฅผ canvas์— ์บ์‹œํ•˜์—ฌ, ๋ฆฌ๋“œ๋กœ์šฐ ์‹œ ๊ฒฝ๋กœ ์žฌ๊ณ„์‚ฐ์„ ์ค„์—ฌ ์„ฑ๋Šฅ ํ–ฅ์ƒ์„ ๋…ธ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ๋™์‹œ์— ๋‘ ๊ฐœ ์ด์ƒ์˜ ์„ ์„ ๊ทธ๋ฆฌ๊ธฐ ์œ„ํ•ด์„œ๋Š” path2D๋กœ ๊ฒฝ๋กœ๋ฅผ ๋ถ„๋ฆฌํ•ด์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.
      • ctx๋Š” canvas์—์„œ 1๊ฐœ๋งŒ ์žˆ๋Š” ๊ฐ์ฒด์ด๊ธฐ ๋•Œ๋ฌธ์—, ๋‘ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ์ ‘๊ทผํ•˜์—ฌ ๋“œ๋กœ์ž‰ ์‹œ ๋‘ ์‚ฌ์šฉ์ž์˜ ์ ์ด ์ด์–ด์ง€๋Š” ํ˜„์ƒ ๋“ฑ ์˜ค๋ฅ˜๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
const strokeManager = useRef(new StrokeManager());
const path2D = useRef<Path2D | null>(null);
const currentStrokeID = useRef<string | null>(null);

๊ทธ๋ฆฌ๊ธฐ ์‹œ์ž‘ ๋ถ€

const startDrawing = useCallback(
    (point: Point) => {
      if (!strokeManager.current) return;

			...
			...

      const { ctx } = getCanvasContext(canvasRef);

			//์‹ค์‹œ๊ฐ„ ๊ทธ๋ฆฌ๊ธฐ๋ฅผ ์œ„ํ•ด ctx์— ๋“œ๋กœ์ž‰ style์„ ์ง€์ •
      ctx.strokeStyle = currentColor;
      ctx.lineWidth = brushSize;
      ctx.fillStyle = currentColor;

			//ํด๋ฆญ ์‹œ ์  ์ฐ๋Š” ๊ฒฝ๋กœ๋ฅผ Path2D ๊ฐ์ฒด์— ์ €์žฅํ•œ๋‹ค.
      path2D.current = new Path2D();
      path2D.current.arc(point.x, point.y, brushSize / 2, 0, Math.PI * 2);
      
			//์œ„์˜ path2D ๊ฐ์ฒด๋ฅผ ๋“œ๋กœ์ž‰ํ•œ๋‹ค.
      ctx.fill(path2D.current);

			//strokeManager์— ๋ณด๋‚ผ PathStyle ๊ฐ์ฒด๋ฅผ ์ค€๋น„ํ•œ๋‹ค.
      const pathStyle: PathStyle = {
        color: currentColor,
        lineWidth: brushSize,
        type: 'shape',
      };
			
			// strokeID๋ฅผ ์ƒˆ๋กœ ๋งŒ๋“ ๋‹ค.
      currentStrokeID.current = [0, '-', Date.now(), '-stroke'].join('');
      // pathStack์— ์ €์žฅํ•ด์ค€๋‹ค.
      strokeManager.current.pushPath(0, currentStrokeID.current, path2D.current, pathStyle);

			// draw์—์„œ ์ƒˆ๋กœ ๋งŒ๋“ค์–ด์งˆ ๊ฒฝ๋กœ๋“ค์„ ์œ„ํ•ด path2D๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๊ณ , ์‹œ์ž‘ ์ง€์ ์„ ๋ฐฐ์ •ํ•œ๋‹ค.
      path2D.current = new Path2D();
      path2D.current.moveTo(point.x, point.y);
      
      ...
      ...
      

    },
    [.....],
  );

๊ทธ๋ฆฌ๋Š” ์ด์–ด๊ฐ€๋Š” ๋ถ€

const draw = useCallback(
  (point: Point) => {
    if (!path2D.current) return;
    if (!currentStrokeID.current) return;
    
    ...
    ...

    const { ctx } = getCanvasContext(canvasRef);

		// 'startDrawing'๋ฉ”์†Œ๋“œ์—์„œ ์ดˆ๊ธฐํ™”ํ•œ path2D์— ๊ฒฝ๋กœ๋ฅผ ์ด์–ด ์ €์žฅํ•œ๋‹ค. 
    path2D.current.lineTo(point.x, point.y);
    // path2D ๊ฐ์ฒด์— ์ €์žฅ๋œ ๊ฒฝ๋กœ๋ฅผ ๊ทธ๋ฆฐ๋‹ค.
    ctx.stroke(path2D.current);

		//strokeManager์— ๋ณด๋‚ผ PathStyle ๊ฐ์ฒด๋ฅผ ์ค€๋น„ํ•œ๋‹ค.
    const pathStyle: PathStyle = {
      color: currentColor,
      lineWidth: brushSize,
      type: 'path',
    };
    
    // pathStack์— ์ €์žฅํ•ด์ค€๋‹ค.
    strokeManager.current.pushPath(0, currentStrokeID.current, path2D.current, pathStyle);

	...
	...
	
  },
  [.....],
);

๊ทธ๋ฆฌ๊ธฐ ์ข…๋ฃŒ ๋ถ€

  const stopDrawing = useCallback(() => {
    if (!currentStrokeID.current) return;

		...
		...
    
    // ๋“œ๋กœ์ž‰์ด ๋๋‚ฌ์œผ๋‹ˆ strokeID๋ฅผ myStrokeIDStack ์— ์ €์žฅํ•ด์ค€๋‹ค.
    // ๋‚ด stroke์ผ ๊ฒฝ์šฐ์—๋งŒ ์ €์žฅํ•ด์•ผ ํ•œ๋‹ค.
    strokeManager.current.pushMyStroke(currentStrokeID.current);
    path2D.current = null;

		...
		...
  }, [.....]);

redo, undo Event Handler

const undo = () => {
  const { canvas, ctx } = getCanvasContext(canvasRef);
  strokeManager.current.undo(canvas, ctx);
};

const redo = () => {
  const { canvas, ctx } = getCanvasContext(canvasRef);
  strokeManager.current.redo(canvas, ctx);
};

๊ตฌํ˜„ ์ค‘ ๋งˆ์ฃผ์นœ ๋ฌธ์ œ๋“ค

stopDrawing ํ•จ์ˆ˜ ์ž‘๋™ ์กฐ๊ฑด

  • ์•„๋ž˜์˜ ํ•ด๋‹น ๋ผ์ธ ์ด ๋ˆ„๋ฝ๋˜์–ด ๋งˆ์šฐ์Šค๊ฐ€ ํด๋ฆญํ•œ ์ƒํƒœ๊ฐ€ ์•„๋‹Œ๋ฐ๋„ ์บ”๋ฒ„์Šค๋ฅผ ๋ฒ—์–ด๋‚˜๋ฉด pushMyStroke์ด ์ž‘๋™ํ•˜๋Š” ์ด์Šˆ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
  • ํ•ด๋‹น ๋ผ์ธ ์œผ๋กœ ๊ทธ๋ฆฌ๊ธฐ ์ƒํƒœ์˜€์„ ๋•Œ์—๋งŒ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ž‘๋™ํ•˜๋„๋ก ์ˆ˜์ •ํ•˜์—ฌ ํ•ด๊ฒฐํ•˜์˜€์Šต๋‹ˆ๋‹ค.
  const stopDrawing = useCallback(() => {
    if (!drawingState.isDrawing) return; // ํ•ด๋‹น ๋ผ์ธ
    if (!currentStrokeID.current) return;
    strokeManager.current.pushMyStroke(currentStrokeID.current);
    path2D.current = null;

    setDrawingState({
      isDrawing: false,
      startPoint: null,
    });
  }, [drawingState.isDrawing]);

์ž˜๋ชป๋œ EventHandler ์ ์šฉ

  • GameCanvasExample.tsx ์—์„œ Canvas ์ปดํฌ๋„ŒํŠธ์— ํ”„๋กญ์Šค๋ฅผ ์ „๋‹ฌํ•  ๋•Œ, ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋ฅผ ์ค‘๋ณต ์ „๋‹ฌํ•˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
  • ์•„๋ž˜ return ๋ถ€์˜ onMouseUp ๋ถ€ํ„ฐ onTouchCancel ๊นŒ์ง€์˜ ์†์„ฑ์€ canvas ํƒœ๊ทธ๊ฐ€ ์•„๋‹ˆ๋ผ ๊ทธ ๋ถ€๋ชจ์˜ ๋ถ€๋ชจ์ธ div ํƒœ๊ทธ์— ์ ์šฉ๋˜๋Š” ๋กœ์ง์œผ๋กœ ๊ตฌ์„ฑ๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค.
  • ๋”ฐ๋ผ์„œ, ํ•ด๋‹น ๋ถ€๋ถ„๋“ค์„ ์‚ญ์ œํ•˜์—ฌ ์ด๋ฒคํŠธ๊ฐ€ ์ค‘๋ณต ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜์˜€์Šต๋‹ˆ๋‹ค.
    • (๋ธŒ๋žœ์น˜์— ๋ณ‘ํ•ฉ๋œ ์ฝ”๋“œ์ด๋ฏ€๋กœ, ์ดํ›„ ์ˆ˜์ • ์‹œ ๊ผญ ์‚ญ์ œํ•ด์ค˜์•ผ ํ•˜๋Š” ์ฝ”๋“œ๋‹ค.)
  return (
    <Canvas
      canvasRef={canvasRef}
      className="min-w-[280px]"
      isDrawable={isDrawable}
      colors={isDrawable ? COLORS : []}
      // toolbarPosition="floating"
      brushSize={brushSize}
      setBrushSize={setBrushSize}
      drawingMode={drawingMode}
      onDrawingModeChange={setDrawingMode}
      inkRemaining={inkRemaining}
      maxPixels={maxPixels}
      canUndo={canUndo}
      canRedo={canRedo}
      onUndo={undo}
      onRedo={redo}
      canvasEvents={canvasEventHandlers}
      
      // canvas ํƒœ๊ทธ ๊ธฐ๋ณธ ์†์„ฑ (ํ•ด๋‹น ์•„๋ž˜ ๋ถ€๋ถ„์ด ์ž˜๋ชป๋จ)
      onMouseUp={stopDrawing}
      onMouseLeave={stopDrawing}
      onTouchEnd={stopDrawing}
      onTouchCancel={stopDrawing}
    />
  );

์ •๋ฆฌ

ํ˜„์žฌ ๊ตฌํ˜„ ์ƒํƒœ์˜ ๋ฌธ์ œ์ 

  • ํ˜„์žฌ๋Š” move ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ ๋งˆ๋‹ค (์ƒˆ ์ขŒํ‘œ๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ ๋งˆ๋‹ค) path2D๋ฅผ ์ƒ์„ฑํ•ด ์ €์žฅํ•˜๊ณ  ์žˆ๊ณ , ๊ทธ๋ ‡๋‹ค๋ณด๋‹ˆ path ๊ด€๋ฆฌ ๋‹จ์œ„๊ฐ€ ๊ต‰์žฅํžˆ ์ž‘์Šต๋‹ˆ๋‹ค.
  • ์–ผ๋งˆ๋‚˜ ์ž‘๋ƒํ•˜๋ฉด, ์กฐ๊ธˆ๋งŒ ๊ทธ๋ ค๋„ pathStack์— ์š”์†Œ๊ฐ€ 700~900๊ฐœ ๋„˜๊ฒŒ ์Œ“์ž…๋‹ˆ๋‹ค.
    • ๋ฉ€ํ‹ฐ ์œ ์ €๊นŒ์ง€ ์ฐธ์—ฌํ•œ๋‹ค๋ฉด 2000๊ฐœ๋Š” ์กฑํžˆ ๋„˜์„ ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋ฉ๋‹ˆ๋‹ค.
  • path2D ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•˜๊ธด ํ–ˆ์ง€๋งŒ, 2000๊ฐœ ๊ฐ€๋Ÿ‰์˜ path๋ฅผ undo & redo ํ•  ๋•Œ๋งˆ๋‹ค ๋ฆฌ๋“œ๋กœ์šฐ ํ•ด์•ผํ•œ๋‹ค๋Š” ์ ์€ ์„ฑ๋Šฅ๋ฉด์—์„œ ๋„ˆ๋ฌด ์น˜๋ช…์ ์ด์ง€ ์•Š๋‚˜ ์ƒ๊ฐ๋ฉ๋‹ˆ๋‹ค.
    • ์‹ค์ œ๋กœ ์„ ์„ ์•„์ฃผ ๊ธฐ์ด์ด์ผ๊ฒŒ ๊ทธ๋ฆฌ๋ฉด ์ข€ ๋Š๋ ค์ง€๋Š” ๋“ฏํ•œ ์ฒด๊ฐ์ด ๋“ญ๋‹ˆ๋‹ค.

๋Œ€์•ˆ

  • path ๋‹จ์œ„๋กœ ์„ ์„ ์„ ๊ด€๋ฆฌํ•˜๋Š” ํ˜„์žฌ ๋ฐฉ์‹์˜ ์žฅ์ ์€, path ๊ฐ„์˜ ์šฐ์„  ์ˆœ์œ„๊ฐ€ ๋†’์€ ์ •ํ™•๋„๋กœ ์ง€์ผœ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.
    • ๋‘ ์„ ์„ ๋ง‰ ๊ฒน์ณ ๊ทธ๋ฆฐ ํ›„ undo/redo ํ•˜์—ฌ๋„ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.
  • ํ•˜์ง€๋งŒ ์œ„์—์„œ ์–ธ๊ธ‰ํ•œ ๋ฌธ์ œ์ ์ด ์žˆ์œผ๋‹ˆ, ์กฐ์œจํ•œ๋‹ค๋ฉด ์•„๋ž˜์˜ ๋ฐฉํ–ฅ์„ ์ƒ์˜ํ•ด๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    • ์„  ๊ด€๋ฆฌ ๋‹จ์œ„ ์กฐ์ •
      • path ๋‹จ์œ„ or stroke ๋‹จ์œ„ or ๊ทธ ์‚ฌ์ด์˜ ์–ด๋”˜๊ฐ€
      • stroke ๋‹จ์œ„๋กœ ๊ด€๋ฆฌํ•˜๊ฒŒ ๋˜๋ฉด, undo & redo ์‹œ path๊ฐ€ ์•„๋‹Œ ์„  ๋‹จ์œ„๋กœ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ์ •ํ•ด์ง„๋‹ค.
    • ๋‹ค๋ฅธ ๋ฐฉ๋ฒ• ๋ชจ์ƒ‰
  • ์—ฌ๊ธฐ์„œ ์šฐ์„ ์ˆœ์œ„๋ž€, ์บ”๋ฒ„์Šค์—์„œ ์‹œ๊ฐ์ ์œผ๋กœ ์–ด๋–ค ์„ ์ด ๋” ์œ„๋กœ ์˜ฌ๋ผ๊ฐˆ ๊ฒƒ์ธ๊ฐ€ ์ž…๋‹ˆ๋‹ค.

์ถ”๊ฐ€ ๊ตฌํ˜„ ํ•„์š”

  • redo / undo ์‹œ ๋‚จ์€ ํ”ฝ์…€๋Ÿ‰ ๋ฐ˜์˜
  • ํŽ˜์ธํŠธ ํˆด ๋ณ‘ํ•ฉ

๐Ÿ˜Ž ์›จ๋ฒ ๋ฒ ๋ฒ ๋ฒฑ

๐Ÿ‘ฎ๐Ÿป ํŒ€ ๊ทœ์น™

๐Ÿ’ป ํ”„๋กœ์ ํŠธ

๐Ÿชต ์›จ๋ฒ ๋ฒฑ ๊ธฐ์ˆ ๋กœ๊ทธ

๐Ÿช„ ๋ฐ๋ชจ ๊ณต์œ 

๐Ÿ”„ ์Šคํ”„๋ฆฐํŠธ ๊ธฐ๋ก

๐Ÿ“— ํšŒ์˜๋ก

Clone this wiki locally