@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.
- package/dist/application/index.js +85 -23
- package/dist/application/index.mjs +1 -1
- package/dist/{chunk-YKXOSUMF.mjs → chunk-7YLVULSN.mjs} +92 -5
- package/dist/{chunk-DNKRSL6D.mjs → chunk-P6RJ75US.mjs} +85 -23
- package/dist/domain/index.d.mts +2 -22
- package/dist/domain/index.d.ts +2 -22
- package/dist/index.js +176 -27
- package/dist/index.mjs +2 -2
- package/dist/presentation/index.d.mts +0 -26
- package/dist/presentation/index.d.ts +0 -26
- package/dist/presentation/index.js +176 -27
- package/dist/presentation/index.mjs +2 -2
- package/package.json +4 -5
- package/src/application/services/ReportService.ts +31 -23
- package/src/domain/types/freehand.types.ts +2 -23
- package/src/infrastructure/services/minimalXlsx.ts +122 -0
- package/src/presentation/hooks/useFreehandCanvas.ts +102 -40
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
useFreehandCanvas,
|
|
4
4
|
useReportExport,
|
|
5
5
|
useScreenshotStudio
|
|
6
|
-
} from "../chunk-
|
|
7
|
-
import "../chunk-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {
|
|
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
|
-
|
|
61
|
-
|
|
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;
|