@umituz/web-canvas 1.2.0 → 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/{chunk-YRC4YPMO.mjs → chunk-7YLVULSN.mjs} +91 -4
- package/dist/domain/index.d.mts +2 -22
- package/dist/domain/index.d.ts +2 -22
- package/dist/index.js +91 -4
- package/dist/index.mjs +1 -1
- package/dist/presentation/index.d.mts +0 -26
- package/dist/presentation/index.d.ts +0 -26
- package/dist/presentation/index.js +91 -4
- package/dist/presentation/index.mjs +1 -1
- package/package.json +1 -1
- package/src/domain/types/freehand.types.ts +2 -23
- package/src/presentation/hooks/useFreehandCanvas.ts +102 -40
|
@@ -604,19 +604,28 @@ function useReportExport() {
|
|
|
604
604
|
import { useCallback as useCallback4, useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
|
|
605
605
|
var DEFAULT_BRUSH_SIZE = 3;
|
|
606
606
|
var DEFAULT_COLOR = "#1a1a1a";
|
|
607
|
+
var DEFAULT_KEYBOARD_STEP = 4;
|
|
608
|
+
var KEYBOARD_FAST_FACTOR = 4;
|
|
607
609
|
function widthFor(stroke, point) {
|
|
608
610
|
if (!stroke.dynamic) return stroke.size;
|
|
609
611
|
return stroke.size * (0.5 + Math.min(1, Math.max(0, point.pressure)));
|
|
610
612
|
}
|
|
611
613
|
function useFreehandCanvas(options = {}) {
|
|
612
|
-
const {
|
|
613
|
-
|
|
614
|
-
|
|
614
|
+
const {
|
|
615
|
+
brushSize = DEFAULT_BRUSH_SIZE,
|
|
616
|
+
color = DEFAULT_COLOR,
|
|
617
|
+
pressureEnabled = true,
|
|
618
|
+
keyboard = false,
|
|
619
|
+
keyboardStep = DEFAULT_KEYBOARD_STEP
|
|
620
|
+
} = options;
|
|
621
|
+
const optionsRef = useRef4({ brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd });
|
|
622
|
+
optionsRef.current = { brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd };
|
|
615
623
|
const canvasRef = useRef4(null);
|
|
616
624
|
const strokesRef = useRef4([]);
|
|
617
625
|
const activeStrokeRef = useRef4(null);
|
|
618
626
|
const activePointerIdRef = useRef4(null);
|
|
619
627
|
const dprRef = useRef4(1);
|
|
628
|
+
const penRef = useRef4(null);
|
|
620
629
|
const [strokeCount, setStrokeCount] = useState4(0);
|
|
621
630
|
const redraw = useCallback4(() => {
|
|
622
631
|
const canvas = canvasRef.current;
|
|
@@ -649,6 +658,18 @@ function useFreehandCanvas(options = {}) {
|
|
|
649
658
|
ctx.stroke();
|
|
650
659
|
}
|
|
651
660
|
}
|
|
661
|
+
const pen = penRef.current;
|
|
662
|
+
if (pen?.engaged && !pen.down) {
|
|
663
|
+
const reach = 5;
|
|
664
|
+
ctx.strokeStyle = "rgba(35, 37, 43, 0.55)";
|
|
665
|
+
ctx.lineWidth = 1;
|
|
666
|
+
ctx.beginPath();
|
|
667
|
+
ctx.moveTo(pen.x - reach, pen.y);
|
|
668
|
+
ctx.lineTo(pen.x + reach, pen.y);
|
|
669
|
+
ctx.moveTo(pen.x, pen.y - reach);
|
|
670
|
+
ctx.lineTo(pen.x, pen.y + reach);
|
|
671
|
+
ctx.stroke();
|
|
672
|
+
}
|
|
652
673
|
}, []);
|
|
653
674
|
const syncDimensions = useCallback4(() => {
|
|
654
675
|
const canvas = canvasRef.current;
|
|
@@ -667,6 +688,8 @@ function useFreehandCanvas(options = {}) {
|
|
|
667
688
|
if (!canvas) return;
|
|
668
689
|
const el = canvas;
|
|
669
690
|
el.style.touchAction = "none";
|
|
691
|
+
if (keyboard && !el.hasAttribute("tabindex")) el.tabIndex = 0;
|
|
692
|
+
if (!penRef.current) penRef.current = { x: el.clientWidth / 2, y: el.clientHeight / 2, down: false, engaged: false };
|
|
670
693
|
syncDimensions();
|
|
671
694
|
redraw();
|
|
672
695
|
const observer = new ResizeObserver(() => {
|
|
@@ -715,6 +738,7 @@ function useFreehandCanvas(options = {}) {
|
|
|
715
738
|
points: [pointFrom(event)]
|
|
716
739
|
};
|
|
717
740
|
activeStrokeRef.current = stroke;
|
|
741
|
+
penRef.current = { x: stroke.points[0].x, y: stroke.points[0].y, down: false, engaged: false };
|
|
718
742
|
drawDot(stroke, stroke.points[0]);
|
|
719
743
|
}
|
|
720
744
|
function onPointerMove(event) {
|
|
@@ -738,18 +762,81 @@ function useFreehandCanvas(options = {}) {
|
|
|
738
762
|
setStrokeCount(strokesRef.current.length);
|
|
739
763
|
optionsRef.current.onStrokeEnd?.(stroke);
|
|
740
764
|
}
|
|
765
|
+
function clampPen(pen) {
|
|
766
|
+
pen.x = Math.min(Math.max(pen.x, 0), Math.max(0, el.clientWidth - 1));
|
|
767
|
+
pen.y = Math.min(Math.max(pen.y, 0), Math.max(0, el.clientHeight - 1));
|
|
768
|
+
}
|
|
769
|
+
function startKeyboardStroke(pen) {
|
|
770
|
+
const { brushSize: size, color: ink } = optionsRef.current;
|
|
771
|
+
const stroke = {
|
|
772
|
+
size,
|
|
773
|
+
color: ink,
|
|
774
|
+
dynamic: false,
|
|
775
|
+
points: [{ x: pen.x, y: pen.y, pressure: 0.5 }]
|
|
776
|
+
};
|
|
777
|
+
activeStrokeRef.current = stroke;
|
|
778
|
+
pen.down = true;
|
|
779
|
+
drawDot(stroke, stroke.points[0]);
|
|
780
|
+
}
|
|
781
|
+
function commitKeyboardStroke(pen) {
|
|
782
|
+
const stroke = activeStrokeRef.current;
|
|
783
|
+
if (!stroke) return;
|
|
784
|
+
activeStrokeRef.current = null;
|
|
785
|
+
pen.down = false;
|
|
786
|
+
strokesRef.current = [...strokesRef.current, stroke];
|
|
787
|
+
setStrokeCount(strokesRef.current.length);
|
|
788
|
+
optionsRef.current.onStrokeEnd?.(stroke);
|
|
789
|
+
redraw();
|
|
790
|
+
}
|
|
791
|
+
function onKeyDown(event) {
|
|
792
|
+
const pen = penRef.current;
|
|
793
|
+
if (!pen) return;
|
|
794
|
+
const fast = event.shiftKey ? KEYBOARD_FAST_FACTOR : 1;
|
|
795
|
+
const step = optionsRef.current.keyboardStep * fast;
|
|
796
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
797
|
+
event.preventDefault();
|
|
798
|
+
pen.engaged = true;
|
|
799
|
+
pen.x += event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
|
800
|
+
pen.y += event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
|
801
|
+
clampPen(pen);
|
|
802
|
+
const stroke = activeStrokeRef.current;
|
|
803
|
+
if (pen.down && stroke) {
|
|
804
|
+
const point = { x: pen.x, y: pen.y, pressure: 0.5 };
|
|
805
|
+
const previous = stroke.points[stroke.points.length - 1];
|
|
806
|
+
stroke.points.push(point);
|
|
807
|
+
drawSegment(stroke, previous, point);
|
|
808
|
+
} else {
|
|
809
|
+
redraw();
|
|
810
|
+
}
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (event.key === " " || event.key === "Enter") {
|
|
814
|
+
event.preventDefault();
|
|
815
|
+
pen.engaged = true;
|
|
816
|
+
if (pen.down) commitKeyboardStroke(pen);
|
|
817
|
+
else startKeyboardStroke(pen);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (event.key === "Escape" && pen.down) {
|
|
821
|
+
activeStrokeRef.current = null;
|
|
822
|
+
pen.down = false;
|
|
823
|
+
redraw();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
741
826
|
el.addEventListener("pointerdown", onPointerDown);
|
|
742
827
|
el.addEventListener("pointermove", onPointerMove);
|
|
743
828
|
el.addEventListener("pointerup", commit);
|
|
744
829
|
el.addEventListener("pointercancel", commit);
|
|
830
|
+
if (keyboard) el.addEventListener("keydown", onKeyDown);
|
|
745
831
|
return () => {
|
|
746
832
|
observer.disconnect();
|
|
747
833
|
el.removeEventListener("pointerdown", onPointerDown);
|
|
748
834
|
el.removeEventListener("pointermove", onPointerMove);
|
|
749
835
|
el.removeEventListener("pointerup", commit);
|
|
750
836
|
el.removeEventListener("pointercancel", commit);
|
|
837
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
751
838
|
};
|
|
752
|
-
}, [redraw, syncDimensions]);
|
|
839
|
+
}, [redraw, syncDimensions, keyboard]);
|
|
753
840
|
const undo = useCallback4(() => {
|
|
754
841
|
if (strokesRef.current.length === 0) return;
|
|
755
842
|
strokesRef.current = strokesRef.current.slice(0, -1);
|
package/dist/domain/index.d.mts
CHANGED
|
@@ -2,43 +2,23 @@ export { A as AppStoreMetadata, B as BackgroundPattern, u as BackgroundPatternEn
|
|
|
2
2
|
export { C as CSVExportOptions, E as ExcelExportOptions, a as ExportFormat, P as PDFExportOptions, R as ReportData, b as ReportExportResult, c as ReportItem, d as ReportItemType, e as ReportSummary } from '../report.types-DWDnprBx.mjs';
|
|
3
3
|
export { AIConfig, DEFAULT_DEVICE_CONFIG, DEFAULT_SCREENSHOT_STUDIO_CONFIG, DEVICE_DIMENSIONS, DeviceConfigOption, ExportConfig, ScreenshotStudioConfig } from './config/index.mjs';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Freehand Types
|
|
7
|
-
* Types for pointer-based freehand drawing on a canvas
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
10
|
-
* A single sampled point along a stroke, in CSS pixels relative to the canvas
|
|
11
|
-
*/
|
|
12
5
|
interface FreehandPoint {
|
|
13
6
|
x: number;
|
|
14
7
|
y: number;
|
|
15
|
-
/** Normalized stylus pressure (0–1). ~0.5 for mouse/touch when unsupported. */
|
|
16
8
|
pressure: number;
|
|
17
9
|
}
|
|
18
|
-
/**
|
|
19
|
-
* A completed (or in-progress) freehand stroke
|
|
20
|
-
*/
|
|
21
10
|
interface FreehandStroke {
|
|
22
|
-
/** Base brush size in CSS pixels. */
|
|
23
11
|
size: number;
|
|
24
|
-
/** CSS color of the stroke. */
|
|
25
12
|
color: string;
|
|
26
|
-
/** True when the stroke should vary width with per-point pressure. */
|
|
27
13
|
dynamic: boolean;
|
|
28
|
-
/** Ordered points from pointer-down to pointer-up. */
|
|
29
14
|
points: FreehandPoint[];
|
|
30
15
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Options for the useFreehandCanvas hook
|
|
33
|
-
*/
|
|
34
16
|
interface UseFreehandCanvasOptions {
|
|
35
|
-
/** Base brush size in CSS pixels. Default: 3 */
|
|
36
17
|
brushSize?: number;
|
|
37
|
-
/** Stroke color. Default: '#1a1a1a' */
|
|
38
18
|
color?: string;
|
|
39
|
-
/** Vary stroke width with stylus pressure (mouse/touch stay constant). Default: true */
|
|
40
19
|
pressureEnabled?: boolean;
|
|
41
|
-
|
|
20
|
+
keyboard?: boolean;
|
|
21
|
+
keyboardStep?: number;
|
|
42
22
|
onStrokeEnd?: (stroke: FreehandStroke) => void;
|
|
43
23
|
}
|
|
44
24
|
|
package/dist/domain/index.d.ts
CHANGED
|
@@ -2,43 +2,23 @@ export { A as AppStoreMetadata, B as BackgroundPattern, u as BackgroundPatternEn
|
|
|
2
2
|
export { C as CSVExportOptions, E as ExcelExportOptions, a as ExportFormat, P as PDFExportOptions, R as ReportData, b as ReportExportResult, c as ReportItem, d as ReportItemType, e as ReportSummary } from '../report.types-DWDnprBx.js';
|
|
3
3
|
export { AIConfig, DEFAULT_DEVICE_CONFIG, DEFAULT_SCREENSHOT_STUDIO_CONFIG, DEVICE_DIMENSIONS, DeviceConfigOption, ExportConfig, ScreenshotStudioConfig } from './config/index.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Freehand Types
|
|
7
|
-
* Types for pointer-based freehand drawing on a canvas
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
10
|
-
* A single sampled point along a stroke, in CSS pixels relative to the canvas
|
|
11
|
-
*/
|
|
12
5
|
interface FreehandPoint {
|
|
13
6
|
x: number;
|
|
14
7
|
y: number;
|
|
15
|
-
/** Normalized stylus pressure (0–1). ~0.5 for mouse/touch when unsupported. */
|
|
16
8
|
pressure: number;
|
|
17
9
|
}
|
|
18
|
-
/**
|
|
19
|
-
* A completed (or in-progress) freehand stroke
|
|
20
|
-
*/
|
|
21
10
|
interface FreehandStroke {
|
|
22
|
-
/** Base brush size in CSS pixels. */
|
|
23
11
|
size: number;
|
|
24
|
-
/** CSS color of the stroke. */
|
|
25
12
|
color: string;
|
|
26
|
-
/** True when the stroke should vary width with per-point pressure. */
|
|
27
13
|
dynamic: boolean;
|
|
28
|
-
/** Ordered points from pointer-down to pointer-up. */
|
|
29
14
|
points: FreehandPoint[];
|
|
30
15
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Options for the useFreehandCanvas hook
|
|
33
|
-
*/
|
|
34
16
|
interface UseFreehandCanvasOptions {
|
|
35
|
-
/** Base brush size in CSS pixels. Default: 3 */
|
|
36
17
|
brushSize?: number;
|
|
37
|
-
/** Stroke color. Default: '#1a1a1a' */
|
|
38
18
|
color?: string;
|
|
39
|
-
/** Vary stroke width with stylus pressure (mouse/touch stay constant). Default: true */
|
|
40
19
|
pressureEnabled?: boolean;
|
|
41
|
-
|
|
20
|
+
keyboard?: boolean;
|
|
21
|
+
keyboardStep?: number;
|
|
42
22
|
onStrokeEnd?: (stroke: FreehandStroke) => void;
|
|
43
23
|
}
|
|
44
24
|
|
package/dist/index.js
CHANGED
|
@@ -1806,19 +1806,28 @@ function useReportExport() {
|
|
|
1806
1806
|
var import_react4 = require("react");
|
|
1807
1807
|
var DEFAULT_BRUSH_SIZE = 3;
|
|
1808
1808
|
var DEFAULT_COLOR = "#1a1a1a";
|
|
1809
|
+
var DEFAULT_KEYBOARD_STEP = 4;
|
|
1810
|
+
var KEYBOARD_FAST_FACTOR = 4;
|
|
1809
1811
|
function widthFor(stroke, point) {
|
|
1810
1812
|
if (!stroke.dynamic) return stroke.size;
|
|
1811
1813
|
return stroke.size * (0.5 + Math.min(1, Math.max(0, point.pressure)));
|
|
1812
1814
|
}
|
|
1813
1815
|
function useFreehandCanvas(options = {}) {
|
|
1814
|
-
const {
|
|
1815
|
-
|
|
1816
|
-
|
|
1816
|
+
const {
|
|
1817
|
+
brushSize = DEFAULT_BRUSH_SIZE,
|
|
1818
|
+
color = DEFAULT_COLOR,
|
|
1819
|
+
pressureEnabled = true,
|
|
1820
|
+
keyboard = false,
|
|
1821
|
+
keyboardStep = DEFAULT_KEYBOARD_STEP
|
|
1822
|
+
} = options;
|
|
1823
|
+
const optionsRef = (0, import_react4.useRef)({ brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd });
|
|
1824
|
+
optionsRef.current = { brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd };
|
|
1817
1825
|
const canvasRef = (0, import_react4.useRef)(null);
|
|
1818
1826
|
const strokesRef = (0, import_react4.useRef)([]);
|
|
1819
1827
|
const activeStrokeRef = (0, import_react4.useRef)(null);
|
|
1820
1828
|
const activePointerIdRef = (0, import_react4.useRef)(null);
|
|
1821
1829
|
const dprRef = (0, import_react4.useRef)(1);
|
|
1830
|
+
const penRef = (0, import_react4.useRef)(null);
|
|
1822
1831
|
const [strokeCount, setStrokeCount] = (0, import_react4.useState)(0);
|
|
1823
1832
|
const redraw = (0, import_react4.useCallback)(() => {
|
|
1824
1833
|
const canvas = canvasRef.current;
|
|
@@ -1851,6 +1860,18 @@ function useFreehandCanvas(options = {}) {
|
|
|
1851
1860
|
ctx.stroke();
|
|
1852
1861
|
}
|
|
1853
1862
|
}
|
|
1863
|
+
const pen = penRef.current;
|
|
1864
|
+
if (pen?.engaged && !pen.down) {
|
|
1865
|
+
const reach = 5;
|
|
1866
|
+
ctx.strokeStyle = "rgba(35, 37, 43, 0.55)";
|
|
1867
|
+
ctx.lineWidth = 1;
|
|
1868
|
+
ctx.beginPath();
|
|
1869
|
+
ctx.moveTo(pen.x - reach, pen.y);
|
|
1870
|
+
ctx.lineTo(pen.x + reach, pen.y);
|
|
1871
|
+
ctx.moveTo(pen.x, pen.y - reach);
|
|
1872
|
+
ctx.lineTo(pen.x, pen.y + reach);
|
|
1873
|
+
ctx.stroke();
|
|
1874
|
+
}
|
|
1854
1875
|
}, []);
|
|
1855
1876
|
const syncDimensions = (0, import_react4.useCallback)(() => {
|
|
1856
1877
|
const canvas = canvasRef.current;
|
|
@@ -1869,6 +1890,8 @@ function useFreehandCanvas(options = {}) {
|
|
|
1869
1890
|
if (!canvas) return;
|
|
1870
1891
|
const el = canvas;
|
|
1871
1892
|
el.style.touchAction = "none";
|
|
1893
|
+
if (keyboard && !el.hasAttribute("tabindex")) el.tabIndex = 0;
|
|
1894
|
+
if (!penRef.current) penRef.current = { x: el.clientWidth / 2, y: el.clientHeight / 2, down: false, engaged: false };
|
|
1872
1895
|
syncDimensions();
|
|
1873
1896
|
redraw();
|
|
1874
1897
|
const observer = new ResizeObserver(() => {
|
|
@@ -1917,6 +1940,7 @@ function useFreehandCanvas(options = {}) {
|
|
|
1917
1940
|
points: [pointFrom(event)]
|
|
1918
1941
|
};
|
|
1919
1942
|
activeStrokeRef.current = stroke;
|
|
1943
|
+
penRef.current = { x: stroke.points[0].x, y: stroke.points[0].y, down: false, engaged: false };
|
|
1920
1944
|
drawDot(stroke, stroke.points[0]);
|
|
1921
1945
|
}
|
|
1922
1946
|
function onPointerMove(event) {
|
|
@@ -1940,18 +1964,81 @@ function useFreehandCanvas(options = {}) {
|
|
|
1940
1964
|
setStrokeCount(strokesRef.current.length);
|
|
1941
1965
|
optionsRef.current.onStrokeEnd?.(stroke);
|
|
1942
1966
|
}
|
|
1967
|
+
function clampPen(pen) {
|
|
1968
|
+
pen.x = Math.min(Math.max(pen.x, 0), Math.max(0, el.clientWidth - 1));
|
|
1969
|
+
pen.y = Math.min(Math.max(pen.y, 0), Math.max(0, el.clientHeight - 1));
|
|
1970
|
+
}
|
|
1971
|
+
function startKeyboardStroke(pen) {
|
|
1972
|
+
const { brushSize: size, color: ink } = optionsRef.current;
|
|
1973
|
+
const stroke = {
|
|
1974
|
+
size,
|
|
1975
|
+
color: ink,
|
|
1976
|
+
dynamic: false,
|
|
1977
|
+
points: [{ x: pen.x, y: pen.y, pressure: 0.5 }]
|
|
1978
|
+
};
|
|
1979
|
+
activeStrokeRef.current = stroke;
|
|
1980
|
+
pen.down = true;
|
|
1981
|
+
drawDot(stroke, stroke.points[0]);
|
|
1982
|
+
}
|
|
1983
|
+
function commitKeyboardStroke(pen) {
|
|
1984
|
+
const stroke = activeStrokeRef.current;
|
|
1985
|
+
if (!stroke) return;
|
|
1986
|
+
activeStrokeRef.current = null;
|
|
1987
|
+
pen.down = false;
|
|
1988
|
+
strokesRef.current = [...strokesRef.current, stroke];
|
|
1989
|
+
setStrokeCount(strokesRef.current.length);
|
|
1990
|
+
optionsRef.current.onStrokeEnd?.(stroke);
|
|
1991
|
+
redraw();
|
|
1992
|
+
}
|
|
1993
|
+
function onKeyDown(event) {
|
|
1994
|
+
const pen = penRef.current;
|
|
1995
|
+
if (!pen) return;
|
|
1996
|
+
const fast = event.shiftKey ? KEYBOARD_FAST_FACTOR : 1;
|
|
1997
|
+
const step = optionsRef.current.keyboardStep * fast;
|
|
1998
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
1999
|
+
event.preventDefault();
|
|
2000
|
+
pen.engaged = true;
|
|
2001
|
+
pen.x += event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
|
2002
|
+
pen.y += event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
|
2003
|
+
clampPen(pen);
|
|
2004
|
+
const stroke = activeStrokeRef.current;
|
|
2005
|
+
if (pen.down && stroke) {
|
|
2006
|
+
const point = { x: pen.x, y: pen.y, pressure: 0.5 };
|
|
2007
|
+
const previous = stroke.points[stroke.points.length - 1];
|
|
2008
|
+
stroke.points.push(point);
|
|
2009
|
+
drawSegment(stroke, previous, point);
|
|
2010
|
+
} else {
|
|
2011
|
+
redraw();
|
|
2012
|
+
}
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (event.key === " " || event.key === "Enter") {
|
|
2016
|
+
event.preventDefault();
|
|
2017
|
+
pen.engaged = true;
|
|
2018
|
+
if (pen.down) commitKeyboardStroke(pen);
|
|
2019
|
+
else startKeyboardStroke(pen);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (event.key === "Escape" && pen.down) {
|
|
2023
|
+
activeStrokeRef.current = null;
|
|
2024
|
+
pen.down = false;
|
|
2025
|
+
redraw();
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
1943
2028
|
el.addEventListener("pointerdown", onPointerDown);
|
|
1944
2029
|
el.addEventListener("pointermove", onPointerMove);
|
|
1945
2030
|
el.addEventListener("pointerup", commit);
|
|
1946
2031
|
el.addEventListener("pointercancel", commit);
|
|
2032
|
+
if (keyboard) el.addEventListener("keydown", onKeyDown);
|
|
1947
2033
|
return () => {
|
|
1948
2034
|
observer.disconnect();
|
|
1949
2035
|
el.removeEventListener("pointerdown", onPointerDown);
|
|
1950
2036
|
el.removeEventListener("pointermove", onPointerMove);
|
|
1951
2037
|
el.removeEventListener("pointerup", commit);
|
|
1952
2038
|
el.removeEventListener("pointercancel", commit);
|
|
2039
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
1953
2040
|
};
|
|
1954
|
-
}, [redraw, syncDimensions]);
|
|
2041
|
+
}, [redraw, syncDimensions, keyboard]);
|
|
1955
2042
|
const undo = (0, import_react4.useCallback)(() => {
|
|
1956
2043
|
if (strokesRef.current.length === 0) return;
|
|
1957
2044
|
strokesRef.current = strokesRef.current.slice(0, -1);
|
package/dist/index.mjs
CHANGED
|
@@ -106,40 +106,14 @@ interface UseReportExportReturn {
|
|
|
106
106
|
*/
|
|
107
107
|
declare function useReportExport(): UseReportExportReturn;
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
* useFreehandCanvas Hook
|
|
111
|
-
* React hook for pointer-based freehand drawing on a canvas
|
|
112
|
-
*/
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Return value of the useFreehandCanvas hook
|
|
116
|
-
*/
|
|
117
109
|
interface UseFreehandCanvasReturn {
|
|
118
|
-
/** Ref to attach to the <canvas> element that captures drawing. */
|
|
119
110
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
|
120
|
-
/** Remove the most recent stroke. */
|
|
121
111
|
undo: () => void;
|
|
122
|
-
/** Remove all strokes. */
|
|
123
112
|
clear: () => void;
|
|
124
|
-
/** Number of committed strokes. */
|
|
125
113
|
strokeCount: number;
|
|
126
|
-
/** True when no strokes are committed. */
|
|
127
114
|
isEmpty: boolean;
|
|
128
|
-
/** Snapshot of committed strokes (safe to serialize). */
|
|
129
115
|
getStrokes: () => FreehandStroke[];
|
|
130
116
|
}
|
|
131
|
-
/**
|
|
132
|
-
* useFreehandCanvas hook
|
|
133
|
-
*
|
|
134
|
-
* Attaches pointer-event drawing to a canvas element. The canvas is sized to
|
|
135
|
-
* its CSS box at device-pixel resolution (and re-rendered on resize), strokes
|
|
136
|
-
* are replayed from captured points so undo/clear are lossless, and stylus
|
|
137
|
-
* pressure modulates line width when the pointer reports it.
|
|
138
|
-
*
|
|
139
|
-
* The canvas gets `touch-action: none` applied so drawing works on touch
|
|
140
|
-
* screens without scrolling the page. Consumers typically layer the canvas
|
|
141
|
-
* over their own guide content (e.g. a faint glyph to trace).
|
|
142
|
-
*/
|
|
143
117
|
declare function useFreehandCanvas(options?: UseFreehandCanvasOptions): UseFreehandCanvasReturn;
|
|
144
118
|
|
|
145
119
|
export { type UseCanvasExportOptions, type UseCanvasExportReturn, type UseFreehandCanvasReturn, type UseReportExportReturn, type UseScreenshotStudioOptions, type UseScreenshotStudioReturn, useCanvasExport, useFreehandCanvas, useReportExport, useScreenshotStudio };
|
|
@@ -106,40 +106,14 @@ interface UseReportExportReturn {
|
|
|
106
106
|
*/
|
|
107
107
|
declare function useReportExport(): UseReportExportReturn;
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
* useFreehandCanvas Hook
|
|
111
|
-
* React hook for pointer-based freehand drawing on a canvas
|
|
112
|
-
*/
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Return value of the useFreehandCanvas hook
|
|
116
|
-
*/
|
|
117
109
|
interface UseFreehandCanvasReturn {
|
|
118
|
-
/** Ref to attach to the <canvas> element that captures drawing. */
|
|
119
110
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
|
120
|
-
/** Remove the most recent stroke. */
|
|
121
111
|
undo: () => void;
|
|
122
|
-
/** Remove all strokes. */
|
|
123
112
|
clear: () => void;
|
|
124
|
-
/** Number of committed strokes. */
|
|
125
113
|
strokeCount: number;
|
|
126
|
-
/** True when no strokes are committed. */
|
|
127
114
|
isEmpty: boolean;
|
|
128
|
-
/** Snapshot of committed strokes (safe to serialize). */
|
|
129
115
|
getStrokes: () => FreehandStroke[];
|
|
130
116
|
}
|
|
131
|
-
/**
|
|
132
|
-
* useFreehandCanvas hook
|
|
133
|
-
*
|
|
134
|
-
* Attaches pointer-event drawing to a canvas element. The canvas is sized to
|
|
135
|
-
* its CSS box at device-pixel resolution (and re-rendered on resize), strokes
|
|
136
|
-
* are replayed from captured points so undo/clear are lossless, and stylus
|
|
137
|
-
* pressure modulates line width when the pointer reports it.
|
|
138
|
-
*
|
|
139
|
-
* The canvas gets `touch-action: none` applied so drawing works on touch
|
|
140
|
-
* screens without scrolling the page. Consumers typically layer the canvas
|
|
141
|
-
* over their own guide content (e.g. a faint glyph to trace).
|
|
142
|
-
*/
|
|
143
117
|
declare function useFreehandCanvas(options?: UseFreehandCanvasOptions): UseFreehandCanvasReturn;
|
|
144
118
|
|
|
145
119
|
export { type UseCanvasExportOptions, type UseCanvasExportReturn, type UseFreehandCanvasReturn, type UseReportExportReturn, type UseScreenshotStudioOptions, type UseScreenshotStudioReturn, useCanvasExport, useFreehandCanvas, useReportExport, useScreenshotStudio };
|
|
@@ -1800,19 +1800,28 @@ function useReportExport() {
|
|
|
1800
1800
|
var import_react4 = require("react");
|
|
1801
1801
|
var DEFAULT_BRUSH_SIZE = 3;
|
|
1802
1802
|
var DEFAULT_COLOR = "#1a1a1a";
|
|
1803
|
+
var DEFAULT_KEYBOARD_STEP = 4;
|
|
1804
|
+
var KEYBOARD_FAST_FACTOR = 4;
|
|
1803
1805
|
function widthFor(stroke, point) {
|
|
1804
1806
|
if (!stroke.dynamic) return stroke.size;
|
|
1805
1807
|
return stroke.size * (0.5 + Math.min(1, Math.max(0, point.pressure)));
|
|
1806
1808
|
}
|
|
1807
1809
|
function useFreehandCanvas(options = {}) {
|
|
1808
|
-
const {
|
|
1809
|
-
|
|
1810
|
-
|
|
1810
|
+
const {
|
|
1811
|
+
brushSize = DEFAULT_BRUSH_SIZE,
|
|
1812
|
+
color = DEFAULT_COLOR,
|
|
1813
|
+
pressureEnabled = true,
|
|
1814
|
+
keyboard = false,
|
|
1815
|
+
keyboardStep = DEFAULT_KEYBOARD_STEP
|
|
1816
|
+
} = options;
|
|
1817
|
+
const optionsRef = (0, import_react4.useRef)({ brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd });
|
|
1818
|
+
optionsRef.current = { brushSize, color, pressureEnabled, keyboardStep, onStrokeEnd: options.onStrokeEnd };
|
|
1811
1819
|
const canvasRef = (0, import_react4.useRef)(null);
|
|
1812
1820
|
const strokesRef = (0, import_react4.useRef)([]);
|
|
1813
1821
|
const activeStrokeRef = (0, import_react4.useRef)(null);
|
|
1814
1822
|
const activePointerIdRef = (0, import_react4.useRef)(null);
|
|
1815
1823
|
const dprRef = (0, import_react4.useRef)(1);
|
|
1824
|
+
const penRef = (0, import_react4.useRef)(null);
|
|
1816
1825
|
const [strokeCount, setStrokeCount] = (0, import_react4.useState)(0);
|
|
1817
1826
|
const redraw = (0, import_react4.useCallback)(() => {
|
|
1818
1827
|
const canvas = canvasRef.current;
|
|
@@ -1845,6 +1854,18 @@ function useFreehandCanvas(options = {}) {
|
|
|
1845
1854
|
ctx.stroke();
|
|
1846
1855
|
}
|
|
1847
1856
|
}
|
|
1857
|
+
const pen = penRef.current;
|
|
1858
|
+
if (pen?.engaged && !pen.down) {
|
|
1859
|
+
const reach = 5;
|
|
1860
|
+
ctx.strokeStyle = "rgba(35, 37, 43, 0.55)";
|
|
1861
|
+
ctx.lineWidth = 1;
|
|
1862
|
+
ctx.beginPath();
|
|
1863
|
+
ctx.moveTo(pen.x - reach, pen.y);
|
|
1864
|
+
ctx.lineTo(pen.x + reach, pen.y);
|
|
1865
|
+
ctx.moveTo(pen.x, pen.y - reach);
|
|
1866
|
+
ctx.lineTo(pen.x, pen.y + reach);
|
|
1867
|
+
ctx.stroke();
|
|
1868
|
+
}
|
|
1848
1869
|
}, []);
|
|
1849
1870
|
const syncDimensions = (0, import_react4.useCallback)(() => {
|
|
1850
1871
|
const canvas = canvasRef.current;
|
|
@@ -1863,6 +1884,8 @@ function useFreehandCanvas(options = {}) {
|
|
|
1863
1884
|
if (!canvas) return;
|
|
1864
1885
|
const el = canvas;
|
|
1865
1886
|
el.style.touchAction = "none";
|
|
1887
|
+
if (keyboard && !el.hasAttribute("tabindex")) el.tabIndex = 0;
|
|
1888
|
+
if (!penRef.current) penRef.current = { x: el.clientWidth / 2, y: el.clientHeight / 2, down: false, engaged: false };
|
|
1866
1889
|
syncDimensions();
|
|
1867
1890
|
redraw();
|
|
1868
1891
|
const observer = new ResizeObserver(() => {
|
|
@@ -1911,6 +1934,7 @@ function useFreehandCanvas(options = {}) {
|
|
|
1911
1934
|
points: [pointFrom(event)]
|
|
1912
1935
|
};
|
|
1913
1936
|
activeStrokeRef.current = stroke;
|
|
1937
|
+
penRef.current = { x: stroke.points[0].x, y: stroke.points[0].y, down: false, engaged: false };
|
|
1914
1938
|
drawDot(stroke, stroke.points[0]);
|
|
1915
1939
|
}
|
|
1916
1940
|
function onPointerMove(event) {
|
|
@@ -1934,18 +1958,81 @@ function useFreehandCanvas(options = {}) {
|
|
|
1934
1958
|
setStrokeCount(strokesRef.current.length);
|
|
1935
1959
|
optionsRef.current.onStrokeEnd?.(stroke);
|
|
1936
1960
|
}
|
|
1961
|
+
function clampPen(pen) {
|
|
1962
|
+
pen.x = Math.min(Math.max(pen.x, 0), Math.max(0, el.clientWidth - 1));
|
|
1963
|
+
pen.y = Math.min(Math.max(pen.y, 0), Math.max(0, el.clientHeight - 1));
|
|
1964
|
+
}
|
|
1965
|
+
function startKeyboardStroke(pen) {
|
|
1966
|
+
const { brushSize: size, color: ink } = optionsRef.current;
|
|
1967
|
+
const stroke = {
|
|
1968
|
+
size,
|
|
1969
|
+
color: ink,
|
|
1970
|
+
dynamic: false,
|
|
1971
|
+
points: [{ x: pen.x, y: pen.y, pressure: 0.5 }]
|
|
1972
|
+
};
|
|
1973
|
+
activeStrokeRef.current = stroke;
|
|
1974
|
+
pen.down = true;
|
|
1975
|
+
drawDot(stroke, stroke.points[0]);
|
|
1976
|
+
}
|
|
1977
|
+
function commitKeyboardStroke(pen) {
|
|
1978
|
+
const stroke = activeStrokeRef.current;
|
|
1979
|
+
if (!stroke) return;
|
|
1980
|
+
activeStrokeRef.current = null;
|
|
1981
|
+
pen.down = false;
|
|
1982
|
+
strokesRef.current = [...strokesRef.current, stroke];
|
|
1983
|
+
setStrokeCount(strokesRef.current.length);
|
|
1984
|
+
optionsRef.current.onStrokeEnd?.(stroke);
|
|
1985
|
+
redraw();
|
|
1986
|
+
}
|
|
1987
|
+
function onKeyDown(event) {
|
|
1988
|
+
const pen = penRef.current;
|
|
1989
|
+
if (!pen) return;
|
|
1990
|
+
const fast = event.shiftKey ? KEYBOARD_FAST_FACTOR : 1;
|
|
1991
|
+
const step = optionsRef.current.keyboardStep * fast;
|
|
1992
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
1993
|
+
event.preventDefault();
|
|
1994
|
+
pen.engaged = true;
|
|
1995
|
+
pen.x += event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
|
1996
|
+
pen.y += event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
|
1997
|
+
clampPen(pen);
|
|
1998
|
+
const stroke = activeStrokeRef.current;
|
|
1999
|
+
if (pen.down && stroke) {
|
|
2000
|
+
const point = { x: pen.x, y: pen.y, pressure: 0.5 };
|
|
2001
|
+
const previous = stroke.points[stroke.points.length - 1];
|
|
2002
|
+
stroke.points.push(point);
|
|
2003
|
+
drawSegment(stroke, previous, point);
|
|
2004
|
+
} else {
|
|
2005
|
+
redraw();
|
|
2006
|
+
}
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (event.key === " " || event.key === "Enter") {
|
|
2010
|
+
event.preventDefault();
|
|
2011
|
+
pen.engaged = true;
|
|
2012
|
+
if (pen.down) commitKeyboardStroke(pen);
|
|
2013
|
+
else startKeyboardStroke(pen);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (event.key === "Escape" && pen.down) {
|
|
2017
|
+
activeStrokeRef.current = null;
|
|
2018
|
+
pen.down = false;
|
|
2019
|
+
redraw();
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
1937
2022
|
el.addEventListener("pointerdown", onPointerDown);
|
|
1938
2023
|
el.addEventListener("pointermove", onPointerMove);
|
|
1939
2024
|
el.addEventListener("pointerup", commit);
|
|
1940
2025
|
el.addEventListener("pointercancel", commit);
|
|
2026
|
+
if (keyboard) el.addEventListener("keydown", onKeyDown);
|
|
1941
2027
|
return () => {
|
|
1942
2028
|
observer.disconnect();
|
|
1943
2029
|
el.removeEventListener("pointerdown", onPointerDown);
|
|
1944
2030
|
el.removeEventListener("pointermove", onPointerMove);
|
|
1945
2031
|
el.removeEventListener("pointerup", commit);
|
|
1946
2032
|
el.removeEventListener("pointercancel", commit);
|
|
2033
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
1947
2034
|
};
|
|
1948
|
-
}, [redraw, syncDimensions]);
|
|
2035
|
+
}, [redraw, syncDimensions, keyboard]);
|
|
1949
2036
|
const undo = (0, import_react4.useCallback)(() => {
|
|
1950
2037
|
if (strokesRef.current.length === 0) return;
|
|
1951
2038
|
strokesRef.current = strokesRef.current.slice(0, -1);
|
package/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -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;
|