├── package-mini.json └── pages ├── canvas ├── context │ ├── config.tsx │ ├── descriptor.ts │ └── index.tsx ├── drawable │ ├── cache-drawable.tsx │ ├── chalk-drawable.tsx │ ├── clear-drawable.tsx │ ├── drawable.tsx │ ├── line-drawable.tsx │ ├── path-drawable.tsx │ ├── path-with-stroke-drawable.tsx │ └── raw-drawable.tsx ├── index.css ├── index.tsx ├── toolbar │ ├── color-size-popover │ │ └── index.tsx │ ├── eraser-popover │ │ └── index.tsx │ ├── index.module.css │ └── index.tsx └── utils │ ├── mouse-to-touch.tsx │ ├── noop.tsx │ ├── use-canvas-dpr.tsx │ ├── use-canvas-events.tsx │ ├── use-create-subscription.tsx │ ├── use-dimension-detector.tsx │ ├── use-dpr.tsx │ └── use-register-canvas.tsx ├── index.css └── index.tsx /package-mini.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tippyjs/react": "^4.2.5", 4 | "clsx": "^1.1.1", 5 | "react": "^17.0.1", 6 | "react-color": "^2.19.3", 7 | "react-dom": "^17.0.1", 8 | "react-draggable": "^4.4.4", 9 | "resize-detector": "^0.3.0", 10 | "tinycolor2": "^1.4.2" 11 | }, 12 | "devDependencies": { 13 | "@types/jest": "^26.0.9", 14 | "@types/node": "^14", 15 | "@types/offscreencanvas": "^2019.6.4", 16 | "@types/react": "^17", 17 | "@types/react-dom": "^17", 18 | "@types/tinycolor2": "^1.4.3", 19 | "typescript": "^4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pages/canvas/context/config.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useCreateSubscription } from '../utils/use-create-subscription'; 3 | 4 | export type BrushType = 'pen' | 'eraser' | 'chalk' | 'stroke'; 5 | export type CanvasState = 'normal' | 'locked' | 'hidden'; 6 | 7 | export type CanvasConfigType = { 8 | color: string; 9 | lineWidth: number; 10 | eraserWidth: number; 11 | type: BrushType; 12 | canvasState: CanvasState; 13 | }; 14 | 15 | export const defaultCanvasConfig: CanvasConfigType = { 16 | type: 'pen', 17 | canvasState: 'normal', 18 | color: '#000000', 19 | lineWidth: 4, 20 | eraserWidth: 16, 21 | }; 22 | 23 | export function useInternalCanvasConfig() { 24 | const target = useMemo( 25 | () => ({ 26 | ...defaultCanvasConfig, 27 | canvasState: 'locked', 28 | }), 29 | [], 30 | ); 31 | const { proxied, subscribe } = useCreateSubscription(target); 32 | return { config: proxied, subscribe }; 33 | } 34 | -------------------------------------------------------------------------------- /pages/canvas/context/descriptor.ts: -------------------------------------------------------------------------------- 1 | import Color from 'tinycolor2'; 2 | 3 | import { CacheDrawable } from '../drawable/cache-drawable'; 4 | import { ClearDrawable } from '../drawable/clear-drawable'; 5 | import { Drawable } from '../drawable/drawable'; 6 | import { PathDrawable } from '../drawable/path-drawable'; 7 | import { RawDrawable } from '../drawable/raw-drawable'; 8 | import { mouseToTouchInit } from '../utils/mouse-to-touch'; 9 | import { CanvasConfigType, defaultCanvasConfig } from './config'; 10 | 11 | export class CanvasDescriptor { 12 | #committed: RawDrawable[] = []; 13 | 14 | id: string; 15 | 16 | config: CanvasConfigType; 17 | 18 | mainCanvas: (HTMLCanvasElement & { cacheCanvas?: HTMLCanvasElement }) | null = 19 | null; 20 | 21 | get cacheCanvas(): HTMLCanvasElement | null { 22 | const { mainCanvas } = this; 23 | if (!mainCanvas) { 24 | return null; 25 | } 26 | const cacheCanvas = 27 | mainCanvas.cacheCanvas || document.createElement('canvas'); 28 | 29 | if ( 30 | cacheCanvas.height !== mainCanvas.height || 31 | cacheCanvas.width !== mainCanvas.width 32 | ) { 33 | this.rasterizedLength = 0; 34 | cacheCanvas.height = mainCanvas.height; 35 | cacheCanvas.width = mainCanvas.width; 36 | } 37 | 38 | mainCanvas.cacheCanvas = cacheCanvas; 39 | return cacheCanvas; 40 | } 41 | 42 | committedDrawable: Drawable[]; 43 | 44 | acceptedTouches: Map = new Map(); 45 | 46 | mouseOverEvent?: MouseEvent; 47 | 48 | rasterizedLength: number = 0; 49 | 50 | afterUndoLength: number | undefined = undefined; 51 | 52 | onCommittedUpdated?: (target: CanvasDescriptor) => void; 53 | 54 | activeAnimationFrame: number | undefined; 55 | 56 | scheduledUpdate: boolean = false; 57 | 58 | constructor(id: string, config?: CanvasConfigType) { 59 | this.id = id; 60 | this.config = config ?? defaultCanvasConfig; 61 | 62 | // eslint-disable-next-line consistent-this, @typescript-eslint/no-this-alias 63 | const desc = this; 64 | this.committedDrawable = new Proxy(this.#committed, { 65 | get(t, p, r) { 66 | // get 的时候,骗对方说已经撤销的步骤不存在 67 | if (p === 'length' && desc.afterUndoLength != null) { 68 | return desc.afterUndoLength; 69 | } 70 | return Reflect.get(t, p, r); 71 | }, 72 | set(t, p, v, r) { 73 | if (p !== 'length') { 74 | desc.onCommittedUpdated?.(desc); 75 | desc.afterUndoLength = undefined; 76 | } 77 | return Reflect.set(t, p, v, r); 78 | }, 79 | }); 80 | } 81 | 82 | draw() { 83 | const pendingDrawable = [...this.acceptedTouches.values()]; 84 | const canvas = this.mainCanvas; 85 | 86 | if (!canvas || canvas.height === 0 || canvas.width === 0) { 87 | // 画布不存在,不用绘制了 88 | return false; 89 | } 90 | 91 | if (this.activeAnimationFrame) { 92 | // 跳过这次绘制 93 | this.scheduledUpdate = true; 94 | return false; 95 | } 96 | const ctx = canvas.getContext('2d'); 97 | 98 | if (!ctx) { 99 | return false; 100 | } 101 | 102 | this.activeAnimationFrame = requestAnimationFrame(() => { 103 | ctx.lineCap = 'round'; 104 | ctx.lineJoin = 'round'; 105 | // clear canvas 106 | new ClearDrawable(this).draw(); 107 | // cache canvas 108 | const cache = new CacheDrawable(this); 109 | cache.draw(); 110 | cache.update(); 111 | // draw committed 112 | this.committedDrawable.slice(this.rasterizedLength).forEach(path => { 113 | path.draw(); 114 | }); 115 | // draw pending 116 | [...pendingDrawable].forEach(path => { 117 | path.draw(); 118 | }); 119 | // draw indicator 120 | const indicatorPath: RawDrawable[] = [...(pendingDrawable as any)]; 121 | if (!indicatorPath.length && this.mouseOverEvent) { 122 | const touch = new Touch(mouseToTouchInit(this.mouseOverEvent)); 123 | const mouse = RawDrawable.start(this, touch); 124 | mouse.commit(); 125 | indicatorPath.push(mouse); 126 | } 127 | const { type, lineWidth, eraserWidth, color } = this.config; 128 | indicatorPath.forEach(path => { 129 | const mouseIndicator = new PathDrawable(this, { 130 | color: 131 | type === 'eraser' 132 | ? Color('#000000').setAlpha(0.4).toHex8String() 133 | : color, 134 | lineWidth: type === 'eraser' ? eraserWidth : lineWidth, 135 | eraserWidth, 136 | type: 'pen', 137 | }); 138 | mouseIndicator.commit(path.getEvents().slice(-1)); 139 | mouseIndicator.draw(); 140 | }); 141 | this.activeAnimationFrame = undefined; 142 | if (this.scheduledUpdate) { 143 | // 有跳过的绘制,重新执行 144 | this.scheduledUpdate = false; 145 | this.draw(); 146 | } 147 | }); 148 | return true; 149 | } 150 | 151 | undo() { 152 | if (this.afterUndoLength == null) { 153 | this.afterUndoLength = this.#committed.length; 154 | } 155 | if (this.afterUndoLength <= 0) { 156 | return false; 157 | } 158 | this.afterUndoLength -= 1; 159 | if (this.rasterizedLength > this.afterUndoLength) { 160 | this.rasterizedLength = 0; 161 | } 162 | return true; 163 | } 164 | 165 | redo() { 166 | if ( 167 | this.afterUndoLength == null || 168 | this.afterUndoLength >= this.#committed.length 169 | ) { 170 | return false; 171 | } 172 | this.afterUndoLength += 1; 173 | return true; 174 | } 175 | 176 | clear() { 177 | if (this.committedDrawable.slice(-1)[0] instanceof ClearDrawable) { 178 | return false; 179 | } 180 | this.committedDrawable.push(new ClearDrawable(this)); 181 | return true; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /pages/canvas/context/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useReducer, 7 | useRef, 8 | useState, 9 | } from 'react'; 10 | import { noop, predicateAccept } from '../utils/noop'; 11 | import { SubscriptionCallback } from '../utils/use-create-subscription'; 12 | import { CanvasFloatingToolbar, CanvasFixedToolbar } from '../toolbar'; 13 | import { CanvasDescriptor } from './descriptor'; 14 | import { 15 | CanvasConfigType, 16 | defaultCanvasConfig, 17 | useInternalCanvasConfig, 18 | } from './config'; 19 | 20 | interface CanvasContextProps { 21 | dimensionLimit?: number; 22 | } 23 | 24 | interface CanvasContextValue { 25 | // 26 | contextProps: CanvasContextProps; 27 | 28 | // 29 | registerCanvas: (id: string) => CanvasDescriptor; 30 | 31 | // canvas config 32 | config: CanvasConfigType; 33 | subscribeConfigChange: ( 34 | cb: SubscriptionCallback, 35 | key?: keyof CanvasConfigType, 36 | ) => void; 37 | 38 | // undo redo clear 39 | undo: () => void; 40 | redo: () => void; 41 | clear: (callbackFn?: CanvasDescriptorPredicate) => void; 42 | unregister: (callbackFn?: CanvasDescriptorPredicate) => void; 43 | 44 | // toolbar ux 45 | getMostRecentCanvas: () => CanvasDescriptor | null; 46 | } 47 | 48 | const CanvasContext = createContext({ 49 | contextProps: {}, 50 | registerCanvas: noop, 51 | config: defaultCanvasConfig, 52 | subscribeConfigChange: noop, 53 | undo: noop, 54 | redo: noop, 55 | clear: noop, 56 | unregister: noop, 57 | getMostRecentCanvas: noop, 58 | }); 59 | 60 | type CanvasProviderState = { 61 | lookup: Map; 62 | _modifiedSequence: string[][]; 63 | modifiedSequence: string[][]; 64 | afterUndoLength?: number | undefined; 65 | }; 66 | 67 | type CanvasDescriptorPredicate = ( 68 | desc: CanvasDescriptor, 69 | index: number, 70 | all: CanvasDescriptor[], 71 | ) => boolean; 72 | 73 | const updateEachTarget = ( 74 | targets: CanvasDescriptor[], 75 | invoke: 'undo' | 'redo' | 'clear', 76 | ) => { 77 | const affected = new Set(); 78 | targets.forEach(target => { 79 | if (target[invoke]()) { 80 | affected.add(target); 81 | } 82 | }); 83 | affected.forEach(i => i.draw()); 84 | return affected; 85 | }; 86 | 87 | export function CanvasProvider( 88 | props: React.PropsWithChildren, 89 | ) { 90 | const { children, ...contextProps } = props; 91 | 92 | const [ref] = useState(() => { 93 | const lookup = new Map(); 94 | const _modifiedSequence: string[][] = []; 95 | const obj: CanvasProviderState = { 96 | lookup, 97 | _modifiedSequence, 98 | modifiedSequence: new Proxy(_modifiedSequence, { 99 | get(t, p, r) { 100 | if (p === 'length' && obj.afterUndoLength != null) { 101 | return obj.afterUndoLength; 102 | } 103 | return Reflect.get(t, p, r); 104 | }, 105 | set(t, p, v, r) { 106 | if (p !== 'length') { 107 | obj.afterUndoLength = undefined; 108 | } 109 | return Reflect.set(t, p, v, r); 110 | }, 111 | }), 112 | }; 113 | return obj; 114 | }); 115 | 116 | const isBatchedUpdateRef = useRef(null); 117 | const onCommittedUpdated = useCallback( 118 | (item: CanvasDescriptor) => { 119 | const isBatched = isBatchedUpdateRef.current; 120 | if (isBatched) { 121 | isBatched.push(item.id); 122 | } else { 123 | ref.modifiedSequence.push([item.id]); 124 | } 125 | }, 126 | [ref], 127 | ); 128 | const beginBatchUpdate = useCallback( 129 | (cb: () => T) => { 130 | if (isBatchedUpdateRef.current) { 131 | return cb(); 132 | } 133 | isBatchedUpdateRef.current = []; 134 | const result = cb(); 135 | if (isBatchedUpdateRef.current.length !== 0) { 136 | ref.modifiedSequence.push(isBatchedUpdateRef.current); 137 | } 138 | isBatchedUpdateRef.current = null; 139 | return result; 140 | }, 141 | [ref], 142 | ); 143 | 144 | const { config, subscribe } = useInternalCanvasConfig(); 145 | 146 | const registerCanvas = useCallback( 147 | (id: string) => { 148 | if (ref.lookup.has(id)) { 149 | return ref.lookup.get(id)!; 150 | } 151 | const item = new CanvasDescriptor(id, config); 152 | item.onCommittedUpdated = onCommittedUpdated; 153 | ref.lookup.set(id, item); 154 | return item; 155 | }, 156 | [ref, config, onCommittedUpdated], 157 | ); 158 | 159 | const undo = useCallback(() => { 160 | const { afterUndoLength, modifiedSequence, lookup } = ref; 161 | const targetLength = (afterUndoLength ?? modifiedSequence.length) - 1; 162 | if (targetLength < 0) { 163 | return false; 164 | } 165 | ref.afterUndoLength = targetLength; 166 | const targets = modifiedSequence[targetLength] 167 | .map(key => lookup.get(key)!) 168 | .filter(Boolean); 169 | return updateEachTarget(targets, 'undo').size !== 0; 170 | }, [ref]); 171 | 172 | const redo = useCallback(() => { 173 | const { afterUndoLength, _modifiedSequence, lookup } = ref; 174 | if ( 175 | afterUndoLength == null || 176 | afterUndoLength >= _modifiedSequence.length 177 | ) { 178 | return false; 179 | } 180 | ref.afterUndoLength = afterUndoLength + 1; 181 | const targets = _modifiedSequence[afterUndoLength] 182 | .map(key => lookup.get(key)!) 183 | .filter(Boolean); 184 | 185 | return updateEachTarget(targets, 'redo').size !== 0; 186 | }, [ref]); 187 | 188 | const clear = useCallback( 189 | (callbackFn?: CanvasDescriptorPredicate) => { 190 | const { lookup } = ref; 191 | const targets = [...lookup.values()].filter( 192 | callbackFn || predicateAccept, 193 | ); 194 | return beginBatchUpdate( 195 | () => updateEachTarget(targets, 'clear').size !== 0, 196 | ); 197 | }, 198 | [ref, beginBatchUpdate], 199 | ); 200 | 201 | const unregister = useCallback( 202 | (callbackFn?: CanvasDescriptorPredicate) => { 203 | const { lookup } = ref; 204 | const targets = [...lookup.values()].filter( 205 | callbackFn || predicateAccept, 206 | ); 207 | targets.forEach(desc => { 208 | let { afterUndoLength } = ref; 209 | let remaining = 0; 210 | const arr = ref._modifiedSequence; 211 | ref._modifiedSequence.forEach((_, index) => { 212 | const next = arr[index].filter(i => i !== desc.id); 213 | if (next.length === 0) { 214 | // reject 215 | if (afterUndoLength && index <= afterUndoLength) { 216 | afterUndoLength--; 217 | } 218 | } else { 219 | // accept 220 | arr[remaining] = next; 221 | remaining++; 222 | } 223 | }); 224 | ref.afterUndoLength = afterUndoLength; 225 | arr.length = remaining; 226 | delete desc.onCommittedUpdated; 227 | lookup.delete(desc.id); 228 | }); 229 | }, 230 | [ref], 231 | ); 232 | 233 | const getMostRecentCanvas = useCallback(() => { 234 | const { lookup, modifiedSequence } = ref; 235 | let target: CanvasDescriptor | null = null; 236 | if (modifiedSequence.length) { 237 | target = lookup.get(modifiedSequence.slice(-1)[0][0]) || null; 238 | } else if (lookup.size) { 239 | target = lookup.values().next().value; 240 | } 241 | return target; 242 | }, [ref]); 243 | 244 | const value: CanvasContextValue = { 245 | contextProps, 246 | config, 247 | subscribeConfigChange: subscribe, 248 | registerCanvas, 249 | undo, 250 | redo, 251 | clear, 252 | unregister, 253 | getMostRecentCanvas, 254 | }; 255 | 256 | return ( 257 | 258 | {children} 259 | 260 | 261 | 262 | ); 263 | } 264 | 265 | export function useCanvasContext() { 266 | return useContext(CanvasContext); 267 | } 268 | 269 | export function useCanvasConfig(key?: keyof CanvasConfigType) { 270 | const context = useContext(CanvasContext); 271 | const [, update] = useReducer(() => ({}), {}); 272 | useEffect(() => context.subscribeConfigChange(update, key), [key, context]); 273 | return context; 274 | } 275 | -------------------------------------------------------------------------------- /pages/canvas/drawable/cache-drawable.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasDescriptor } from '../context/descriptor'; 2 | import { Drawable } from './drawable'; 3 | 4 | const BUFFER_SIZE = 2; 5 | const MIN_THRESHOLD = 2; 6 | 7 | export class CacheDrawable extends Drawable { 8 | desc: CanvasDescriptor; 9 | 10 | constructor(desc: CanvasDescriptor) { 11 | super(desc); 12 | this.desc = desc; 13 | } 14 | 15 | draw() { 16 | const { ctx, desc } = this; 17 | if (!desc.rasterizedLength || !ctx) { 18 | return; 19 | } 20 | const { mainCanvas, cacheCanvas } = desc; 21 | if (!mainCanvas || !cacheCanvas) { 22 | return; 23 | } 24 | ctx.imageSmoothingEnabled = true; 25 | ctx.imageSmoothingQuality = 'high'; 26 | ctx.globalCompositeOperation = 'source-over'; 27 | ctx.drawImage( 28 | cacheCanvas, 29 | 0, 30 | 0, 31 | mainCanvas.clientWidth, 32 | mainCanvas.clientHeight, 33 | ); 34 | } 35 | 36 | update() { 37 | const { desc } = this; 38 | if ( 39 | desc.committedDrawable.length >= 40 | desc.rasterizedLength + BUFFER_SIZE + MIN_THRESHOLD 41 | ) { 42 | const before = desc.rasterizedLength; 43 | const after = desc.committedDrawable.length - BUFFER_SIZE; // after > before 44 | const toRasterize = desc.committedDrawable.slice(before, after); 45 | toRasterize.forEach(path => { 46 | path.draw(); 47 | }); 48 | const canvas = desc.mainCanvas!; 49 | const cacheCanvas = desc.cacheCanvas!; 50 | const cacheContext = cacheCanvas.getContext('2d')!; 51 | cacheContext.imageSmoothingEnabled = true; 52 | cacheContext.imageSmoothingQuality = 'high'; 53 | cacheContext.clearRect(0, 0, cacheCanvas.width, cacheCanvas.height); 54 | cacheContext.drawImage( 55 | canvas, 56 | 0, 57 | 0, 58 | cacheCanvas.width, 59 | cacheCanvas.height, 60 | ); 61 | desc.rasterizedLength = after; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pages/canvas/drawable/chalk-drawable.tsx: -------------------------------------------------------------------------------- 1 | // 算法:https://codepen.io/mmoustafa/pen/gmEdk 2 | // 仅用于演示一种笔迹样式的绘制 3 | 4 | import Color from 'tinycolor2'; 5 | import { CanvasDescriptor } from '../context/descriptor'; 6 | import { RawDrawable, TrackedDrawEvent } from './raw-drawable'; 7 | 8 | export type ChalkDrawableConfig = { 9 | lineWidth: number; 10 | color: string; 11 | }; 12 | 13 | // 随机数生成器 14 | /* eslint-disable no-bitwise, no-multi-assign, no-param-reassign */ 15 | function mulberry32(a: number) { 16 | return function () { 17 | let t = (a += 0x6d2b79f5); 18 | t = Math.imul(t ^ (t >>> 15), t | 1); 19 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 20 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 21 | }; 22 | } 23 | /* eslint-enable */ 24 | 25 | export class ChalkDrawable extends RawDrawable { 26 | config: ChalkDrawableConfig; 27 | 28 | seed: number; 29 | 30 | constructor(desc: CanvasDescriptor, config: ChalkDrawableConfig) { 31 | super(desc); 32 | this.config = { ...config }; 33 | this.seed = Number(`${Math.random()}`.slice(2)); 34 | } 35 | 36 | draw(events?: TrackedDrawEvent[]) { 37 | const { ctx, config } = this; 38 | const { lineWidth, color } = config; 39 | 40 | if (!ctx) { 41 | return; 42 | } 43 | 44 | const { clientWidth, clientHeight, width, height } = ctx.canvas; 45 | const offscreen = new OffscreenCanvas(width, height); 46 | const offscreenCtx = offscreen.getContext('2d')!; 47 | offscreenCtx.scale(width / clientWidth, height / clientHeight); 48 | 49 | const originalColor = Color(color); 50 | 51 | offscreenCtx.fillStyle = originalColor.setAlpha(0.5).toHex8String(); 52 | offscreenCtx.strokeStyle = originalColor.setAlpha(0.5).toHex8String(); 53 | offscreenCtx.lineWidth = lineWidth; 54 | offscreenCtx.lineCap = 'round'; 55 | 56 | let xLast: number | null = null; 57 | let yLast: number | null = null; 58 | 59 | const random = mulberry32(this.seed); 60 | 61 | function drawPoint(x: number, y: number) { 62 | if (xLast == null || yLast == null) { 63 | xLast = x; 64 | yLast = y; 65 | } 66 | offscreenCtx.strokeStyle = originalColor 67 | .setAlpha(0.4 + random() * 0.2) 68 | .toHex8String(); 69 | offscreenCtx.beginPath(); 70 | offscreenCtx.moveTo(xLast, yLast); 71 | offscreenCtx.lineTo(x, y); 72 | offscreenCtx.stroke(); 73 | 74 | const length = Math.round( 75 | Math.sqrt(Math.pow(x - xLast, 2) + Math.pow(y - yLast, 2)) / 76 | (5 / lineWidth), 77 | ); 78 | const xUnit = (x - xLast) / length; 79 | const yUnit = (y - yLast) / length; 80 | for (let i = 0; i < length; i++) { 81 | const xCurrent = xLast + i * xUnit; 82 | const yCurrent = yLast + i * yUnit; 83 | const xRandom = xCurrent + (random() - 0.5) * lineWidth * 1.2; 84 | const yRandom = yCurrent + (random() - 0.5) * lineWidth * 1.2; 85 | offscreenCtx.clearRect( 86 | xRandom, 87 | yRandom, 88 | random() * 2 + 2, 89 | random() + 1, 90 | ); 91 | } 92 | 93 | xLast = x; 94 | yLast = y; 95 | } 96 | 97 | (events ?? this.getEvents()).forEach(e => { 98 | drawPoint(e.left, e.top); 99 | }); 100 | ctx.globalCompositeOperation = 'source-over'; 101 | ctx.drawImage(offscreen, 0, 0, clientWidth, clientHeight); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /pages/canvas/drawable/clear-drawable.tsx: -------------------------------------------------------------------------------- 1 | import { Drawable } from './drawable'; 2 | 3 | export class ClearDrawable extends Drawable { 4 | draw() { 5 | const { ctx } = this; 6 | if (!ctx) { 7 | return; 8 | } 9 | const { canvas } = ctx; 10 | const { clientWidth, clientHeight } = canvas; 11 | ctx.clearRect(0, 0, clientWidth, clientHeight); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pages/canvas/drawable/drawable.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasDescriptor } from '../context/descriptor'; 2 | 3 | /* eslint-disable @typescript-eslint/method-signature-style */ 4 | export interface Drawable { 5 | serialize?(): string; 6 | deserialize?(v: string): void; 7 | } 8 | /* eslint-enable @typescript-eslint/method-signature-style */ 9 | export abstract class Drawable { 10 | #desc: CanvasDescriptor; 11 | 12 | get ctx(): CanvasRenderingContext2D | null { 13 | return this.#desc.mainCanvas?.getContext('2d') || null; 14 | } 15 | 16 | constructor(desc: CanvasDescriptor) { 17 | this.#desc = desc; 18 | } 19 | abstract draw(): void; 20 | } 21 | -------------------------------------------------------------------------------- /pages/canvas/drawable/line-drawable.tsx: -------------------------------------------------------------------------------- 1 | import { PathDrawable } from './path-drawable'; 2 | 3 | export class LineDrawable extends PathDrawable { 4 | draw() { 5 | const events = this.getEvents(); 6 | if (this.getCache()) { 7 | super.draw(); 8 | return; 9 | } 10 | if (events.length < 2) { 11 | return; 12 | } 13 | const [start] = events; 14 | const [end] = events.slice(-1); 15 | super.draw([start, end]); 16 | } 17 | 18 | commit() { 19 | const events = this.getEvents(); 20 | const [start] = events; 21 | const [end] = events.slice(-1); 22 | super.commit([start, end]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/canvas/drawable/path-drawable.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasConfigType } from '../context/config'; 2 | import { CanvasDescriptor } from '../context/descriptor'; 3 | import { RawDrawable, TrackedDrawEvent } from './raw-drawable'; 4 | 5 | export type PathDrawableConfig = Pick< 6 | CanvasConfigType, 7 | 'color' | 'eraserWidth' | 'lineWidth' | 'type' 8 | >; 9 | 10 | export class PathDrawable extends RawDrawable { 11 | config: PathDrawableConfig; 12 | 13 | constructor(desc: CanvasDescriptor, config: PathDrawableConfig) { 14 | super(desc); 15 | this.config = { ...config }; 16 | } 17 | 18 | draw(events?: TrackedDrawEvent[]) { 19 | const { ctx, config } = this; 20 | if (!ctx) { 21 | return; 22 | } 23 | const { lineWidth, eraserWidth, color, type } = config; 24 | ctx.globalCompositeOperation = 25 | type === 'eraser' ? 'destination-out' : 'source-over'; 26 | ctx.lineWidth = type === 'eraser' ? eraserWidth : lineWidth; 27 | ctx.strokeStyle = color; 28 | ctx.beginPath(); 29 | (events ?? this.getEvents()).forEach(e => { 30 | ctx.lineTo(Math.round(e.left), Math.round(e.top)); 31 | }); 32 | ctx.stroke(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pages/canvas/drawable/path-with-stroke-drawable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-statements */ 2 | import { BrushType } from '../context/config'; 3 | import { CanvasDescriptor } from '../context/descriptor'; 4 | import { RawDrawable, TrackedDrawEvent } from './raw-drawable'; 5 | 6 | export type PathDrawableConfig = { 7 | lineWidth: number; 8 | color: string; 9 | type: BrushType; 10 | }; 11 | 12 | const smoothLine = (array: TrackedDrawEvent[], duration: number) => { 13 | if (array.length < 2) { 14 | return array; 15 | } 16 | const [start, ...original] = array; 17 | const smoothed = [start]; 18 | 19 | for ( 20 | let targetTime = Math.min(duration - start.time, 350); 21 | targetTime >= 0; 22 | 23 | ) { 24 | const testee = original[0]; 25 | if (!testee) { 26 | return smoothed; 27 | } 28 | // 如果 testee.time 大于等于 targetTime,直接接受它 29 | // 如果 testee.time + 5 >= targetTime,也接受它 30 | if (duration - testee.time + 5 >= targetTime) { 31 | smoothed.push(testee); 32 | original.shift(); 33 | } 34 | // 不能接受了,在其中插入一个点 35 | else { 36 | const current = smoothed[smoothed.length - 1]; 37 | // 拟合一次函数先,设函数为 y = ax + b,两个点为 (c - 时间, d) , (m - 时间2, n) 38 | const c = duration - current.time; 39 | const m = duration - testee.time; 40 | const result = { time: duration - targetTime } as TrackedDrawEvent; 41 | const solve = (key: Exclude) => { 42 | const d = current[key]; 43 | const n = testee[key]; 44 | const a = (n - d) / (m - c); 45 | const b = n - m * a; 46 | const res = (duration - result.time) * a + b; 47 | result[key] = res; 48 | }; 49 | solve('force'); 50 | solve('left'); 51 | solve('top'); 52 | smoothed.push(result); 53 | } 54 | targetTime = duration - smoothed[smoothed.length - 1].time - 10; 55 | } 56 | 57 | return smoothed; 58 | }; 59 | 60 | export class PathWithStrokeDrawable extends RawDrawable { 61 | config: PathDrawableConfig; 62 | 63 | constructor(desc: CanvasDescriptor, config: PathDrawableConfig) { 64 | super(desc); 65 | this.config = { ...config }; 66 | } 67 | 68 | draw(events?: TrackedDrawEvent[]) { 69 | const division = 350; 70 | const { ctx, config } = this; 71 | 72 | if (!ctx) { 73 | return; 74 | } 75 | 76 | const { clientWidth, clientHeight, width, height } = ctx.canvas; 77 | const offscreen = new OffscreenCanvas(width, height); 78 | const offscreenCtx = offscreen.getContext('2d')!; 79 | offscreenCtx.scale(width / clientWidth, height / clientHeight); 80 | offscreenCtx.lineCap = 'round'; 81 | 82 | const { lineWidth, color, type } = config; 83 | ctx.globalCompositeOperation = 84 | type === 'eraser' ? 'destination-out' : 'source-over'; 85 | 86 | offscreenCtx.lineWidth = lineWidth; 87 | offscreenCtx.strokeStyle = color; 88 | offscreenCtx.beginPath(); 89 | 90 | const points = events ?? this.getEvents(); 91 | 92 | const { duration } = this; 93 | 94 | let i = 0; 95 | 96 | for (i = 0; i < points.length; i++) { 97 | const e = points[i]; 98 | if (duration - e.time < division) { 99 | break; 100 | } 101 | offscreenCtx.lineTo(e.left, e.top); 102 | } 103 | if (i !== 0) { 104 | offscreenCtx.stroke(); 105 | } 106 | 107 | // 平滑剩下的点,stroke 性能很差,所以拆成两部分 108 | 109 | let smooth = points.slice(i ? i - 1 : i); 110 | 111 | if (smooth.length < 20) { 112 | smooth = smoothLine(smooth, duration); 113 | } 114 | offscreenCtx.miterLimit = 1; 115 | smooth.forEach(e => { 116 | if (duration - e.time > division) { 117 | return; 118 | } 119 | offscreenCtx.lineWidth = 120 | lineWidth * 121 | (0.4 + 122 | Math.sin((((duration - e.time) / division) * Math.PI) / 2) * 0.6); 123 | offscreenCtx.lineTo(e.left, e.top); 124 | offscreenCtx.stroke(); 125 | }); 126 | 127 | ctx.drawImage(offscreen, 0, 0, clientWidth, clientHeight); 128 | } 129 | } 130 | /* eslint-enable max-statements */ 131 | -------------------------------------------------------------------------------- /pages/canvas/drawable/raw-drawable.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasDescriptor } from '../context/descriptor'; 2 | import { Drawable } from './drawable'; 3 | 4 | export type TrackedDrawEvent = { 5 | top: number; 6 | left: number; 7 | force: number; 8 | time: number; // high res timer (ms) 9 | }; 10 | 11 | export type RawDrawableSerialized = { 12 | name: string; 13 | value: TrackedDrawEvent[]; 14 | }; 15 | 16 | export class RawDrawable extends Drawable { 17 | static start(desc: CanvasDescriptor, touch: Touch) { 18 | const instance = new RawDrawable(desc); 19 | instance.track(touch); 20 | return instance; 21 | } 22 | 23 | #events: TrackedDrawEvent[] = []; 24 | 25 | #startTime: number | null = null; 26 | 27 | #endTime: number | null = null; 28 | 29 | #cache: TrackedDrawEvent[] | null = null; 30 | 31 | get duration() { 32 | if (this.#startTime == null) { 33 | return 0; 34 | } 35 | if (this.#endTime == null) { 36 | return performance.now() - this.#startTime; 37 | } 38 | return this.#endTime - this.#startTime; 39 | } 40 | 41 | track(touch: Touch) { 42 | if (this.#startTime === null) { 43 | this.#startTime = performance.now(); 44 | } 45 | 46 | const { clientX, clientY, force } = touch; 47 | const { ctx } = this; 48 | if (!ctx) { 49 | return; 50 | } 51 | const { canvas } = ctx; 52 | 53 | const { clientWidth, clientHeight } = canvas; 54 | const { top, left, width, height } = canvas.getBoundingClientRect(); 55 | const scaleX = clientWidth / width; 56 | const scaleY = clientHeight / height; 57 | 58 | this.#events.push({ 59 | left: (clientX - left) * scaleX, 60 | top: (clientY - top) * scaleY, 61 | force, 62 | time: performance.now() - this.#startTime, 63 | }); 64 | } 65 | 66 | commit(next?: TrackedDrawEvent[]) { 67 | this.#cache = next ?? this.#events; 68 | this.#endTime = performance.now(); 69 | } 70 | 71 | draw() { 72 | throw new Error(`I don't know how to draw`); 73 | } 74 | 75 | serialize(): string { 76 | return JSON.stringify({ 77 | name: this.constructor.name, 78 | value: JSON.stringify(this.#cache), 79 | }); 80 | } 81 | 82 | deserialize(v: string) { 83 | const { value } = JSON.parse(v) as RawDrawableSerialized; 84 | this.#cache = value; 85 | } 86 | 87 | getEvents() { 88 | return this.#cache || this.#events; 89 | } 90 | 91 | getRawEvents() { 92 | return this.#events; 93 | } 94 | 95 | getCache() { 96 | return this.#cache; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pages/canvas/index.css: -------------------------------------------------------------------------------- 1 | .canvas-layout-container { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | 6 | .canvas-absolute-wrapper { 7 | touch-action: none; 8 | user-select: none; 9 | position: absolute; 10 | width: 0; 11 | height: 0; 12 | left: 0; 13 | top: 0; 14 | } 15 | 16 | .canvas-inner-wrapper { 17 | position: relative; 18 | } 19 | 20 | .canvas-inner-wrapper.locked { 21 | pointer-events: none; 22 | } 23 | 24 | .canvas-inner-wrapper.hidden { 25 | visibility: hidden; 26 | } 27 | 28 | .cache-canvas, .main-canvas { 29 | position: absolute; 30 | left: 0; 31 | top: 0; 32 | } 33 | 34 | .cache-canvas { 35 | visibility: hidden; 36 | } 37 | 38 | .canvas-child-container { 39 | position: relative; 40 | } 41 | 42 | .canvas-child-container.canvas-state-normal { 43 | pointer-events: none; 44 | } -------------------------------------------------------------------------------- /pages/canvas/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React, { ReactNode, useImperativeHandle, useRef } from 'react'; 3 | import { useCanvasConfig } from './context'; 4 | import { CanvasDescriptor } from './context/descriptor'; 5 | import './index.css'; 6 | import { useCanvasDpr } from './utils/use-canvas-dpr'; 7 | import { useCanvasEvents } from './utils/use-canvas-events'; 8 | import { useDimensionDetector } from './utils/use-dimension-detector'; 9 | import { useRegisterCanvas } from './utils/use-register-canvas'; 10 | 11 | export interface CanvasProps { 12 | children?: ReactNode; 13 | canvasId: string; 14 | containerProps?: React.DetailedHTMLProps< 15 | React.HTMLAttributes, 16 | HTMLDivElement 17 | >; 18 | canvasWrapperProps?: React.DetailedHTMLProps< 19 | React.HTMLAttributes, 20 | HTMLDivElement 21 | >; 22 | childContainerProps?: React.DetailedHTMLProps< 23 | React.HTMLAttributes, 24 | HTMLDivElement 25 | >; 26 | resetOnDestroy?: boolean; 27 | } 28 | 29 | type CanvasRef = { 30 | getCanvasDescriptor: () => CanvasDescriptor; 31 | getCanvasData: () => string; 32 | }; 33 | 34 | export const Canvas = React.forwardRef(function Canvas( 35 | props, 36 | ref, 37 | ) { 38 | const { children, canvasId, resetOnDestroy } = props; 39 | const { containerProps, canvasWrapperProps, childContainerProps } = props; 40 | const { desc, mainRef } = useRegisterCanvas(canvasId, resetOnDestroy); 41 | 42 | const containerRef = useRef(null!); 43 | const { dimension } = useDimensionDetector(containerRef); 44 | const { config, contextProps } = useCanvasConfig('canvasState'); 45 | const { width, height } = useCanvasDpr({ 46 | dimension, 47 | desc, 48 | dimensionLimit: contextProps.dimensionLimit, 49 | }); 50 | 51 | useCanvasEvents(desc); 52 | const { canvasState } = config; 53 | 54 | useImperativeHandle( 55 | ref, 56 | () => ({ 57 | getCanvasDescriptor() { 58 | return desc; 59 | }, 60 | getCanvasData() { 61 | return desc.mainCanvas?.toDataURL() || 'data:image/png;base64,'; 62 | }, 63 | }), 64 | [desc], 65 | ); 66 | 67 | return ( 68 |
72 |
78 |
79 | 89 |
90 |
91 |
98 | {children} 99 |
100 |
101 | ); 102 | }); 103 | 104 | export const useCanvasRef = () => { 105 | const ref = useRef(null); 106 | return ref; 107 | }; 108 | -------------------------------------------------------------------------------- /pages/canvas/toolbar/color-size-popover/index.tsx: -------------------------------------------------------------------------------- 1 | import { CirclePicker, HuePicker } from 'react-color'; 2 | import Tippy from '@tippyjs/react'; 3 | import React, { useRef } from 'react'; 4 | import clsx from 'clsx'; 5 | import { useCanvasConfig } from '../../context'; 6 | 7 | import s from '../index.module.css'; 8 | import { BrushType } from '../../context/config'; 9 | 10 | type Instance = Parameters< 11 | NonNullable['onCreate']> 12 | >[0]; 13 | 14 | interface ColorSizePopoverProps { 15 | type: BrushType; 16 | text: string; 17 | } 18 | 19 | export function ColorSizePopover(props: ColorSizePopoverProps) { 20 | const { type, text } = props; 21 | const { config } = useCanvasConfig(); 22 | const instanceRef = useRef(null); 23 | const updateColor = (next: string) => { 24 | config.color = next; 25 | }; 26 | return ( 27 | (instanceRef.current = instance)} 31 | onDestroy={() => (instanceRef.current = null)} 32 | onClickOutside={instance => instance.hide()} 33 | content={ 34 |
instanceRef.current?.hide()}> 37 |
38 | updateColor(e.hex)} 41 | onChangeComplete={(c: any) => updateColor(c.hex)} 42 | /> 43 |
44 |
45 | updateColor(e.hex)} 49 | onChangeComplete={(c: any) => updateColor(c.hex)} 50 | /> 51 |
52 |
53 | (config.lineWidth = Number(e.target.value))} 59 | /> 60 | {config.lineWidth} 61 |
62 |
63 | }> 64 |
(config.type = type)} 66 | className={clsx(s['toolbar-item'], config.type === type && s.active)}> 67 | {text} 68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /pages/canvas/toolbar/eraser-popover/index.tsx: -------------------------------------------------------------------------------- 1 | import Tippy from '@tippyjs/react'; 2 | import React, { useRef } from 'react'; 3 | import clsx from 'clsx'; 4 | import { useCanvasConfig } from '../../context'; 5 | 6 | import s from '../index.module.css'; 7 | 8 | type Instance = Parameters< 9 | NonNullable['onCreate']> 10 | >[0]; 11 | 12 | export function EraserPopover() { 13 | const { config, clear } = useCanvasConfig(); 14 | const instanceRef = useRef(null); 15 | return ( 16 | (instanceRef.current = instance)} 20 | onDestroy={() => (instanceRef.current = null)} 21 | onClickOutside={instance => instance.hide()} 22 | content={ 23 |
instanceRef.current?.hide()}> 26 |
clear()} className={s['item-wrapper']}> 27 | 清空画布 28 |
29 |
30 | (config.eraserWidth = Number(e.target.value))} 36 | /> 37 | {config.eraserWidth} 38 |
39 |
40 | }> 41 |
(config.type = 'eraser')} 43 | className={clsx( 44 | s['toolbar-item'], 45 | config.type === 'eraser' && s.active, 46 | )}> 47 | 橡皮 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /pages/canvas/toolbar/index.module.css: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | box-shadow: 0 6px 16px -8px rgb(0 0 0 / 8%), 0 9px 28px 0 rgb(0 0 0 / 5%), 0 12px 48px 16px rgb(0 0 0 / 3%); 3 | display: inline-flex; 4 | align-items: center; 5 | padding: 12px 16px; 6 | background-color: rgba(255, 255, 255, 0.6); 7 | } 8 | 9 | .toolbar.floating-toolbar { 10 | position: absolute; 11 | left: 0; 12 | top: 100px; 13 | } 14 | 15 | .toolbar-draggable-icon { 16 | line-height: 0; 17 | margin-left: -8px; 18 | margin-right: 8px; 19 | cursor: move; 20 | color: #666; 21 | } 22 | 23 | .toolbar-non-draggable { 24 | display: flex; 25 | } 26 | 27 | .toolbar-non-draggable .toolbar-item { 28 | position: relative; 29 | display: inline-flex; 30 | height: 48px; 31 | width: 48px; 32 | justify-content: center; 33 | align-items: center; 34 | } 35 | 36 | .toolbar-non-draggable .toolbar-item.active::after { 37 | content: ''; 38 | position: absolute; 39 | left: 0; 40 | right: 0; 41 | bottom: 0; 42 | height: 4px; 43 | background-color: aquamarine; 44 | } 45 | 46 | .popover-content { 47 | box-shadow: rgb(0 0 0 / 12%) 0px 2px 10px, rgb(0 0 0 / 16%) 0px 2px 5px; 48 | padding: 16px; 49 | background-color: white; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | 54 | .item-wrapper { 55 | display: flex; 56 | } 57 | 58 | .item-wrapper:not(:last-child) { 59 | margin-bottom: 16px; 60 | } 61 | -------------------------------------------------------------------------------- /pages/canvas/toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import Draggable from 'react-draggable'; 2 | import clsx from 'clsx'; 3 | import { useCanvasConfig } from '../context'; 4 | import { ColorSizePopover } from './color-size-popover'; 5 | import { EraserPopover } from './eraser-popover'; 6 | import s from './index.module.css'; 7 | 8 | export function CanvasFloatingToolbar() { 9 | const { undo, redo, config, getMostRecentCanvas } = 10 | useCanvasConfig('canvasState'); 11 | if (config.canvasState !== 'normal') { 12 | return null; 13 | } 14 | const defaultPosition = (() => { 15 | const target = getMostRecentCanvas(); 16 | if (!target) { 17 | return { x: 0, y: 0 }; 18 | } 19 | const { x, y } = target.mainCanvas?.getBoundingClientRect() || {}; 20 | return { x: x || 0, y: y || 0 }; 21 | })(); 22 | return ( 23 | 26 |
27 | 拖动 28 |
29 | 30 | 31 | 32 | 33 |
undo()} className={s['toolbar-item']}> 34 | 撤销 35 |
36 |
redo()} className={s['toolbar-item']}> 37 | 重做 38 |
39 |
(config.canvasState = 'locked')} 41 | className={s['toolbar-item']}> 42 | 完成 43 |
44 |
45 |
46 |
47 | ); 48 | } 49 | 50 | export function CanvasFixedToolbar() { 51 | const { config } = useCanvasConfig('canvasState'); 52 | return ( 53 |
54 |
55 |
(config.canvasState = 'normal')} 57 | className={s['toolbar-item']}> 58 | 开始 59 |
60 | 绘制 61 |
62 | {config.canvasState === 'hidden' ? ( 63 |
(config.canvasState = 'locked')} 65 | className={s['toolbar-item']}> 66 | 显示 67 |
68 | ) : ( 69 |
(config.canvasState = 'hidden')} 71 | className={s['toolbar-item']}> 72 | 隐藏 73 |
74 | )} 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /pages/canvas/utils/mouse-to-touch.tsx: -------------------------------------------------------------------------------- 1 | export function mouseToTouchInit(e: MouseEvent): TouchInit { 2 | const { clientX, clientY, pageX, pageY, target, screenX, screenY } = e; 3 | return { 4 | clientX, 5 | clientY, 6 | pageX, 7 | pageY, 8 | force: 1, 9 | radiusX: 0, 10 | radiusY: 0, 11 | identifier: Infinity, 12 | target: target!, 13 | rotationAngle: 0, 14 | screenX, 15 | screenY, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /pages/canvas/utils/noop.tsx: -------------------------------------------------------------------------------- 1 | export const noop = () => null!; 2 | 3 | export const predicateAccept = () => true; 4 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-canvas-dpr.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { CanvasDescriptor } from '../context/descriptor'; 3 | import { CanvasDimension } from './use-dimension-detector'; 4 | import { useDevicePixelRatio } from './use-dpr'; 5 | 6 | type CanvasDprConfig = { 7 | dimension: CanvasDimension; 8 | desc: CanvasDescriptor; 9 | // width * height 10 | dimensionLimit?: number; 11 | }; 12 | 13 | export function useCanvasDpr(config: CanvasDprConfig) { 14 | const { dimension, desc, dimensionLimit } = config; 15 | const { dpr } = useDevicePixelRatio(); 16 | let dprw = dpr; 17 | let dprh = dpr; 18 | if ( 19 | dimensionLimit && 20 | dpr * dpr * dimension.width * dimension.height > dimensionLimit 21 | ) { 22 | const ratio = dimension.width / dimension.height; 23 | const height = Math.floor(Math.sqrt(dimensionLimit / ratio)); 24 | const width = Math.floor(height * ratio); 25 | dprw = width / dimension.width; 26 | dprh = height / dimension.height; 27 | } 28 | 29 | useEffect(() => { 30 | [desc.mainCanvas].forEach(canvas => { 31 | if (!canvas) { 32 | return; 33 | } 34 | const ctx = canvas.getContext('2d')!; 35 | ctx.setTransform(1, 0, 0, 1, 0, 0); // scale 前先恢复 36 | ctx.scale(dprw, dprh); 37 | }); 38 | desc.rasterizedLength = 0; 39 | desc.draw?.(); 40 | }, [dprw, dprh, dimension, desc]); 41 | return { 42 | width: Math.round(dimension.width * dprw), 43 | height: Math.round(dimension.height * dprh), 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-canvas-events.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { CanvasDescriptor } from '../context/descriptor'; 3 | import { ChalkDrawable } from '../drawable/chalk-drawable'; 4 | import { PathDrawable } from '../drawable/path-drawable'; 5 | import { PathWithStrokeDrawable } from '../drawable/path-with-stroke-drawable'; 6 | import { mouseToTouchInit } from './mouse-to-touch'; 7 | 8 | type TouchEventHandler = (e: TouchEvent) => void; 9 | type TouchAdapter = { 10 | touchStart: TouchEventHandler; 11 | touchMove: TouchEventHandler; 12 | touchEnd: TouchEventHandler; 13 | touchCancel: TouchEventHandler; 14 | }; 15 | 16 | function mouseToTouchAdapter(getEventHandler: () => TouchAdapter) { 17 | return (e: PointerEvent) => { 18 | const isTouch = e.pointerType === 'touch'; 19 | if (isTouch) { 20 | // 触控事件由 touch 系列事件解决 21 | return; 22 | } 23 | const { touchStart, touchMove, touchEnd, touchCancel } = getEventHandler(); 24 | const touchInit = mouseToTouchInit(e); 25 | const init: TouchEventInit = { 26 | touches: [new Touch(touchInit)], 27 | changedTouches: [new Touch(touchInit)], 28 | }; 29 | 30 | const typeMap = { 31 | pointerdown: ['touchstart', touchStart], 32 | pointermove: ['touchmove', touchMove], 33 | pointerup: ['touchend', touchEnd], 34 | pointercancel: ['touchcancel', touchCancel], 35 | } as const; 36 | const { type } = e; 37 | if (!(type in typeMap)) { 38 | return; 39 | } 40 | const next = typeMap[type as keyof typeof typeMap]; 41 | const [newEvent, eventHandler] = next; 42 | const touchEvent = new TouchEvent(newEvent, init); 43 | Reflect.set(touchEvent, 'isMouse', !isTouch); 44 | eventHandler(touchEvent); 45 | }; 46 | } 47 | 48 | export function useCanvasEvents(desc: CanvasDescriptor) { 49 | useEffect(() => { 50 | const canvas = desc.mainCanvas!; 51 | const { acceptedTouches } = desc; 52 | 53 | let adapter: TouchAdapter; 54 | const mouseEventHandler = mouseToTouchAdapter(() => adapter); 55 | 56 | const touchStart = (event: TouchEvent & { isMouse?: boolean }) => { 57 | const { config } = desc; 58 | const touches = event.changedTouches; 59 | // eslint-disable-next-line @typescript-eslint/prefer-for-of 60 | for (let i = 0; i < touches.length; i++) { 61 | const touch = touches[i]; 62 | const accepted = 63 | // eslint-disable-next-line no-nested-ternary 64 | config.type === 'chalk' 65 | ? new ChalkDrawable(desc, config) 66 | : config.type === 'stroke' 67 | ? new PathWithStrokeDrawable(desc, config) 68 | : new PathDrawable(desc, config); 69 | acceptedTouches.set(touch.identifier ?? Infinity, accepted); 70 | accepted.track(touch); 71 | desc.draw?.(); 72 | } 73 | if (event.isMouse) { 74 | document.addEventListener('pointermove', mouseEventHandler); 75 | document.addEventListener('pointerup', mouseEventHandler); 76 | } else if (acceptedTouches.size === 1) { 77 | document.addEventListener('touchmove', touchMove); 78 | document.addEventListener('touchend', touchEnd); 79 | document.addEventListener('touchleave', touchCancel); 80 | document.addEventListener('touchcancel', touchCancel); 81 | } 82 | }; 83 | 84 | const removeListenerIfClear = () => { 85 | if (acceptedTouches.size === 0) { 86 | document.removeEventListener('pointermove', mouseEventHandler); 87 | document.removeEventListener('pointerup', mouseEventHandler); 88 | 89 | document.removeEventListener('touchmove', touchMove); 90 | document.removeEventListener('touchend', touchEnd); 91 | document.removeEventListener('touchleave', touchCancel); 92 | document.removeEventListener('touchcancel', touchCancel); 93 | } 94 | }; 95 | 96 | const touchMove = (event: TouchEvent) => { 97 | const touches = event.changedTouches; 98 | 99 | // eslint-disable-next-line @typescript-eslint/prefer-for-of 100 | for (let i = 0; i < touches.length; i++) { 101 | const touch = touches[i]; 102 | const id = touch.identifier ?? Infinity; 103 | const accepted = acceptedTouches.get(id); 104 | 105 | if (accepted) { 106 | accepted.track(touch); 107 | } else { 108 | // not accepted 109 | } 110 | } 111 | desc.draw?.(); 112 | }; 113 | 114 | const touchEnd = (event: TouchEvent) => { 115 | const touches = event.changedTouches; 116 | 117 | // eslint-disable-next-line @typescript-eslint/prefer-for-of 118 | for (let i = 0; i < touches.length; i++) { 119 | const touch = touches[i]; 120 | const id = touch.identifier ?? Infinity; 121 | const accepted = acceptedTouches.get(id); 122 | 123 | if (accepted) { 124 | accepted.commit(); 125 | desc.committedDrawable.push(accepted); 126 | acceptedTouches.delete(id); 127 | } else { 128 | // not accepted 129 | } 130 | } 131 | desc.draw?.(); 132 | removeListenerIfClear(); 133 | }; 134 | 135 | const touchCancel = (event: Event) => { 136 | const touches = (event as TouchEvent).changedTouches ?? []; 137 | 138 | // eslint-disable-next-line @typescript-eslint/prefer-for-of 139 | for (let i = 0; i < touches.length; i++) { 140 | const touch = touches[i]; 141 | const id = touch.identifier ?? Infinity; 142 | const accepted = acceptedTouches.get(id); 143 | 144 | if (accepted) { 145 | acceptedTouches.delete(id); 146 | } else { 147 | // not accepted 148 | } 149 | } 150 | removeListenerIfClear(); 151 | }; 152 | 153 | adapter = { touchStart, touchMove, touchEnd, touchCancel }; 154 | 155 | const mouseMove = (e: MouseEvent) => { 156 | desc.mouseOverEvent = e; 157 | desc.draw?.(); 158 | }; 159 | const mouseOut = () => { 160 | desc.mouseOverEvent = undefined; 161 | desc.draw?.(); 162 | }; 163 | 164 | canvas.addEventListener('touchstart', touchStart); 165 | canvas.addEventListener('pointermove', mouseMove); 166 | canvas.addEventListener('pointerout', mouseOut); 167 | canvas.addEventListener('pointerdown', mouseEventHandler); 168 | desc.draw?.(); 169 | return () => { 170 | canvas.removeEventListener('touchstart', touchStart); 171 | canvas.removeEventListener('pointermove', mouseMove); 172 | canvas.removeEventListener('pointerout', mouseOut); 173 | canvas.removeEventListener('pointerdown', mouseEventHandler); 174 | }; 175 | }, [desc]); 176 | } 177 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-create-subscription.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useCallback } from 'react'; 2 | 3 | export type SubscriptionCallback> = ( 4 | prev: T, 5 | next: T, 6 | ) => void; 7 | 8 | export function useCreateSubscription>( 9 | target: T, 10 | ) { 11 | type Callback = SubscriptionCallback; 12 | const { subscription, proxied } = useMemo( 13 | () => ({ 14 | subscription: new Set(), 15 | proxied: new Proxy(target, { 16 | set(t, p, v, r) { 17 | const prev = { ...t }; 18 | const result = Reflect.set(t, p, v, r); 19 | subscription.forEach(item => item(prev, t)); 20 | return result; 21 | }, 22 | }), 23 | }), 24 | [target], 25 | ); 26 | const subscribe = useCallback( 27 | (func: Callback, key?: keyof T) => { 28 | const cb: Callback = (prev, next) => { 29 | if (key == null || prev[key] !== next[key]) { 30 | func(prev, next); 31 | } 32 | }; 33 | subscription.add(cb); 34 | return () => subscription.delete(cb); 35 | }, 36 | [subscription], 37 | ); 38 | 39 | return { proxied, subscribe }; 40 | } 41 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-dimension-detector.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MutableRefObject } from 'react'; 2 | import { addListener, removeListener } from 'resize-detector'; 3 | import { debounce } from 'lodash-es'; 4 | 5 | export type CanvasDimension = { 6 | width: number; 7 | height: number; 8 | }; 9 | 10 | export function useDimensionDetector(ref: MutableRefObject) { 11 | const [dimension, setDimension] = useState({ 12 | width: 1, 13 | height: 1, 14 | }); 15 | useEffect(() => { 16 | const { current } = ref; 17 | const updateDimension = debounce(() => { 18 | setDimension({ 19 | width: current.clientWidth, 20 | height: current.clientHeight, 21 | }); 22 | }, 100); 23 | updateDimension(); 24 | addListener(current, updateDimension); 25 | return () => { 26 | updateDimension.cancel(); 27 | removeListener(current, updateDimension); 28 | }; 29 | }, [ref]); 30 | 31 | return { dimension }; 32 | } 33 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-dpr.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useDevicePixelRatio() { 4 | const [dpr, setDpr] = useState(window.devicePixelRatio); 5 | useEffect(() => { 6 | const list = matchMedia(`(resolution: ${dpr}dppx)`); 7 | const update = () => setDpr(window.devicePixelRatio); 8 | list.addEventListener('change', update); 9 | return () => { 10 | list.removeEventListener('change', update); 11 | }; 12 | }, [dpr]); 13 | return { dpr }; 14 | } 15 | -------------------------------------------------------------------------------- /pages/canvas/utils/use-register-canvas.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'react'; 2 | import { useCanvasContext } from '../context'; 3 | import { CanvasDescriptor } from '../context/descriptor'; 4 | 5 | export function useRegisterCanvas(id: string, resetOnDestroy?: boolean) { 6 | const { registerCanvas, unregister } = useCanvasContext(); 7 | const desc = useMemo( 8 | () => registerCanvas(id) ?? new CanvasDescriptor(id), 9 | // eslint-disable-next-line react-hooks/exhaustive-deps 10 | [registerCanvas, unregister, id, resetOnDestroy], 11 | ); 12 | useEffect( 13 | () => () => { 14 | if (resetOnDestroy) { 15 | unregister(item => item.id === id); 16 | } 17 | }, 18 | [registerCanvas, unregister, id, resetOnDestroy], 19 | ); 20 | // bind this 21 | const mainRef = useCallback( 22 | (next: HTMLCanvasElement | null) => { 23 | desc.mainCanvas = next; 24 | }, 25 | [desc], 26 | ); 27 | return { desc, mainRef }; 28 | } 29 | -------------------------------------------------------------------------------- /pages/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://unpkg.byted-static.com/arco-design/web-react/2.15.0/dist/asset/style/fonts/nunito_for_arco-regular-webfont.woff"); 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: nunito_for_arco, Helvetica Neue, Helvetica, PingFang SC, 8 | Hiragino Sans GB, Microsoft YaHei, 微软雅黑, Arial, sans-serif; 9 | } 10 | 11 | * { 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | box-sizing: border-box; 15 | } 16 | 17 | .container { 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | 25 | main { 26 | padding: 5rem 0; 27 | flex: 1; 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | 34 | .footer { 35 | width: 100%; 36 | height: 80px; 37 | border-top: 1px solid #eaeaea; 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | background-color: #470000; 42 | } 43 | 44 | .footer a { 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | flex-grow: 1; 49 | color: #f4f4f4; 50 | text-decoration: none; 51 | font-size: 1.1rem; 52 | } 53 | 54 | .logo { 55 | margin-bottom: 2rem; 56 | } 57 | 58 | .logo svg { 59 | width: 450px; 60 | height: 132px; 61 | } 62 | 63 | .description { 64 | text-align: center; 65 | line-height: 1.5; 66 | font-size: 1.5rem; 67 | } 68 | 69 | .code { 70 | background: #fafafa; 71 | border-radius: 5px; 72 | padding: 0.75rem; 73 | font-size: 1.1rem; 74 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 75 | Bitstream Vera Sans Mono, Courier New, monospace; 76 | } 77 | 78 | @media (max-width: 600px) { 79 | .grid { 80 | width: 100%; 81 | flex-direction: column; 82 | } 83 | } 84 | 85 | .grid { 86 | display: flex; 87 | align-items: center; 88 | justify-content: center; 89 | flex-wrap: wrap; 90 | width: 800px; 91 | margin-top: 3rem; 92 | } 93 | 94 | .card { 95 | margin: 1rem; 96 | padding: 1.5rem; 97 | display: flex; 98 | align-items: center; 99 | justify-content: center; 100 | height: 100px; 101 | color: inherit; 102 | text-decoration: none; 103 | border: 1px solid #470000; 104 | color: #470000; 105 | transition: color 0.15s ease, border-color 0.15s ease; 106 | width: 45%; 107 | } 108 | 109 | .card:hover, 110 | .card:focus, 111 | .card:active { 112 | transform: scale(1.05); 113 | transition: 0.1s ease-in-out; 114 | } 115 | 116 | .card h2 { 117 | font-size: 1.5rem; 118 | margin: 0; 119 | padding: 0; 120 | } 121 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import { Canvas } from './canvas'; 3 | import { CanvasProvider } from './canvas/context'; 4 | 5 | const Index: React.FC = () => ( 6 | 7 |
8 | 12 |
13 |
14 | ); 15 | 16 | export default Index; 17 | --------------------------------------------------------------------------------