@umituz/web-canvas 1.1.1 → 1.2.1

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.
@@ -3,8 +3,8 @@ import {
3
3
  useFreehandCanvas,
4
4
  useReportExport,
5
5
  useScreenshotStudio
6
- } from "../chunk-YKXOSUMF.mjs";
7
- import "../chunk-DNKRSL6D.mjs";
6
+ } from "../chunk-7YLVULSN.mjs";
7
+ import "../chunk-P6RJ75US.mjs";
8
8
  import "../chunk-UOGTEMIJ.mjs";
9
9
  import "../chunk-Z6IB4BQV.mjs";
10
10
  import "../chunk-MC6PNV2F.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-canvas",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Comprehensive canvas and screenshot generation library with device frames, patterns, and PDF export",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -77,7 +77,7 @@
77
77
  "license": "MIT",
78
78
  "repository": {
79
79
  "type": "git",
80
- "url": "https://github.com/umituz/web-canvas"
80
+ "url": "git+https://github.com/umituz/web-canvas.git"
81
81
  },
82
82
  "bugs": {
83
83
  "url": "https://github.com/umituz/web-canvas/issues"
@@ -97,7 +97,6 @@
97
97
  "dependencies": {
98
98
  "html2canvas": "^1.4.1",
99
99
  "jspdf": "^4.2.1",
100
- "jszip": "^3.10.1",
101
- "xlsx": "^0.18.5"
100
+ "jszip": "^3.10.1"
102
101
  }
103
- }
102
+ }
@@ -5,6 +5,20 @@
5
5
 
6
6
  import type { ReportData } from '../../domain/types/report.types';
7
7
  import type { PDFExportOptions, ExcelExportOptions } from '../../domain/types/report.types';
8
+ import { writeXlsxBlob } from '../../infrastructure/services/minimalXlsx';
9
+ import type { XlsxSheet } from '../../infrastructure/services/minimalXlsx';
10
+
11
+ /** Flat records → header + value rows, matching json_to_sheet's shape. */
12
+ function recordsToSheet(
13
+ name: string,
14
+ records: Record<string, string | number | boolean | null | undefined>[]
15
+ ): XlsxSheet {
16
+ const headers = [...new Set(records.flatMap((record) => Object.keys(record)))];
17
+ return {
18
+ name,
19
+ rows: [headers, ...records.map((record) => headers.map((header) => record[header]))],
20
+ };
21
+ }
8
22
 
9
23
  export class ReportService {
10
24
  private static instance: ReportService;
@@ -71,8 +85,7 @@ export class ReportService {
71
85
  data: ReportData,
72
86
  options: ExcelExportOptions = {}
73
87
  ): Promise<void> {
74
- const XLSX = await import('xlsx');
75
- const wb = XLSX.utils.book_new();
88
+ const JSZip = (await import('jszip')).default;
76
89
 
77
90
  // Group items by type
78
91
  const apps = data.items
@@ -85,26 +98,15 @@ export class ReportService {
85
98
  .filter((i) => i.type === 'post')
86
99
  .map((i) => ({ Name: i.name, ...i.metrics, ...i.details }));
87
100
 
101
+ const sheets: XlsxSheet[] = [];
88
102
  if (apps.length > 0) {
89
- XLSX.utils.book_append_sheet(
90
- wb,
91
- XLSX.utils.json_to_sheet(apps),
92
- options.sheetName || 'Apps'
93
- );
103
+ sheets.push(recordsToSheet(options.sheetName || 'Apps', apps));
94
104
  }
95
105
  if (campaigns.length > 0) {
96
- XLSX.utils.book_append_sheet(
97
- wb,
98
- XLSX.utils.json_to_sheet(campaigns),
99
- 'Campaigns'
100
- );
106
+ sheets.push(recordsToSheet('Campaigns', campaigns));
101
107
  }
102
108
  if (posts.length > 0) {
103
- XLSX.utils.book_append_sheet(
104
- wb,
105
- XLSX.utils.json_to_sheet(posts),
106
- 'Posts'
107
- );
109
+ sheets.push(recordsToSheet('Posts', posts));
108
110
  }
109
111
 
110
112
  // Summary sheet
@@ -124,14 +126,20 @@ export class ReportService {
124
126
  Value: new Date().toLocaleDateString(),
125
127
  },
126
128
  ];
127
- XLSX.utils.book_append_sheet(
128
- wb,
129
- XLSX.utils.json_to_sheet(summary),
130
- 'Summary'
131
- );
129
+ sheets.push(recordsToSheet('Summary', summary));
130
+ }
131
+
132
+ if (sheets.length === 0) {
133
+ sheets.push({ name: options.sheetName || 'Sheet1', rows: [] });
132
134
  }
133
135
 
134
- XLSX.writeFile(wb, options.filename || 'report.xlsx');
136
+ const blob = await writeXlsxBlob(sheets, JSZip);
137
+ const url = URL.createObjectURL(blob);
138
+ const link = document.createElement('a');
139
+ link.href = url;
140
+ link.download = options.filename || 'report.xlsx';
141
+ link.click();
142
+ URL.revokeObjectURL(url);
135
143
  }
