pixel-data-js 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixel-data-js",
3
3
  "type": "module",
4
- "version": "0.30.0",
4
+ "version": "0.32.0",
5
5
  "packageManager": "pnpm@10.33.0",
6
6
  "description": "JS Pixel and ImageData operations",
7
7
  "author": {
@@ -0,0 +1,76 @@
1
+ export type BatchedQueueFn = (fn: () => void) => void
2
+
3
+ export type BatchedQueue = ReturnType<typeof makeBatchedQueue>
4
+
5
+ /**
6
+ * Creates a high-performance, zero-allocation batching queue.
7
+ * This utility collects items marked as "dirty" and flushes them in a single batch.
8
+ * * **⚠️ CRITICAL: Synchronous Processing Required**
9
+ * Because the internal sets are reused, the `Set` passed to the `processor` is instantly
10
+ * cleared the moment the processor function returns. If you need to process the items
11
+ * asynchronously, you **must** manually clone the set inside your processor.
12
+ * @template T - The type of items being batched.
13
+ * @param processor - The callback executed when the batch flushes. Receives a `Set` of all batched items.
14
+ * @param queue - The scheduling function used to defer the flush. Defaults to `queueMicrotask`.
15
+ * @returns An object containing methods to mark items as dirty.
16
+ * @example
17
+ * * @example
18
+ * ```ts
19
+ * import { nextTick } from 'vue'
20
+ * let bq = makeBatchedQueue<string>(
21
+ * (items) => drawSomething(items),
22
+ * nextTick,
23
+ * )
24
+ * ```
25
+ */
26
+ export function makeBatchedQueue<T>(
27
+ processor: (items: Set<T>) => void,
28
+ queue: BatchedQueueFn,
29
+ ) {
30
+ let activeSet = new Set<T>()
31
+ let processingSet = new Set<T>()
32
+ let scheduled = false
33
+
34
+ const flush = () => {
35
+ // swap sets
36
+ const current = activeSet
37
+ activeSet = processingSet
38
+ processingSet = current
39
+
40
+ scheduled = false
41
+
42
+ try {
43
+ processor(processingSet)
44
+ } finally {
45
+ processingSet.clear()
46
+ }
47
+ }
48
+
49
+ function markDirty(item: T) {
50
+ activeSet.add(item)
51
+
52
+ if (!scheduled) {
53
+ scheduled = true
54
+ queue(flush)
55
+ }
56
+ }
57
+
58
+ function markMultipleDirty(items: T[]) {
59
+ let len = items.length
60
+ if (len === 0) return
61
+
62
+ for (let i = 0; i < len; i++) {
63
+ activeSet.add(items[i])
64
+ }
65
+
66
+ if (!scheduled) {
67
+ scheduled = true
68
+ queue(flush)
69
+ }
70
+ }
71
+
72
+ return {
73
+ markDirty,
74
+ markMultipleDirty,
75
+ }
76
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Creates a debounced render queue using `requestAnimationFrame`.
3
+ * This utility ensures that a callback is executed exactly once right before
4
+ * the next visual frame, regardless of how many times the trigger is called
5
+ * synchronously. It safely prevents layout thrashing and redundant computations.
6
+ * @param cb - The function to execute on the next animation frame.
7
+ * @returns A trigger function that schedules the callback. It includes a `.cancel()` method to abort the pending frame.
8
+ * * @example
9
+ * ```ts
10
+ * let renderQueue = makeRenderQueue(() => {
11
+ * console.log('DOM updated!')
12
+ * })
13
+ * * // Calling this multiple times synchronously...
14
+ * renderQueue()
15
+ * renderQueue()
16
+ * renderQueue()
17
+ * * // ...will only result in one 'DOM updated!' log on the next frame.
18
+ * ```
19
+ * * @example
20
+ * ```ts
21
+ * // Canceling a scheduled render (e.g., when a component unmounts)
22
+ * let trigger = makeRenderQueue(updateLayout)
23
+ * trigger()
24
+ * trigger.cancel() // The callback will not execute
25
+ * ```
26
+ */
27
+ export function makeRenderQueue(cb: () => void) {
28
+ let needsRender = false
29
+ let frameId = 0
30
+
31
+ const trigger = () => {
32
+ if (needsRender) return
33
+ needsRender = true
34
+
35
+ frameId = requestAnimationFrame(() => {
36
+ needsRender = false
37
+ cb()
38
+ })
39
+ }
40
+
41
+ trigger.cancel = () => {
42
+ if (needsRender) {
43
+ cancelAnimationFrame(frameId)
44
+ needsRender = false
45
+ }
46
+ }
47
+
48
+ return trigger
49
+ }
@@ -8,6 +8,7 @@ import { type HistoryActionFactory, makeHistoryAction } from './HistoryAction'
8
8
  import { HistoryManager } from './HistoryManager'
9
9
  import { PixelAccumulator } from './PixelAccumulator'
10
10
  import { PixelEngineConfig } from './PixelEngineConfig'
11
+ import type { PixelPatchTiles } from './PixelPatchTiles'
11
12
 
12
13
  export interface PixelWriterOptions {
13
14
  maxHistorySteps?: number
@@ -72,15 +73,13 @@ export class PixelWriter<M> {
72
73
  * throw immediately to prevent silent data loss from a nested extractPatch.
73
74
  *
74
75
  * @param transaction Callback to be executed inside the transaction.
75
- * @param after Called after both undo and redo — use for generic change notifications.
76
- * @param afterUndo Called after undo only — use for dimension or state changes specific to undo.
76
+ * @param afterUndo Called after undo only.
77
77
  * @param afterRedo Called after redo only.
78
78
  */
79
79
  withHistory(
80
80
  transaction: (mutator: M) => void,
81
- after?: () => void,
82
- afterUndo?: () => void,
83
- afterRedo?: () => void,
81
+ afterUndo?: (patch: PixelPatchTiles) => void,
82
+ afterRedo?: (patch: PixelPatchTiles) => void,
84
83
  ): void {
85
84
  if (this._inProgress) {
86
85
  throw new Error('withHistory is not re-entrant — commit or rollback the current operation first')
@@ -100,7 +99,7 @@ export class PixelWriter<M> {
100
99
  if (this.accumulator.beforeTiles.length === 0) return
101
100
 
102
101
  const patch = this.accumulator.extractPatch()
103
- const action = this.historyActionFactory(this.config, this.accumulator, patch, after, afterUndo, afterRedo)
102
+ const action = this.historyActionFactory(this.config, this.accumulator, patch, afterUndo, afterRedo)
104
103
 
105
104
  this.historyManager.commit(action)
106
105
  }
@@ -110,7 +109,6 @@ export class PixelWriter<M> {
110
109
  newHeight: number,
111
110
  offsetX = 0,
112
111
  offsetY = 0,
113
- after?: (target: ImageData) => void,
114
112
  afterUndo?: (target: ImageData) => void,
115
113
  afterRedo?: (target: ImageData) => void,
116
114
  resizeImageDataFn = resizeImageData,
@@ -134,12 +132,10 @@ export class PixelWriter<M> {
134
132
  undo: () => {
135
133
  setPixelData(target, beforeImageData)
136
134
  afterUndo?.(beforeImageData)
137
- after?.(beforeImageData)
138
135
  },
139
136
  redo: () => {
140
137
  setPixelData(target, afterImageData)
141
138
  afterRedo?.(afterImageData)
142
- after?.(afterImageData)
143
139
  },
144
140
  })
145
141
  }
@@ -0,0 +1,55 @@
1
+ import type { Rect } from '../Rect/_rect-types'
2
+ import type { ImageDataLike } from './_ImageData-types'
3
+ import { extractImageDataBuffer } from './extractImageDataBuffer'
4
+
5
+ /**
6
+ * Extracts a specific rectangular region of pixels from a larger {@link ImageDataLike}
7
+ * source into a new {@link Uint8ClampedArray}.
8
+ *
9
+ * This is a "read-only" operation that returns a copy of the pixel data.
10
+ *
11
+ * @param imageData - The source image data to read from.
12
+ * @param rect - A rect defining the region to extract.
13
+ * @returns A buffer containing the RGBA pixel data of the region.
14
+ */
15
+ export function extractImageData(
16
+ imageData: ImageDataLike,
17
+ rect: Rect,
18
+ ): ImageData | null
19
+ /**
20
+ * @param imageData - The source image data to read from.
21
+ * @param x - The starting horizontal coordinate.
22
+ * @param y - The starting vertical coordinate.
23
+ * @param w - The width of the region to extract.
24
+ * @param h - The height of the region to extract.
25
+ * @returns A buffer containing the RGBA pixel data of the region.
26
+ */
27
+ export function extractImageData(
28
+ imageData: ImageDataLike,
29
+ x: number,
30
+ y: number,
31
+ w: number,
32
+ h: number,
33
+ ): ImageData | null
34
+ export function extractImageData(
35
+ imageData: ImageDataLike,
36
+ _x: Rect | number,
37
+ _y?: number,
38
+ _w?: number,
39
+ _h?: number,
40
+ ): ImageData | null {
41
+ const { x, y, w, h } = typeof _x === 'object'
42
+ ? _x
43
+ : { x: _x, y: _y!, w: _w!, h: _h! }
44
+
45
+ if (w <= 0) return null
46
+ if (h <= 0) return null
47
+
48
+ const result = new ImageData(w, h)
49
+
50
+ const buffer = extractImageDataBuffer(imageData, x, y, w, h)
51
+ result.data.set(buffer)
52
+
53
+ return result
54
+
55
+ }
@@ -1,9 +1,6 @@
1
1
  import type { Rect } from '../Rect/_rect-types'
2
- import { makeClippedBlit, resolveBlitClipping } from '../Rect/resolveClipping'
3
2
  import type { ImageDataLike } from './_ImageData-types'
4
3
 
5
- const SCRATCH_BLIT = makeClippedBlit()
6
-
7
4
  /**
8
5
  * Extracts a specific rectangular region of pixels from a larger {@link ImageDataLike}
9
6
  * source into a new {@link Uint8ClampedArray}.
@@ -43,37 +40,69 @@ export function extractImageDataBuffer(
43
40
  const { x, y, w, h } = typeof _x === 'object'
44
41
  ? _x
45
42
  : { x: _x, y: _y!, w: _w!, h: _h! }
43
+ if (w <= 0) return new Uint8ClampedArray(0)
44
+ if (h <= 0) return new Uint8ClampedArray(0)
45
+
46
+ const srcW = imageData.width
47
+ const srcH = imageData.height
48
+ const src = imageData.data
49
+
50
+ const outLen = w * h * 4
51
+ const out = new Uint8ClampedArray(outLen)
52
+
53
+ let srcX = x
54
+ let srcY = y
55
+ let dstX = 0
56
+ let dstY = 0
57
+ let copyW = w
58
+ let copyH = h
59
+
60
+ if (srcX < 0) {
61
+ dstX = -srcX
62
+ copyW += srcX
63
+ srcX = 0
64
+ }
65
+
66
+ if (srcY < 0) {
67
+ dstY = -srcY
68
+ copyH += srcY
69
+ srcY = 0
70
+ }
71
+
72
+ copyW = Math.min(copyW, srcW - srcX)
73
+ copyH = Math.min(copyH, srcH - srcY)
74
+
75
+ if (copyW <= 0) return out
76
+ if (copyH <= 0) return out
46
77
 
47
- const { width: srcW, height: srcH, data: src } = imageData
48
- // Safety check for invalid dimensions
49
- if (w <= 0 || h <= 0) return new Uint8ClampedArray(0)
50
- const out = new Uint8ClampedArray(w * h * 4)
78
+ // 2. Perform high-speed block copy
79
+ // Attempt to use a 32-bit view if the buffer is memory-aligned.
80
+ // This reduces loop iterations and arithmetic by 4x.
81
+ const isAligned = src.byteOffset % 4 === 0
51
82
 
52
- const clip = resolveBlitClipping(
53
- 0,
54
- 0,
55
- x,
56
- y,
57
- w,
58
- h,
59
- w,
60
- h,
61
- srcW,
62
- srcH,
63
- SCRATCH_BLIT,
64
- )
83
+ if (isAligned) {
84
+ const srcLen32 = src.byteLength / 4
85
+ const src32 = new Uint32Array(src.buffer, src.byteOffset, srcLen32)
86
+ const out32 = new Uint32Array(out.buffer)
65
87
 
66
- if (!clip.inBounds) return out
88
+ for (let row = 0; row < copyH; row++) {
89
+ const srcStart = (srcY + row) * srcW + srcX
90
+ const dstStart = (dstY + row) * w + dstX
91
+ const chunk = src32.subarray(srcStart, srcStart + copyW)
67
92
 
68
- const { x: dstX, y: dstY, sx: srcX, sy: srcY, w: copyW, h: copyH } = clip
69
- const rowLen = copyW * 4
93
+ out32.set(chunk, dstStart)
94
+ }
95
+ } else {
96
+ // Fallback for unaligned data
97
+ const rowLen = copyW * 4
70
98
 
71
- for (let row = 0; row < copyH; row++) {
72
- const srcStart = ((srcY + row) * srcW + srcX) * 4
73
- const dstStart = ((dstY + row) * w + dstX) * 4
99
+ for (let row = 0; row < copyH; row++) {
100
+ const srcStart = ((srcY + row) * srcW + srcX) * 4
101
+ const dstStart = ((dstY + row) * w + dstX) * 4
102
+ const chunk = src.subarray(srcStart, srcStart + rowLen)
74
103
 
75
- // Perform the high-speed bulk copy
76
- out.set(src.subarray(srcStart, srcStart + rowLen), dstStart)
104
+ out.set(chunk, dstStart)
105
+ }
77
106
  }
78
107
 
79
108
  return out
@@ -1,8 +1,3 @@
1
- import { MaskType } from '../Mask/_mask-types'
2
- import { makeClippedBlit, resolveBlitClipping } from '../Rect/resolveClipping'
3
-
4
- const SCRATCH_BLIT = makeClippedBlit()
5
-
6
1
  /**
7
2
  * Writes image data from a source to a target with support for clipping and alpha masking.
8
3
  *
@@ -10,88 +5,72 @@ const SCRATCH_BLIT = makeClippedBlit()
10
5
  * @param source - The source ImageData to read from.
11
6
  * @param x - The x-coordinate in the target where drawing starts.
12
7
  * @param y - The y-coordinate in the target where drawing starts.
13
- * @param sx - The x-coordinate in the source to start copying from.
14
- * @param sy - The y-coordinate in the source to start copying from.
15
- * @param sw - The width of the rectangle to copy.
16
- * @param sh - The height of the rectangle to copy.
17
- * @param mask - An optional Uint8Array mask (0-255). 0 is transparent, 255 is opaque.
18
- * @param maskType - type of mask
19
8
  */
20
9
  export function writeImageData(
21
10
  target: ImageData,
22
11
  source: ImageData,
23
12
  x: number,
24
13
  y: number,
25
- sx: number = 0,
26
- sy: number = 0,
27
- sw: number = source.width,
28
- sh: number = source.height,
29
- mask: Uint8Array | null = null,
30
- maskType: MaskType = MaskType.BINARY,
31
14
  ): void {
32
15
  const dstW = target.width
33
16
  const dstH = target.height
34
- const dstData = target.data
17
+ const dst = target.data
18
+
35
19
  const srcW = source.width
36
- const srcData = source.data
20
+ const srcH = source.height
21
+ const src = source.data
37
22
 
38
- const clip = resolveBlitClipping(
39
- x, y, sx, sy, sw, sh,
40
- dstW, dstH, srcW, source.height,
41
- SCRATCH_BLIT,
42
- )
23
+ let dstX = x
24
+ let dstY = y
25
+ let srcX = 0
26
+ let srcY = 0
27
+ let copyW = srcW
28
+ let copyH = srcH
43
29
 
44
- if (!clip.inBounds) return
30
+ if (dstX < 0) {
31
+ srcX = -dstX
32
+ copyW += dstX
33
+ dstX = 0
34
+ }
45
35
 
46
- const {
47
- x: dstX,
48
- y: dstY,
49
- sx: srcX,
50
- sy: srcY,
51
- w: copyW,
52
- h: copyH,
53
- } = clip
36
+ if (dstY < 0) {
37
+ srcY = -dstY
38
+ copyH += dstY
39
+ dstY = 0
40
+ }
41
+
42
+ copyW = Math.min(copyW, dstW - dstX)
43
+ copyH = Math.min(copyH, dstH - dstY)
54
44
 
55
- const useMask = !!mask
45
+ if (copyW <= 0) return
46
+ if (copyH <= 0) return
56
47
 
57
- for (let row = 0; row < copyH; row++) {
58
- const currentDstY = dstY + row
59
- const currentSrcY = srcY + row
48
+ const isDstAligned = dst.byteOffset % 4 === 0
49
+ const isSrcAligned = src.byteOffset % 4 === 0
60
50
 
61
- const dstStart = (currentDstY * dstW + dstX) * 4
62
- const srcStart = (currentSrcY * srcW + srcX) * 4
51
+ if (isDstAligned && isSrcAligned) {
52
+ const dstLen32 = dst.byteLength / 4
53
+ const dst32 = new Uint32Array(dst.buffer, dst.byteOffset, dstLen32)
63
54
 
64
- if (useMask && mask) {
65
- for (let ix = 0; ix < copyW; ix++) {
66
- const mi = currentSrcY * srcW + (srcX + ix)
67
- const alpha = mask[mi]
55
+ const srcLen32 = src.byteLength / 4
56
+ const src32 = new Uint32Array(src.buffer, src.byteOffset, srcLen32)
68
57
 
69
- if (alpha === 0) {
70
- continue
71
- }
58
+ for (let row = 0; row < copyH; row++) {
59
+ const dstStart = (dstY + row) * dstW + dstX
60
+ const srcStart = (srcY + row) * srcW + srcX
61
+ const chunk = src32.subarray(srcStart, srcStart + copyW)
72
62
 
73
- const di = dstStart + (ix * 4)
74
- const si = srcStart + (ix * 4)
63
+ dst32.set(chunk, dstStart)
64
+ }
65
+ } else {
66
+ const rowLen = copyW * 4
75
67
 
76
- if (maskType === MaskType.BINARY || alpha === 255) {
77
- dstData[di] = srcData[si]
78
- dstData[di + 1] = srcData[si + 1]
79
- dstData[di + 2] = srcData[si + 2]
80
- dstData[di + 3] = srcData[si + 3]
81
- } else {
82
- const a = alpha / 255
83
- const invA = 1 - a
68
+ for (let row = 0; row < copyH; row++) {
69
+ const dstStart = ((dstY + row) * dstW + dstX) * 4
70
+ const srcStart = ((srcY + row) * srcW + srcX) * 4
71
+ const chunk = src.subarray(srcStart, srcStart + rowLen)
84
72
 
85
- dstData[di] = srcData[si] * a + dstData[di] * invA
86
- dstData[di + 1] = srcData[si + 1] * a + dstData[di + 1] * invA
87
- dstData[di + 2] = srcData[si + 2] * a + dstData[di + 2] * invA
88
- dstData[di + 3] = srcData[si + 3] * a + dstData[di + 3] * invA
89
- }
90
- }
91
- } else {
92
- const byteLen = copyW * 4
93
- const sub = srcData.subarray(srcStart, srcStart + byteLen)
94
- dstData.set(sub, dstStart)
73
+ dst.set(chunk, dstStart)
95
74
  }
96
75
  }
97
76
  }
@@ -1,7 +1,4 @@
1
1
  import type { Rect } from '../Rect/_rect-types'
2
- import { makeClippedBlit, resolveBlitClipping } from '../Rect/resolveClipping'
3
-
4
- const SCRATCH_BLIT = makeClippedBlit()
5
2
 
6
3
  /**
7
4
  * Copies a pixel buffer into a specific region of an {@link ImageData} object.
@@ -43,43 +40,84 @@ export function writeImageDataBuffer(
43
40
  _w?: number,
44
41
  _h?: number,
45
42
  ): void {
46
- const { x, y, w, h } = typeof _x === 'object'
47
- ? _x
48
- : { x: _x, y: _y!, w: _w!, h: _h! }
49
-
50
- const { width: dstW, height: dstH, data: dst } = target
51
-
52
- const clip = resolveBlitClipping(
53
- x,
54
- y,
55
- 0,
56
- 0,
57
- w,
58
- h,
59
- dstW,
60
- dstH,
61
- w,
62
- h,
63
- SCRATCH_BLIT,
64
- )
65
-
66
- if (!clip.inBounds) return
67
-
68
- const {
69
- x: dstX,
70
- y: dstY,
71
- sx: srcX,
72
- sy: srcY,
73
- w: copyW,
74
- h: copyH,
75
- } = clip
76
-
77
- const rowLen = copyW * 4
78
-
79
- for (let row = 0; row < copyH; row++) {
80
- const dstStart = ((dstY + row) * dstW + dstX) * 4
81
- const srcStart = ((srcY + row) * w + srcX) * 4
82
-
83
- dst.set(data.subarray(srcStart, srcStart + rowLen), dstStart)
43
+ let x: number
44
+ let y: number
45
+ let w: number
46
+ let h: number
47
+
48
+ if (typeof _x === 'object') {
49
+ x = _x.x
50
+ y = _x.y
51
+ w = _x.w
52
+ h = _x.h
53
+ } else {
54
+ x = _x
55
+ y = _y!
56
+ w = _w!
57
+ h = _h!
58
+ }
59
+
60
+ if (w <= 0) return
61
+ if (h <= 0) return
62
+
63
+ const dstW = target.width
64
+ const dstH = target.height
65
+ const dst = target.data
66
+
67
+ // Inline clipping logic for destination boundaries
68
+ let dstX = x
69
+ let dstY = y
70
+ let srcX = 0
71
+ let srcY = 0
72
+ let copyW = w
73
+ let copyH = h
74
+
75
+ if (dstX < 0) {
76
+ srcX = -dstX
77
+ copyW += dstX
78
+ dstX = 0
79
+ }
80
+
81
+ if (dstY < 0) {
82
+ srcY = -dstY
83
+ copyH += dstY
84
+ dstY = 0
85
+ }
86
+
87
+ copyW = Math.min(copyW, dstW - dstX)
88
+ copyH = Math.min(copyH, dstH - dstY)
89
+
90
+ if (copyW <= 0) return
91
+ if (copyH <= 0) return
92
+
93
+ // Fast-path: Both arrays must be 4-byte aligned to use Uint32Array safely
94
+ const isDstAligned = dst.byteOffset % 4 === 0
95
+ const isSrcAligned = data.byteOffset % 4 === 0
96
+
97
+ if (isDstAligned && isSrcAligned) {
98
+ const dstLen32 = dst.byteLength / 4
99
+ const dst32 = new Uint32Array(dst.buffer, dst.byteOffset, dstLen32)
100
+
101
+ const srcLen32 = data.byteLength / 4
102
+ const src32 = new Uint32Array(data.buffer, data.byteOffset, srcLen32)
103
+
104
+ for (let row = 0; row < copyH; row++) {
105
+ const dstStart = (dstY + row) * dstW + dstX
106
+ const srcStart = (srcY + row) * w + srcX
107
+ const chunk = src32.subarray(srcStart, srcStart + copyW)
108
+
109
+ dst32.set(chunk, dstStart)
110
+ }
111
+ } else {
112
+ // Fallback for unaligned data arrays
113
+ const rowLen = copyW * 4
114
+
115
+ for (let row = 0; row < copyH; row++) {
116
+ const dstStart = ((dstY + row) * dstW + dstX) * 4
117
+ const srcStart = ((srcY + row) * w + srcX) * 4
118
+ const chunk = data.subarray(srcStart, srcStart + rowLen)
119
+
120
+ dst.set(chunk, dstStart)
121
+ }
84
122
  }
85
123
  }