Skip to content
Closed
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
766 changes: 189 additions & 577 deletions packages/pointer-native-drawing/src/DrawingCanvas.tsx

Large diffs are not rendered by default.

213 changes: 213 additions & 0 deletions packages/pointer-native-drawing/src/engine/HistoryManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { type Stroke, type StrokeBounds, type TextItem, type DocumentSnapshot } from '../model/drawingTypes';

// ---------------------------------------------------------------------------
// History entry types (stroke only — textbox entries added in MAT-359)
// ---------------------------------------------------------------------------

export type AppendStrokeEntry = {
readonly type: 'append-stroke';
readonly stroke: Stroke;
readonly bounds: StrokeBounds;
readonly snapshotBefore: DocumentSnapshot;
};

export type EraseStrokesEntry = {
readonly type: 'erase-strokes';
readonly snapshotBefore: DocumentSnapshot;
readonly snapshotAfter: DocumentSnapshot;
/** Cached SkPath[] from before the erase — used for O(1) undo. */
readonly cachedPathsBefore?: readonly unknown[];
};

export type ReplaceDocumentEntry = {
readonly type: 'replace-document';
readonly snapshotBefore: DocumentSnapshot;
readonly snapshotAfter: DocumentSnapshot;
readonly textsBefore: readonly TextItem[];
readonly textsAfter: readonly TextItem[];
};

export type HistoryEntry =
| AppendStrokeEntry
| EraseStrokesEntry
| ReplaceDocumentEntry;

// ---------------------------------------------------------------------------
// State listener
// ---------------------------------------------------------------------------

export type HistoryStateListener = (state: { canUndo: boolean; canRedo: boolean }) => void;

// ---------------------------------------------------------------------------
// HistoryManager — command pattern
//
// append-stroke undo: O(1) — slice로 마지막 stroke 제거
// erase-strokes undo: snapshot 복원
// replace-document undo: snapshot + texts 복원
// ---------------------------------------------------------------------------

const DEFAULT_MAX_SIZE = 50;

export class HistoryManager {
private stack: HistoryEntry[] = [];
/** Points to the last pushed/applied entry (-1 = empty). */
private pointer = -1;
private readonly maxSize: number;
private locked = false;
private listener: HistoryStateListener | null = null;
private onEntryEvicted: ((entry: HistoryEntry) => void) | null = null;
private activeTransaction: {
snapshotBefore: DocumentSnapshot;
cachedPaths?: readonly unknown[];
} | null = null;

constructor(maxSize: number = DEFAULT_MAX_SIZE) {
this.maxSize = maxSize;
}

// -----------------------------------------------------------------------
// Listener
// -----------------------------------------------------------------------

setListener(listener: HistoryStateListener | null): void {
this.listener = listener;
}

setOnEntryEvicted(cb: ((entry: HistoryEntry) => void) | null): void {
this.onEntryEvicted = cb;
}

private notifyListener(): void {
this.listener?.({ canUndo: this.canUndo(), canRedo: this.canRedo() });
}

private evictEntries(entries: HistoryEntry[]): void {
if (!this.onEntryEvicted) return;
for (const e of entries) this.onEntryEvicted(e);
}

// -----------------------------------------------------------------------
// Push
// -----------------------------------------------------------------------

push(entry: HistoryEntry): void {
if (this.pointer < this.stack.length - 1) {
this.evictEntries(this.stack.slice(this.pointer + 1));
this.stack.length = this.pointer + 1;
}

this.stack.push(entry);
this.pointer = this.stack.length - 1;

if (this.stack.length > this.maxSize) {
const evicted = this.stack.shift()!;
this.evictEntries([evicted]);
this.pointer = this.stack.length - 1;
}

this.notifyListener();
}

// -----------------------------------------------------------------------
// Undo / Redo — returns the entry for the caller to interpret
// -----------------------------------------------------------------------

undo(): HistoryEntry | null {
if (!this.canUndo()) return null;
const entry = this.stack[this.pointer];
this.pointer--;
this.notifyListener();
return entry;
}

redo(): HistoryEntry | null {
if (!this.canRedo()) return null;
this.pointer++;
const entry = this.stack[this.pointer];
this.notifyListener();
return entry;
}

// -----------------------------------------------------------------------
// Query
// -----------------------------------------------------------------------

canUndo(): boolean {
return !this.locked && this.pointer >= 0;
}

canRedo(): boolean {
return !this.locked && this.pointer < this.stack.length - 1;
}

// -----------------------------------------------------------------------
// Erase transaction
// -----------------------------------------------------------------------

beginTransaction(snapshotBefore: DocumentSnapshot, cachedPaths?: readonly unknown[]): void {
this.discardTransaction();
this.activeTransaction = { snapshotBefore, cachedPaths };
}

commitTransaction(snapshotAfter: DocumentSnapshot): void {
if (!this.activeTransaction) return;
const { snapshotBefore, cachedPaths } = this.activeTransaction;
this.activeTransaction = null;

if (snapshotBefore.strokes.length === snapshotAfter.strokes.length) return;

this.push({
type: 'erase-strokes',
snapshotBefore,
snapshotAfter,
...(cachedPaths ? { cachedPathsBefore: cachedPaths } : {}),
});
}

discardTransaction(): void {
if (this.activeTransaction?.cachedPaths) {
this.evictEntries([{
type: 'erase-strokes',
snapshotBefore: this.activeTransaction.snapshotBefore,
snapshotAfter: this.activeTransaction.snapshotBefore,
cachedPathsBefore: this.activeTransaction.cachedPaths,
}]);
}
this.activeTransaction = null;
}

hasActiveTransaction(): boolean {
return this.activeTransaction !== null;
}

// -----------------------------------------------------------------------
// Lock (text editing blocks canvas undo/redo)
// -----------------------------------------------------------------------

lock(): void {
this.locked = true;
this.notifyListener();
}

unlock(): void {
this.locked = false;
this.notifyListener();
}

isLocked(): boolean {
return this.locked;
}

// -----------------------------------------------------------------------
// Clear
// -----------------------------------------------------------------------

clear(): void {
this.evictEntries(this.stack);
this.discardTransaction();
this.stack = [];
this.pointer = -1;
this.locked = false;
this.notifyListener();
}
}
6 changes: 5 additions & 1 deletion packages/pointer-native-drawing/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export { default as DrawingCanvas } from './DrawingCanvas';
export type { DrawingCanvasRef, Point, Stroke, TextItem } from './DrawingCanvas';
export { buildSmoothPath } from './smoothing';