136
144
 
137
145
  /**
@@ -1,42 +1,21 @@
1
- /**
2
- * Freehand Types
3
- * Types for pointer-based freehand drawing on a canvas
4
- */
5
-
6
- /**
7
- * A single sampled point along a stroke, in CSS pixels relative to the canvas
8
- */
9
1
  export interface FreehandPoint {
10
2
  x: number;
11
3
  y: number;
12
- /** Normalized stylus pressure (0–1). ~0.5 for mouse/touch when unsupported. */
13
4
  pressure: number;
14
5
  }
15
6
 
16
- /**
17
- * A completed (or in-progress) freehand stroke
18
- */
19
7
  export interface FreehandStroke {
20
- /** Base brush size in CSS pixels. */
21
8
  size: number;
22
- /** CSS color of the stroke. */
23
9
  color: string;
24
- /** True when the stroke should vary width with per-point pressure. */
25
10
  dynamic: boolean;
26
- /** Ordered points from pointer-down to pointer-up. */
27
11
  points: FreehandPoint[];
28
12
  }
29
13
 
30
- /**
31
- * Options for the useFreehandCanvas hook
32
- */
33
14
  export interface UseFreehandCanvasOptions {
34
- /** Base brush size in CSS pixels. Default: 3 */
35
15
  brushSize?: number;
36
- /** Stroke color. Default: '#1a1a1a' */
37
16
  color?: string;
38
- /** Vary stroke width with stylus pressure (mouse/touch stay constant). Default: true */
39
17
  pressureEnabled?: boolean;
40
- /** Called after each completed stroke (pointer-up). */
18
+ keyboard?: boolean;
19
+ keyboardStep?: number;
41
20
  onStrokeEnd?: (stroke: FreehandStroke) => void;
42
21
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Minimal XLSX writer — replaces the unmaintained npm `xlsx` (SheetJS)
3
+ * dependency, whose registry build carries open prototype-pollution and
4
+ * ReDoS advisories with no registry fix. The report export only ever
5
+ * writes flat tables (header row + primitive cells), so a hand-rolled
6
+ * SpreadsheetML package on top of jszip covers the need: inline-string
7
+ * and numeric cells, multiple sheets, Excel/LibreOffice/Sheets-compatible.
8
+ */
9
+
10
+ export interface XlsxSheet {
11
+ name: string;
12
+ /** Rows of primitive cells; the first row is the header. */
13
+ rows: (string | number | boolean | null | undefined)[][];
14
+ }
15
+
16
+ const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
17
+
18
+ function escapeXml(value: string): string {
19
+ return value
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&apos;');
25
+ }
26
+
27
+ /** Excel sheet names: max 31 chars, no : \ / ? * [ ]. */
28
+ function safeSheetName(name: string, index: number): string {
29
+ const cleaned = name.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31).trim();
30
+ return cleaned.length > 0 ? cleaned : `Sheet${index + 1}`;
31
+ }
32
+
33
+ function columnName(index: number): string {
34
+ let name = '';
35
+ let n = index;
36
+ while (n >= 0) {
37
+ name = String.fromCharCode(65 + (n % 26)) + name;
38
+ n = Math.floor(n / 26) - 1;
39
+ }
40
+ return name;
41
+ }
42
+
43
+ function cellXml(value: string | number | boolean | null | undefined, row: number, column: number): string {
44
+ const ref = `${columnName(column)}${row}`;
45
+ if (typeof value === 'number' && Number.isFinite(value)) {
46
+ return `<c r="${ref}"><v>${value}</v></c>`;
47
+ }
48
+ const text =
49
+ value === null || value === undefined
50
+ ? ''
51
+ : typeof value === 'boolean'
52
+ ? value
53
+ ? 'TRUE'
54
+ : 'FALSE'
55
+ : String(value);
56
+ return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(text)}</t></is></c>`;
57
+ }
58
+
59
+ function sheetXml(rows: XlsxSheet['rows']): string {
60
+ const body = rows
61
+ .map((row, rowIndex) => {
62
+ const cells = row.map((value, columnIndex) => cellXml(value, rowIndex + 1, columnIndex)).join('');
63
+ return `<row r="${rowIndex + 1}">${cells}</row>`;
64
+ })
65
+ .join('');
66
+ return `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
67
+ }
68
+
69
+ /** Assembles the sheets into a .xlsx zip blob using jszip. */
70
+ export async function writeXlsxBlob(sheets: XlsxSheet[], JSZip: typeof import('jszip')): Promise<Blob> {
71
+ const zip = new JSZip();
72
+ const safeNames = sheets.map((sheet, index) => safeSheetName(sheet.name, index));
73
+
74
+ zip.file(
75
+ '[Content_Types].xml',
76
+ `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
77
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
78
+ '<Default Extension="xml" ContentType="application/xml"/>' +
79
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
80
+ safeNames
81
+ .map(
82
+ (_, index) =>
83
+ `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
84
+ )
85
+ .join('') +
86
+ '</Types>',
87
+ );
88
+
89
+ zip.file(
90
+ '_rels/.rels',
91
+ `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
92
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
93
+ '</Relationships>',
94
+ );
95
+
96
+ zip.file(
97
+ 'xl/workbook.xml',
98
+ `${XML_HEADER}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
99
+ safeNames
100
+ .map((name, index) => `<sheet name="${escapeXml(name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`)
101
+ .join('') +
102
+ '</sheets></workbook>',
103
+ );
104
+
105
+ zip.file(
106
+ 'xl/_rels/workbook.xml.rels',
107
+ `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
108
+ safeNames
109
+ .map(
110
+ (_, index) =>
111
+ `<Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`,
112
+ )
113
+ .join('') +
114
+ '</Relationships>',
115
+ );
116
+
117
+ sheets.forEach((sheet, index) => {
118
+ zip.file(`xl/worksheets/sheet${index + 1}.xml`, sheetXml(sheet.rows));
119
+ });
120
+
121
+ return zip.generateAsync({ type: 'blob' });
122
+ }
@@ -1,8 +1,3 @@
1
- /**
2
- * useFreehandCanvas Hook
3
- * React hook for pointer-based freehand drawing on a canvas
4
- */
5
-
6
1
  import { useCallback, useEffect, useRef, useState } from 'react';
7
2
  import type {
8
3
  FreehandPoint,
@@ -12,64 +7,53 @@ import type {
12
7
 
13
8
  const DEFAULT_BRUSH_SIZE = 3;
14
9
  const DEFAULT_COLOR = '#1a1a1a';
10
+ const DEFAULT_KEYBOARD_STEP = 4;
11
+ const KEYBOARD_FAST_FACTOR = 4;
15
12
 
16
- /**
17
- * Width for a point: constant for mouse/touch, pressure-scaled for pens
18
- */
19
13
  function widthFor(stroke: FreehandStroke, point: FreehandPoint): number {
20
14
  if (!stroke.dynamic) return stroke.size;
21
- // pressure is 0 (some mice) – 1 (hard press); clamp the visual range
22
15
  return stroke.size * (0.5 + Math.min(1, Math.max(0, point.pressure)));
23
16
  }
24
17
 
25
- /**
26
- * Return value of the useFreehandCanvas hook
27
- */
28
18
  export interface UseFreehandCanvasReturn {
29
- /** Ref to attach to the <canvas> element that captures drawing. */
30
19
  canvasRef: React.RefObject<HTMLCanvasElement | null>;
31
- /** Remove the most recent stroke. */
32
20
  undo: () => void;
33
- /** Remove all strokes. */
34
21
  clear: () => void;
35
- /** Number of committed strokes. */
36
22
  strokeCount: number;
37
- /** True when no strokes are committed. */
38
23
  isEmpty: boolean;
39
- /** Snapshot of committed strokes (safe to serialize). */
40
24
  getStrokes: () => FreehandStroke[];
41
25
  }
42
26
 
43
- /**
44
- * useFreehandCanvas hook
45
- *
46
- * Attaches pointer-event drawing to a canvas element. The canvas is sized to
47
- * its CSS box at device-pixel resolution (and re-rendered on resize), strokes
48
- * are replayed from captured points so undo/clear are lossless, and stylus
49
- * pressure modulates line width when the pointer reports it.
50
- *
51
- * The canvas gets `touch-action: none` applied so drawing works on touch
52
- * screens without scrolling the page. Consumers typically layer the canvas
53
- * over their own guide content (e.g. a faint glyph to trace).
54
- */
27
+ interface KeyboardPen {
28
+ x: number;
29
+ y: number;
30
+ down: boolean;
31
+ engaged: boolean;
32
+ }
33
+
55
34
  export function useFreehandCanvas(
56
35
  options: UseFreehandCanvasOptions = {}
57
36
  ): UseFreehandCanvasReturn {
58
- const { brushSize = DEFAULT_BRUSH_SIZE, color = DEFAULT_COLOR, pressureEnabled = true } = options;
37
+ const {
38
+ brushSize = DEFAULT_BRUSH_SIZE,
39
+ color = DEFAULT_COLOR,
40
+ pressureEnabled = true,
41
+ keyboard = false,
42
+ keyboardStep = DEFAULT_KEYBOARD_STEP,
43
+ } = options;
59
44
 
60
- // Latest option values for event handlers, without re-binding listeners.
61
- const optionsRef = useRef({ brushSize, color, pressureEnabled, onStrokeEnd: options.onStrokeEnd });
62
- optionsRef.current = { brushSize, color, pressureEnabled, onStrokeEnd: options.onStrokeEnd };
45
+ const optionsRef = useRef({ brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd });
46
+ optionsRef.current = { brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd };
63
47
 
64
48
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
65
49
  const strokesRef = useRef<FreehandStroke[]>([]);
66
50
  const activeStrokeRef = useRef<FreehandStroke | null>(null);
67
51
  const activePointerIdRef = useRef<number | null>(null);
68
52
  const dprRef = useRef(1);
53
+ const penRef = useRef<KeyboardPen | null>(null);
69
54
 
70
55
  const [strokeCount, setStrokeCount] = useState(0);
71
56
 
72
- /** Replay every committed stroke (and the active one) at current DPR. */
73
57
  const redraw = useCallback(() => {
74
58
  const canvas = canvasRef.current;
75
59
  const ctx = canvas?.getContext('2d');
@@ -103,9 +87,20 @@ export function useFreehandCanvas(
103
87
  ctx.stroke();
104
88
  }
105
89
  }
90
+ const pen = penRef.current;
91
+ if (pen?.engaged && !pen.down) {
92
+ const reach = 5;
93
+ ctx.strokeStyle = 'rgba(35, 37, 43, 0.55)';
94
+ ctx.lineWidth = 1;
95
+ ctx.beginPath();
96
+ ctx.moveTo(pen.x - reach, pen.y);
97
+ ctx.lineTo(pen.x + reach, pen.y);
98
+ ctx.moveTo(pen.x, pen.y - reach);
99
+ ctx.lineTo(pen.x, pen.y + reach);
100
+ ctx.stroke();
101
+ }
106
102
  }, []);
107
103
 
108
- /** Match the backing store to the CSS box; returns true when it changed. */
109
104
  const syncDimensions = useCallback((): boolean => {
110
105
  const canvas = canvasRef.current;
111
106
  if (!canvas) return false;
@@ -125,8 +120,9 @@ export function useFreehandCanvas(
125
120
 
126
121
  const el: HTMLCanvasElement = canvas;
127
122
 
128
- // Touch screens must draw, not scroll, while the pointer is down.
129
123
  el.style.touchAction = 'none';
124
+ if (keyboard && !el.hasAttribute('tabindex')) el.tabIndex = 0;
125
+ if (!penRef.current) penRef.current = { x: el.clientWidth / 2, y: el.clientHeight / 2, down: false, engaged: false };
130
126
 
131
127
  syncDimensions();
132
128
  redraw();
@@ -169,8 +165,6 @@ export function useFreehandCanvas(
169
165
 
170
166
  function onPointerDown(event: PointerEvent): void {
171
167
  if (event.button !== 0 && event.pointerType === 'mouse') return;
172
- // Only one stroke at a time: a resting palm or second finger touching
173
- // mid-stroke must not hijack or corrupt the active stroke.
174
168
  if (activeStrokeRef.current != null) return;
175
169
  event.preventDefault();
176
170
  el.setPointerCapture(event.pointerId);
@@ -183,6 +177,7 @@ export function useFreehandCanvas(
183
177
  points: [pointFrom(event)],
184
178
  };
185
179
  activeStrokeRef.current = stroke;
180
+ penRef.current = { x: stroke.points[0].x, y: stroke.points[0].y, down: false, engaged: false };
186
181
  drawDot(stroke, stroke.points[0]);
187
182
  }
188
183
 
@@ -209,10 +204,76 @@ export function useFreehandCanvas(
209
204
  optionsRef.current.onStrokeEnd?.(stroke);
210
205
  }
211
206
 
207
+ function clampPen(pen: KeyboardPen): void {
208
+ pen.x = Math.min(Math.max(pen.x, 0), Math.max(0, el.clientWidth - 1));
209
+ pen.y = Math.min(Math.max(pen.y, 0), Math.max(0, el.clientHeight - 1));
210
+ }
211
+
212
+ function startKeyboardStroke(pen: KeyboardPen): void {
213
+ const { brushSize: size, color: ink } = optionsRef.current;
214
+ const stroke: FreehandStroke = {
215
+ size,
216
+ color: ink,
217
+ dynamic: false,
218
+ points: [{ x: pen.x, y: pen.y, pressure: 0.5 }],
219
+ };
220
+ activeStrokeRef.current = stroke;
221
+ pen.down = true;
222
+ drawDot(stroke, stroke.points[0]);
223
+ }
224
+
225
+ function commitKeyboardStroke(pen: KeyboardPen): void {
226
+ const stroke = activeStrokeRef.current;
227
+ if (!stroke) return;
228
+ activeStrokeRef.current = null;
229
+ pen.down = false;
230
+ strokesRef.current = [...strokesRef.current, stroke];
231
+ setStrokeCount(strokesRef.current.length);
232
+ optionsRef.current.onStrokeEnd?.(stroke);
233
+ redraw();
234
+ }
235
+
236
+ function onKeyDown(event: KeyboardEvent): void {
237
+ const pen = penRef.current;
238
+ if (!pen) return;
239
+ const fast = event.shiftKey ? KEYBOARD_FAST_FACTOR : 1;
240
+ const step = optionsRef.current.keyboardStep * fast;
241
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowDown') {
242
+ event.preventDefault();
243
+ pen.engaged = true;
244
+ pen.x += event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
245
+ pen.y += event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
246
+ clampPen(pen);
247
+ const stroke = activeStrokeRef.current;
248
+ if (pen.down && stroke) {
249
+ const point: FreehandPoint = { x: pen.x, y: pen.y, pressure: 0.5 };
250
+ const previous = stroke.points[stroke.points.length - 1];
251
+ stroke.points.push(point);
252
+ drawSegment(stroke, previous, point);
253
+ } else {
254
+ redraw();
255
+ }
256
+ return;
257
+ }
258
+ if (event.key === ' ' || event.key === 'Enter') {
259
+ event.preventDefault();
260
+ pen.engaged = true;
261
+ if (pen.down) commitKeyboardStroke(pen);
262
+ else startKeyboardStroke(pen);
263
+ return;
264
+ }
265
+ if (event.key === 'Escape' && pen.down) {
266
+ activeStrokeRef.current = null;
267
+ pen.down = false;
268
+ redraw();
269
+ }
270
+ }
271
+
212
272
  el.addEventListener('pointerdown', onPointerDown);
213
273
  el.addEventListener('pointermove', onPointerMove);
214
274
  el.addEventListener('pointerup', commit);
215
275
  el.addEventListener('pointercancel', commit);
276
+ if (keyboard) el.addEventListener('keydown', onKeyDown);
216
277
 
217
278
  return () => {
218
279
  observer.disconnect();
@@ -220,8 +281,9 @@ export function useFreehandCanvas(
220
281
  el.removeEventListener('pointermove', onPointerMove);
221
282
  el.removeEventListener('pointerup', commit);
222
283
  el.removeEventListener('pointercancel', commit);
284
+ el.removeEventListener('keydown', onKeyDown);
223
285
  };
224
- }, [redraw, syncDimensions]);
286
+ }, [redraw, syncDimensions, keyboard]);
225
287
 
226
288
  const undo = useCallback(() => {
227
289
  if (strokesRef.current.length === 0) return;