// model
export type { Point, Stroke, TextItem, DrawingCanvasRef } from './model/drawingTypes';
export type { ReadonlyPoint, ReadonlyStroke, StrokeBounds } from './model/drawingTypes';
export { deepCopyStrokes, deepCopyTexts, safeMax, computeStrokeBounds } from './model/strokeUtils';
74 changes: 74 additions & 0 deletions packages/pointer-native-drawing/src/model/drawingTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// ── 기본 타입 ──

export type Point = { x: number; y: number };

export type Stroke = {
points: Point[];
color: string;
width: number;
};

export type TextItem = {
id: string;
text: string;
x: number;
y: number;
fontSize: number;
color: string;
};

// ── Readonly 타입 (불변 참조용) ──

export type ReadonlyPoint = Readonly<Point>;

export type ReadonlyStroke = {
readonly points: readonly ReadonlyPoint[];
readonly color: string;
readonly width: number;
};

// ── Bounds ──

/** stroke의 AABB (axis-aligned bounding box). 지우개 히트 테스트 최적화 기반. */
export type StrokeBounds = {
readonly minX: number;
readonly minY: number;
readonly maxX: number;
readonly maxY: number;
};

// ── 컴포넌트 공개 API ──

export type DrawingCanvasRef = {
clear: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
getStrokes: () => Stroke[];
setStrokes: (strokes: Stroke[]) => void;
getTexts: () => TextItem[];
setTexts: (texts: TextItem[]) => void;
};

export type DrawingCanvasProps = {
strokeColor?: string;
strokeWidth?: number;
onChange?: (strokes: Stroke[]) => void;
onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void;
eraserMode?: boolean;
eraserSize?: number;
textMode?: boolean;
textFontPath?: number;
};

// ── Snapshot (lightweight — stores references, not deep copies) ─

export type DocumentSnapshot = {
readonly strokes: readonly Stroke[];
readonly bounds: readonly StrokeBounds[];
};

// ── History (legacy — DrawingCanvas에서 HistoryManager로 대체됨) ──

export type HistoryState = { strokes: Stroke[]; texts: TextItem[] };
37 changes: 37 additions & 0 deletions packages/pointer-native-drawing/src/model/strokeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type Point, type Stroke, type TextItem, type StrokeBounds } from './drawingTypes';

// ── Deep copy ──

export const deepCopyStrokes = (strokes: Stroke[]): Stroke[] =>
strokes.map((stroke) => ({
points: stroke.points.map((p) => ({ ...p })),
color: stroke.color,
width: stroke.width,
}));

export const deepCopyTexts = (texts: TextItem[]): TextItem[] => texts.map((text) => ({ ...text }));

// ── 배열 유틸 ──

export const safeMax = (arr: number[], fallback = 0): number =>
arr.length > 0 ? arr.reduce((max, v) => (v > max ? v : max), arr[0]) : fallback;

// ── Bounds 계산 ──

/** single-pass O(n)으로 stroke의 AABB를 계산. MAT-360 지우개 히트 테스트 최적화 기반. */
export function computeStrokeBounds(points: readonly Point[]): StrokeBounds {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;

for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}

return { minX, minY, maxX, maxY };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { Canvas } from '@shopify/react-native-skia';

type SkiaDrawingCanvasSurfaceProps = {
height: number;
children: React.ReactNode;
};

export function SkiaDrawingCanvasSurface({ height, children }: SkiaDrawingCanvasSurfaceProps) {
return <Canvas style={[styles.canvas, { height }]}>{children}</Canvas>;
}

const styles = StyleSheet.create({
canvas: { width: '100%', backgroundColor: 'transparent' },
});
42 changes: 42 additions & 0 deletions packages/pointer-native-drawing/src/render/skia/skiaRenderUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type MeasureTextFn = (text: string) => { width: number };

/**
* 텍스트를 최대 너비 기준으로 줄바꿈하여 라인 배열로 반환.
* calculateTextLineCount와 renderedTexts에서 공유하여 중복 제거.
*/
export function wrapTextToLines(
text: string,
maxWidth: number,
measureText: MeasureTextFn
): string[] {
const allLines: string[] = [];
const paragraphs = text.split('\n');

for (const paragraph of paragraphs) {
if (!paragraph) {
allLines.push('');
continue;
}

const words = paragraph.split(' ');
let currentLine = '';

for (let i = 0; i < words.length; i++) {
const testLine = currentLine ? `${currentLine} ${words[i]}` : words[i];
const textWidth = measureText(testLine).width;

if (textWidth > maxWidth && currentLine) {
allLines.push(currentLine);
currentLine = words[i];
} else {
currentLine = testLine;
}

if (i === words.length - 1) {
allLines.push(currentLine);
}
}
}

return allLines;
}
Loading
Loading