@onesy/ui-react
Version:
UI for React
1,171 lines • 57.7 kB
JavaScript
"use strict";
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = __importDefault(require("react"));
const utils_1 = require("@onesy/utils");
const style_react_1 = require("@onesy/style-react");
const date_1 = require("@onesy/date");
const Line_1 = __importDefault(require("../Line"));
const utils_2 = require("../utils");
const useStyle = (0, style_react_1.style)(theme => ({
root: {
position: 'relative'
},
canvas: {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
imageRendering: 'pixelated',
background: '#fff',
appearance: 'none',
border: 'none',
userSelect: 'none',
transition: theme.methods.transitions.make('opacity'),
'&[disabled]': {
opacity: 0.7,
pointerEvents: 'none'
}
},
ui: {
zIndex: 0
},
object: {
cursor: 'crosshair'
},
pen: {
cursor: 'crosshair'
},
pan: {
cursor: 'grab'
},
panning: {
cursor: 'grabbing'
},
zoom: {
cursor: 'zoom-in'
},
eraser: {
cursor: 'not-allowed'
},
image: {
cursor: 'copy'
},
text: {
cursor: 'text'
}
}), { name: 'onesy-Whiteboard' });
const colorSelect = 'hsl(244deg 64% 64%)';
const colorSelectBackground = 'hsla(244deg 64% 64% / 4%)';
const Whiteboard = react_1.default.forwardRef((props_, ref) => {
const theme = (0, style_react_1.useOnesyTheme)();
const props = react_1.default.useMemo(() => { var _a, _b, _c, _d, _e, _f, _g, _h; return (Object.assign(Object.assign(Object.assign({}, (_d = (_c = (_b = (_a = theme === null || theme === void 0 ? void 0 : theme.ui) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b.all) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.default), (_h = (_g = (_f = (_e = theme === null || theme === void 0 ? void 0 : theme.ui) === null || _e === void 0 ? void 0 : _e.elements) === null || _f === void 0 ? void 0 : _f.onesyWhiteboard) === null || _g === void 0 ? void 0 : _g.props) === null || _h === void 0 ? void 0 : _h.default), props_)); }, [props_]);
const Line = react_1.default.useMemo(() => { var _a; return ((_a = theme === null || theme === void 0 ? void 0 : theme.elements) === null || _a === void 0 ? void 0 : _a.Line) || Line_1.default; }, [theme]);
const { valueDefault, onChange: onChangeProps,
// 10%
minZoom = 10,
// 400%
maxZoom = 4000, grid: gridProps = true, settings = {
lineCap: 'round',
lineJoin: 'round',
lineWidth: 10,
fillStyle: 'lightgreen',
strokeStyle: 'lightgreen',
globalAlpha: 0.44
}, className } = props, other = __rest(props, ["valueDefault", "onChange", "minZoom", "maxZoom", "grid", "settings", "className"]);
const { classes } = useStyle();
const [size, setSize] = react_1.default.useState({});
const [tool, setTool] = react_1.default.useState('select');
const [mouseDown, setMouseDown] = react_1.default.useState(false);
const [grid, setGrid] = react_1.default.useState(gridProps);
const [loaded, setLoaded] = react_1.default.useState(false);
const refs = {
root: react_1.default.useRef(null),
ui: react_1.default.useRef(null),
interactive: react_1.default.useRef(null),
on: react_1.default.useRef(false),
items: react_1.default.useRef(valueDefault || []),
previous: react_1.default.useRef({ x: 0, y: 0 }),
previousMouse: react_1.default.useRef({ x: 0, y: 0 }),
moveStarted: react_1.default.useRef(false),
undo: react_1.default.useRef([]),
redo: react_1.default.useRef([]),
move: react_1.default.useRef({ x: 0, y: 0 }),
offset: react_1.default.useRef({ x: 0, y: 0 }),
start: react_1.default.useRef({ x: 0, y: 0 }),
end: react_1.default.useRef({ x: 0, y: 0 }),
scale: react_1.default.useRef(1),
mouseDown: react_1.default.useRef(mouseDown),
mouseMove: react_1.default.useRef({
current: { x: 0, y: 0 },
previous: undefined,
delta: { x: 0, y: 0 }
}),
tool: react_1.default.useRef(tool),
previousTool: react_1.default.useRef(tool),
toolUpdateAuto: react_1.default.useRef(false),
remove: react_1.default.useRef([]),
grid: react_1.default.useRef(grid),
typing: react_1.default.useRef(false),
image: react_1.default.useRef((0, utils_1.isEnvironment)('browser') && new Image()),
aspectRatio: react_1.default.useRef(1),
select: react_1.default.useRef(null),
textActive: react_1.default.useRef(null),
textSettings: react_1.default.useRef({
lineHeight: 20,
padding: 5,
fillStyle: 'black'
})
};
refs.mouseDown.current = mouseDown;
refs.tool.current = tool;
refs.grid.current = grid;
const init = react_1.default.useCallback(() => {
// Todo
// items
// load all of the images in memory and attach theme to items as image elements
// once it's all done, setLoaded(true), render
setTimeout(() => {
render();
setLoaded(true);
}, 40);
}, []);
react_1.default.useEffect(() => {
if (!['zoom'].includes(tool))
refs.previousTool.current = tool;
}, [tool]);
const onChange = react_1.default.useCallback(() => {
if ((0, utils_1.is)('function', onChangeProps))
onChangeProps(refs.items.current);
}, [onChangeProps]);
const getItems = react_1.default.useCallback((selected = undefined) => refs.items.current.filter(item => selected === undefined || item.se === selected), []);
const getItem = react_1.default.useCallback(() => refs.items.current[refs.items.current.length - 1], []);
const filterItems = react_1.default.useCallback(() => {
const toRemove = refs.items.current.filter(item => {
var _a;
if (refs.tool.current === 'text' && item !== refs.textActive.current)
item.se = false;
const lines = ((_a = item.s) === null || _a === void 0 ? void 0 : _a.lines) || [];
return !(item.v !== 't' || (item === refs.textActive.current || (lines.length > 1 || lines[0].length)));
});
if (toRemove.length)
remove(toRemove);
}, []);
const add = react_1.default.useCallback((toAdd) => {
const itemsAdd = (Array.isArray(toAdd) ? toAdd : [toAdd]).filter(Boolean);
const items = getItems();
// add to undo stack snapshot of current state
refs.undo.current.push([...items]);
// clear redo stack
refs.redo.current = [];
refs.items.current.push(...itemsAdd);
}, []);
const remove = react_1.default.useCallback((toRemove) => {
const itemsRemove = (Array.isArray(toRemove) ? toRemove : [toRemove]).filter(Boolean);
const items = getItems();
const IDs = itemsRemove === null || itemsRemove === void 0 ? void 0 : itemsRemove.map(item => item.i);
const toRemoveIDs = items.filter(item => IDs.includes(item.i)).map(item => item.i);
if (!toRemoveIDs)
return;
// add to undo stack snapshot of current state
refs.undo.current.push([...items]);
// clear redo stack
refs.redo.current = [];
refs.items.current = refs.items.current.filter(item => !toRemoveIDs.includes(item.i));
}, []);
const reset = react_1.default.useCallback(() => {
refs.on.current = false;
refs.moveStarted.current = false;
refs.image.current = null;
// new move start
refs.offset.current.x = refs.move.current.x;
refs.offset.current.y = refs.move.current.y;
}, []);
const transform = react_1.default.useCallback((coordinate) => coordinate / refs.scale.current, []);
const selectAll = react_1.default.useCallback(() => {
return [...refs.items.current].filter(Boolean).map(item => {
item.se = true;
return item;
});
}, []);
const unselectAll = react_1.default.useCallback((eventReact) => {
const event = (eventReact === null || eventReact === void 0 ? void 0 : eventReact.nativeEvent) || eventReact;
const shift = event === null || event === void 0 ? void 0 : event.shiftKey;
return [...refs.items.current].filter(Boolean).map(item => {
if (shift)
return item;
item.se = false;
return item;
});
}, []);
const onInteractionDown = react_1.default.useCallback((body, eventReact) => {
var _a, _b;
const event = (eventReact === null || eventReact === void 0 ? void 0 : eventReact.nativeEvent) || eventReact;
const { offsetX: x, offsetY: y, clientX, clientY } = body;
refs.on.current = true;
refs.previous.current = { x, y };
refs.mouseMove.current = {
current: { x: 0, y: 0 },
previous: undefined,
delta: { x: 0, y: 0 }
};
const shift = event === null || event === void 0 ? void 0 : event.shiftKey;
const ui = refs.ui.current.getContext('2d');
const rect = refs.ui.current.getBoundingClientRect();
refs.start.current.x = clientX - rect.left;
refs.start.current.y = clientY - rect.top;
refs.textActive.current = null;
const start = refs.start.current;
const startTransformed = {
x: transform(start.x - refs.move.current.x),
y: transform(start.y - refs.move.current.y)
};
Object.keys(settings).forEach(item_ => ui[item_] = settings[item_]);
let item;
const items = getItems();
const t = refs.tool.current;
refs.select.current = null;
if (t === 'select') {
// z-index top to bottom order
const itemsReversed = [...items].reverse();
const itemsClicked = itemsReversed.filter(item_ => {
return item_.c && startTransformed.x >= item_.c[0] && startTransformed.x <= item_.c[0] + item_.c[2] && startTransformed.y >= item_.c[1] && startTransformed.y <= item_.c[1] + item_.c[3];
});
const itemsSelected = getItems(true);
const clicked = itemsClicked[0];
if (!clicked) {
unselectAll();
refs.select.current = { p: [], ar: [] };
}
if (!shift && !((clicked === null || clicked === void 0 ? void 0 : clicked.se) && itemsSelected.length > 1))
unselectAll();
if (clicked) {
clicked.se = shift ? !clicked.se : true;
}
}
else if (t === 'text') {
const { lineHeight, padding } = refs.textSettings.current;
const selectedText = items.find(item_ => item_.c && startTransformed.x >= item_.c[0] && startTransformed.x <= item_.c[0] + item_.c[2] && startTransformed.y >= item_.c[1] && startTransformed.y <= item_.c[1] + item_.c[3]);
if (selectedText) {
refs.textActive.current = selectedText;
selectedText.se = true;
const relativeY = startTransformed.y - selectedText.c[1] - padding;
const lineIndex = Math.floor(relativeY / lineHeight);
const clickedLine = ((_b = (_a = selectedText.s) === null || _a === void 0 ? void 0 : _a.lines) === null || _b === void 0 ? void 0 : _b[lineIndex]) || '';
const relativeX = startTransformed.x - selectedText.c[0] - padding;
let charIndex = clickedLine.length;
for (let i = 0; i < clickedLine.length; i++) {
if (relativeX < ui.measureText(clickedLine.slice(0, i + 1)).width) {
charIndex = i;
break;
}
}
selectedText.s.cursor = { line: lineIndex, char: charIndex };
}
else {
item = {
i: (0, utils_1.getID)(),
v: 't',
p: [startTransformed.x, startTransformed.y],
ar: [15, lineHeight + (padding * 2)],
s: Object.assign(Object.assign({}, refs.textSettings.current), { lines: [''], cursor: { line: 0, char: 0 } }),
se: true,
a: date_1.OnesyDate.milliseconds
};
refs.textActive.current = item;
}
}
else {
// pen
if (t === 'pen') {
// point
item = {
i: (0, utils_1.getID)(),
v: 'dp',
p: [transform(x - refs.move.current.x), transform(y - refs.move.current.y)],
ar: [ui.lineWidth / 2, 0, Math.PI * 2],
s: (0, utils_1.copy)(settings),
a: date_1.OnesyDate.milliseconds
};
}
// circle, rectangle, line, line-arrow
if (['circle', 'rectangle', 'triangle', 'line', 'line-arrow'].includes(t)) {
item = {
i: (0, utils_1.getID)(),
p: [],
ar: [],
s: (0, utils_1.copy)(settings),
a: date_1.OnesyDate.milliseconds
};
}
// pan
if (t === 'pan') {
// new move start
refs.offset.current.x = refs.move.current.x;
refs.offset.current.y = refs.move.current.y;
}
// image
else if (t === 'image' && refs.image.current.complete && refs.image.current.src) {
refs.aspectRatio.current = refs.image.current.width / refs.image.current.height;
// Todo
// add url of the image
// instead of embeding the image
item = {
i: (0, utils_1.getID)(),
v: 'i',
p: [],
ar: [],
s: {
// Todo
// remove in the future
image: refs.image.current,
aspectRatio: refs.aspectRatio.current
},
a: date_1.OnesyDate.milliseconds
};
}
}
if (item)
add(item);
filterItems();
// render
render();
setMouseDown(true);
}, []);
const onMouseDown = react_1.default.useCallback((event) => {
const { offsetX, offsetY, clientX, clientY } = event.nativeEvent;
onInteractionDown({ offsetX, offsetY, clientX, clientY }, event);
}, [onInteractionDown]);
const onTouchStart = react_1.default.useCallback((event) => {
// Get the first touch point
const touch = event.touches[0];
const { clientX, clientY } = touch;
let { offsetX, offsetY } = touch;
const targetElement = touch.target;
if (targetElement instanceof HTMLElement) {
// Get the bounding rectangle of the target element
const rect = targetElement.getBoundingClientRect();
// Calculate the offsetX and offsetY
offsetX = touch.clientX - rect.left;
offsetY = touch.clientY - rect.top;
}
onInteractionDown({ offsetX, offsetY, clientX, clientY }, event);
}, [onInteractionDown]);
const removeItems = react_1.default.useCallback(() => {
// remove
if (refs.remove.current.length) {
const toRemove = [];
for (const item of refs.remove.current) {
const index = refs.items.current.findIndex(itemItems => itemItems === item);
if (index > -1)
toRemove.push(item);
}
if (toRemove.length)
remove(toRemove);
refs.remove.current = [];
}
}, []);
const onUpdateCoordinates = react_1.default.useCallback(() => {
const items = getItems();
items.forEach(item => {
const p = (item === null || item === void 0 ? void 0 : item.p) || [];
const ar = (item === null || item === void 0 ? void 0 : item.ar) || [];
const s = (item === null || item === void 0 ? void 0 : item.s) || {};
if (p.length) {
// cache
// x1, y1, width. height for position on the screen
const v = item.v;
// draw point
if (v === 'dp') {
const lineWidth = s.lineWidth || 10;
item.c = [
p[0] - (lineWidth / 2),
p[1] - (lineWidth / 2),
lineWidth,
lineWidth
];
}
// draw line
else if (v === 'dl') {
const x = [];
const y = [];
for (let i = 0; i < p.length; i += 2) {
x.push(p[i]);
y.push(p[i + 1]);
}
const xMin = Math.min(...x);
const yMin = Math.min(...y);
const xMax = Math.max(...x);
const yMax = Math.max(...y);
item.c = [
xMin,
yMin,
xMax - xMin,
yMax - yMin
];
}
// object line, object arrow
else if (['ol', 'oa'].includes(v)) {
const x = [p[0], ar[0]];
const y = [p[1], ar[1]];
const xMin = Math.min(...x);
const yMin = Math.min(...y);
const xMax = Math.max(...x);
const yMax = Math.max(...y);
item.c = [
xMin,
yMin,
xMax - xMin,
yMax - yMin
];
}
// object rectangle, object square
else if (['or', 'os'].includes(v)) {
item.c = [
...p,
...ar
];
}
// object circle, object ellipse
else if (['oc', 'oe'].includes(v)) {
if (v === 'oc') {
item.c = [
p[0] - ar[0],
p[1] - ar[0],
ar[0] * 2,
ar[0] * 2
];
}
else {
item.c = [
p[0] - ar[0],
p[1] - ar[1],
ar[0] * 2,
ar[1] * 2
];
}
}
// object triangle, object triangle equilateral
else if (['ot', 'ote'].includes(v)) {
const [x1, y1, x2] = p;
const { height } = s;
item.c = [
x1,
y1 - height,
x2 - x1,
height
];
}
// image
else if (['i', 't'].includes(v)) {
item.c = [
...p,
...ar
];
}
}
});
}, []);
const onSelect = react_1.default.useCallback(() => {
const select = refs.select.current;
if (!select)
return;
const px1 = select.p[0];
const px2 = px1 + select.ar[0];
const py1 = select.p[1];
const py2 = py1 + select.ar[1];
const min = {
x: Math.min(px1, px2),
y: Math.min(py1, py2)
};
const max = {
x: Math.max(px1, px2),
y: Math.max(py1, py2)
};
const items = getItems();
items.forEach(item => {
const { c } = item;
const [x1, y1] = c;
let [x2, y2] = c;
x2 = x1 + x2;
y2 = y1 + y2;
const minItem = {
x: Math.min(x1, x2),
y: Math.min(y1, y2)
};
const maxItem = {
x: Math.max(x1, x2),
y: Math.max(y1, y2)
};
const selected = (minItem.x >= min.x && maxItem.x <= max.x) && (minItem.y >= min.y && maxItem.y <= max.y);
if (selected)
item.se = true;
});
}, []);
const onMouseUp = react_1.default.useCallback((event) => {
if (refs.mouseDown.current) {
refs.select.current = null;
// update coordinates
onUpdateCoordinates();
// reset
reset();
// remove
removeItems();
console.log('items', refs.items.current);
// onChange
onChange();
setMouseDown(false);
if (['circle', 'rectangle', 'triangle', 'line', 'line-arrow', 'image'].includes(refs.previousTool.current)) {
setTool('select');
}
render();
}
}, [onChange]);
const updateTextBoxDimensions = react_1.default.useCallback((item) => {
const ui = refs.ui.current.getContext('2d');
const { lineHeight, padding } = refs.textSettings.current;
ui.font = '16px Arial';
const maxWidth = Math.max(...item.s.lines.map(line => ui.measureText(line).width));
item.ar[0] = maxWidth + padding * 2.5;
item.ar[1] = item.s.lines.length * lineHeight + padding * 2;
}, []);
const getPath = react_1.default.useCallback((item) => {
const path = new Path2D();
const { v, p, ar } = item;
// draw line
if (v === 'dl') {
const points = (0, utils_1.arrayToParts)(p, 2);
for (let i = 0; i < points.length - 1; i++) {
const current = points[i];
const next = points[i + 1];
// calculate the control point for the curve
const midX = (current[0] + next[0]) / 2;
const midY = (current[1] + next[1]) / 2;
if (i === 0) {
// start from the first point
path.moveTo(current[0], current[1]);
}
path.quadraticCurveTo(current[0], current[1], midX, midY);
}
}
// draw point, object circle
else if (['dp', 'oc'].includes(v) && ar.length === 3) {
path.arc(p[0], p[1], ...ar);
}
// object ellipse
else if (v === 'oe' && ar.length === 5) {
path.ellipse(p[0], p[1], ...ar);
}
// object rectangle
else if (['or', 'os'].includes(v)) {
path.roundRect(p[0], p[1], ...ar);
}
// object line
else if (['ol', 'oa'].includes(v) && ar.length === 2) {
path.moveTo(p[0], p[1]);
path.lineTo(...ar);
// draw an arrow
if (v === 'oa') {
// Length of the arrowhead
const headLength = 40;
const angle = Math.atan2(ar[1] - p[1], ar[0] - p[0]);
path.moveTo(ar[0], ar[1]);
path.lineTo(ar[0] - headLength * Math.cos(angle - Math.PI / 6), ar[1] - headLength * Math.sin(angle - Math.PI / 6));
path.moveTo(ar[0], ar[1]);
path.lineTo(ar[0] - headLength * Math.cos(angle + Math.PI / 6), ar[1] - headLength * Math.sin(angle + Math.PI / 6));
}
}
// object triangle
else if (['ot', 'ote'].includes(v)) {
path.moveTo(p[0], p[1]);
path.lineTo(p[2], p[3]);
path.lineTo(p[4], p[5]);
path.closePath();
}
return path;
}, []);
const draw = react_1.default.useCallback((item) => {
var _a, _b;
const ui = refs.ui.current.getContext('2d');
// settings
Object.keys(item.s || {}).forEach(key => { var _a; return ui[key] = (_a = item.s) === null || _a === void 0 ? void 0 : _a[key]; });
ui.globalAlpha = refs.remove.current.includes(item) ? 0.25 : ((_a = item.s) === null || _a === void 0 ? void 0 : _a.globalAlpha) !== undefined ? (_b = item.s) === null || _b === void 0 ? void 0 : _b.globalAlpha : 1;
ui.beginPath();
const path = getPath(item);
const v = item.v;
if (['dp'].includes(v))
ui.fill(path);
else if (['dl', 'oc', 'oe', 'or', 'os', 'ol', 'oa', 'ot', 'ote'].includes(v))
ui.stroke(path);
}, []);
const drawGrid = react_1.default.useCallback(() => {
const uiCanvas = refs.ui.current;
const ui = refs.ui.current.getContext('2d');
const zoom = refs.scale.current;
const gridSize = 70;
const offsetX = refs.move.current.x / zoom;
const offsetY = refs.move.current.y / zoom;
// Calculate start positions based on offsets
const startX = Math.floor(-offsetX / gridSize) * gridSize;
const startY = Math.floor(-offsetY / gridSize) * gridSize;
const width = (uiCanvas.clientWidth * 1.5) / (zoom < 1 ? zoom : 1);
const height = (uiCanvas.clientHeight * 1.5) / (zoom < 1 ? zoom : 1);
if (gridSize < 30)
return;
// Draw main grid lines
ui.globalAlpha = 1;
ui.lineWidth = (zoom < 0.5 ? 0.3 : zoom <= 1 ? 0.5 : 0.7) / zoom;
ui.strokeStyle = '#ccc';
// grid
for (let x = startX; x < width + Math.abs(startX); x += gridSize) {
ui.beginPath();
ui.moveTo(x, startY);
ui.lineTo(x, height + startY);
ui.stroke();
}
for (let y = startY; y < height + Math.abs(startY); y += gridSize) {
ui.beginPath();
ui.moveTo(startX, y);
ui.lineTo(width + startX, y);
ui.stroke();
}
// subgrid
if (gridSize * zoom > 100) {
// Draw subgrid lines if zoomed in
const subGridSize = gridSize / 5;
ui.lineWidth = (zoom <= 5 ? 0.6 : zoom <= 10 ? 0.8 : 1) / zoom;
ui.strokeStyle = '#ddd';
const dash = zoom < 1 ? 3 * zoom : 3 / zoom;
ui.setLineDash([dash, dash]);
for (let x = startX; x < width + Math.abs(startX); x += subGridSize) {
// without overlap
if (!(x % gridSize))
continue;
ui.beginPath();
ui.moveTo(x, startY);
ui.lineTo(x, height + startY);
ui.stroke();
}
for (let y = startY; y < height + Math.abs(startY); y += subGridSize) {
// without overlap
if (!(y % gridSize))
continue;
ui.beginPath();
ui.moveTo(startX, y);
ui.lineTo(width + startX, y);
ui.stroke();
}
ui.setLineDash([]); // Reset line dash
}
}, []);
const drawImage = react_1.default.useCallback((item) => {
const ui = refs.ui.current.getContext('2d');
ui.globalAlpha = 1;
ui.drawImage(item.s.image || refs.image.current, ...item.p, ...item.ar);
}, []);
const drawCursor = react_1.default.useCallback((item) => {
if (!item || !item.s.cursor)
return;
const ui = refs.ui.current.getContext('2d');
const { line, char } = item.s.cursor;
const currentLine = item.s.lines[line] || '';
const textWidth = ui.measureText(currentLine.slice(0, char)).width;
const { padding, lineHeight } = refs.textSettings.current;
const cursorX = item.p[0] + padding + textWidth;
const cursorY = item.p[1] + padding + (line + 1) * lineHeight;
ui.fillStyle = 'black';
ui.fillRect(cursorX, cursorY - lineHeight + 3, 2, lineHeight - 5);
}, []);
const drawText = react_1.default.useCallback((item) => {
const ui = refs.ui.current.getContext('2d');
const zoom = refs.scale.current;
const [x, y] = item.p;
const [width, height] = item.ar;
const { lineHeight, padding, fillStyle } = refs.textSettings.current;
const selected = (refs.tool.current === 'text' && item.se);
// Draw the box
ui.globalAlpha = 1;
ui.fillStyle = 'transparent';
ui.fillRect(x, y, width, height);
ui.lineWidth = 1 / zoom;
ui.strokeStyle = selected ? colorSelect : 'transparent';
ui.strokeRect(x, y, width, height);
// Draw the text
ui.fillStyle = fillStyle || 'black';
ui.font = '16px Arial';
item.s.lines.forEach((line, index) => {
ui.fillText(line, x + padding, y + padding + (index + 1) * lineHeight - 5);
});
if (selected)
drawCursor(item);
}, []);
const drawSelect = react_1.default.useCallback((item) => {
const ui = refs.ui.current.getContext('2d');
const [x, y, width, height] = item.c || [];
const path = new Path2D();
path.rect(x, y, width, height);
ui.globalAlpha = 1;
ui.strokeStyle = colorSelect;
ui.lineCap = 'square';
ui.lineJoin = 'bevel';
ui.lineWidth = 1 / refs.scale.current;
ui.stroke(path);
}, []);
const drawSelection = react_1.default.useCallback(() => {
const ui = refs.ui.current.getContext('2d');
const zoom = refs.scale.current;
// canvas selection
const select = refs.select.current;
if (select) {
ui.globalAlpha = 1;
ui.globalCompositeOperation = 'source-over';
ui.lineWidth = 1 / zoom;
ui.lineCap = 'square';
ui.lineJoin = 'bevel';
ui.strokeStyle = colorSelect;
ui.fillStyle = colorSelectBackground;
const path = getPath(select);
ui.fill(path);
ui.stroke(path);
}
}, []);
const render = react_1.default.useCallback(() => {
const ui = refs.ui.current.getContext('2d');
const items = refs.items.current.filter(Boolean);
// methods
ui.clearRect(0, 0, refs.ui.current.width, refs.ui.current.height);
ui.save();
// pan
ui.translate(refs.move.current.x, refs.move.current.y);
// zoom
ui.scale(refs.scale.current, refs.scale.current);
// grid
if (refs.grid.current)
drawGrid();
// draw
items.forEach(item => {
// image
if (item.v === 'i' && item.ar.length === 2)
drawImage(item);
// text
else if (item.v === 't')
drawText(item);
// other
else
draw(item);
// select
if (item.se)
drawSelect(item);
});
// canvas selection
drawSelection();
ui.restore();
}, []);
// Snap angle to nearest multiple of 15 degrees
const snapToAngle = react_1.default.useCallback((dx, dy) => {
// Current angle in radians
const angle = Math.atan2(dy, dx);
// Snap to nearest 15 degrees
const snappedAngle = Math.round(angle / (Math.PI / 12)) * (Math.PI / 12);
// Length of the vector
const length = Math.sqrt(dx * dx + dy * dy);
return {
x: Math.cos(snappedAngle) * length,
y: Math.sin(snappedAngle) * length
};
}, []);
const onMoveItems = react_1.default.useCallback((x, y) => {
const itemsSelected = getItems(true);
itemsSelected.forEach(item => {
const v = item.v;
// draw line
if (v === 'dl') {
item.p = item.p.map((value, index) => {
return index % 2 ? value + y : value + x;
});
}
// rectangle, draw point, object circle, ellipse, object line, object arrow, object triangle, image, text
if (['or', 'os', 'dp', 'oc', 'oe', 'ol', 'oa', 'ot', 'ote', 'i', 't'].includes(v)) {
item.p[0] += x;
item.p[1] += y;
}
// object line
if (['ol', 'oa'].includes(v)) {
item.ar[0] += x;
item.ar[1] += y;
}
// object triangle
if (['ot', 'ote'].includes(v)) {
item.p[2] += x;
item.p[4] += x;
item.p[3] += y;
item.p[5] += y;
}
});
onUpdateCoordinates();
}, []);
const onMove = react_1.default.useCallback((body, event) => {
if (!refs.on.current)
return;
const { offsetX: x, offsetY: y, clientX, clientY } = body;
const xo = transform(x - refs.move.current.x);
const yo = transform(y - refs.move.current.y);
const ui = refs.ui.current.getContext('2d');
const rect = refs.ui.current.getBoundingClientRect();
const currentX = clientX - rect.left;
const currentY = clientY - rect.top;
const start = refs.start.current;
const startTransformed = {
x: transform(start.x - refs.move.current.x),
y: transform(start.y - refs.move.current.y)
};
const item = getItem();
const items = getItems();
const t = refs.tool.current;
const shiftKey = event.shiftKey;
const zoom = refs.scale.current;
refs.mouseMove.current.current = { x: clientX / zoom, y: clientY / zoom };
refs.mouseMove.current.delta = { x: refs.mouseMove.current.previous ? refs.mouseMove.current.current.x - refs.mouseMove.current.previous.x : 0, y: refs.mouseMove.current.previous ? refs.mouseMove.current.current.y - refs.mouseMove.current.previous.y : 0 };
refs.mouseMove.current.previous = Object.assign({}, refs.mouseMove.current.current);
const delta = refs.mouseMove.current.delta;
// select
if (t === 'select') {
if (!refs.moveStarted.current)
refs.moveStarted.current = true;
if (refs.select.current) {
unselectAll();
const width = currentX - start.x;
const height = currentY - start.y;
const isSquare = shiftKey;
const radius = 0;
if (isSquare) {
const side = Math.min(Math.abs(width), Math.abs(height));
refs.select.current.v = 'os';
refs.select.current.p = [startTransformed.x, startTransformed.y];
refs.select.current.ar = [transform(Math.sign(width) * side), transform(Math.sign(height) * side), radius];
}
else {
refs.select.current.v = 'or';
refs.select.current.p = [startTransformed.x, startTransformed.y];
refs.select.current.ar = [transform(width), transform(height), radius];
}
}
else
onMoveItems(delta.x, delta.y);
}
// pen
else if (t === 'pen' && item) {
// same path from draw point, to move
if (!refs.moveStarted.current) {
item.v = 'dl';
refs.moveStarted.current = true;
}
// Add the current point to the path
item.p.push(xo, yo);
}
// pan
else if (t === 'pan') {
refs.move.current.x = x - refs.previous.current.x + refs.offset.current.x;
refs.move.current.y = y - refs.previous.current.y + refs.offset.current.y;
}
// eraser
else if (t === 'eraser') {
// find all items that x, y collides with, with certain radius
for (const i of items) {
const isPointInStroke = ui.isPointInStroke(getPath(i), xo, yo);
if (isPointInStroke)
refs.remove.current.push(i);
}
}
// object line, object arrow
else if (['line', 'line-arrow'].includes(t)) {
const snapAt15Degrees = shiftKey;
let endX = currentX;
let endY = currentY;
if (snapAt15Degrees) {
const snapped = snapToAngle(currentX - start.x, currentY - start.y);
endX = start.x + snapped.x;
endY = start.y + snapped.y;
}
item.v = t === 'line' ? 'ol' : 'oa';
item.p = [startTransformed.x, startTransformed.y];
item.ar = [transform(endX - refs.move.current.x), transform(endY - refs.move.current.y)];
}
// object circle
else if (t === 'circle') {
const width = currentX - start.x;
const height = currentY - start.y;
const isCircle = shiftKey;
if (isCircle) {
const radius = Math.min(Math.abs(width), Math.abs(height)) / 2;
const centerX = start.x + Math.sign(width) * radius;
const centerY = start.y + Math.sign(height) * radius;
item.v = 'oc';
item.p = [transform(centerX - refs.move.current.x), transform(centerY - refs.move.current.y)];
item.ar = [transform(radius), 0, Math.PI * 2];
}
else {
item.v = 'oe';
item.p = [transform((start.x + width / 2) - refs.move.current.x), transform((start.y + height / 2) - refs.move.current.y)];
item.ar = [transform(Math.abs(width) / 2), transform(Math.abs(height) / 2), 0, 0, Math.PI * 2];
}
}
// object rectangle
else if (t === 'rectangle') {
const width = currentX - start.x;
const height = currentY - start.y;
const isSquare = shiftKey;
const radius = 0;
if (isSquare) {
const side = Math.min(Math.abs(width), Math.abs(height));
item.v = 'os';
item.p = [startTransformed.x, startTransformed.y];
item.ar = [transform(Math.sign(width) * side), transform(Math.sign(height) * side), radius];
}
else {
item.v = 'or';
item.p = [startTransformed.x, startTransformed.y];
item.ar = [transform(width), transform(height), radius];
}
}
// object triangle
else if (['triangle'].includes(t)) {
const endX = xo;
const endY = yo;
const base = Math.abs(endX - startTransformed.x);
const height = shiftKey ? base * Math.sqrt(3) / 2 : Math.abs(endY - startTransformed.y);
const points = [startTransformed.x, startTransformed.y, endX, startTransformed.y, startTransformed.x + (endX - startTransformed.x) / 2, startTransformed.y - height];
item.v = shiftKey ? 'ote' : 'ot';
item.p = points;
item.s = Object.assign(Object.assign({}, item.s), { height });
}
// image
else if (t === 'image' && (refs.image.current.complete && refs.image.current.src)) {
const width = transform(currentX - start.x);
const height = transform(currentY - start.y);
const keepAspectRatio = !shiftKey;
let currentWidth;
let currentHeight;
if (keepAspectRatio) {
if (Math.abs(width / refs.aspectRatio.current) <= Math.abs(height)) {
currentWidth = width;
currentHeight = width / refs.aspectRatio.current;
}
else {
currentWidth = height * refs.aspectRatio.current;
currentHeight = height;
}
}
else {
currentWidth = width;
currentHeight = height;
}
if (keepAspectRatio) {
if ((width < 0 && currentWidth > 0) ||
(width > 0 && currentWidth < 0))
currentWidth *= -1;
if ((height < 0 && currentHeight > 0) ||
(height > 0 && currentHeight < 0))
currentHeight *= -1;
}
item.p = [startTransformed.x, startTransformed.y];
item.ar = [currentWidth, currentHeight];
}
// select box
onSelect();
// render
render();
}, []);
const onMouseMove = react_1.default.useCallback((event) => {
const { offsetX, offsetY, clientX, clientY } = event;
onMove({ offsetX, offsetY, clientX, clientY }, event);
}, [onMove]);
const onTouchMove = react_1.default.useCallback((event) => {
// Get the first touch point
const touch = event.touches[0];
const { clientX, clientY } = touch;
let { offsetX, offsetY } = touch;
const targetElement = touch.target;
if (targetElement instanceof HTMLElement) {
// Get the bounding rectangle of the target element
const rect = targetElement.getBoundingClientRect();
// Calculate the offsetX and offsetY
offsetX = touch.clientX - rect.left;
offsetY = touch.clientY - rect.top;
}
onMove({ offsetX, offsetY, clientX, clientY }, event);
}, [onInteractionDown]);
const undo = react_1.default.useCallback(() => {
if (!refs.undo.current.length)
return;
// add current state to redo
refs.redo.current.push([...getItems()]);
// restore the undo state
refs.items.current = refs.undo.current.pop();
// render
render();
}, []);
const redo = react_1.default.useCallback(() => {
if (!refs.redo.current.length)
return;
// add current state to undo
refs.undo.current.push([...getItems()]);
// restore the redo state
refs.items.current = refs.redo.current.pop();
// render
render();
}, []);
const onWheel = react_1.default.useCallback((eventReact) => {
const event = eventReact.nativeEvent;
// zoom
if (event.metaKey || event.ctrlKey) {
setTool('zoom');
refs.toolUpdateAuto.current = true;
const zoomFactor = 1.054;
const mouseX = event.offsetX;
const mouseY = event.offsetY;
const scale = refs.scale.current;
// Convert mouse position to canvas coordinates
const canvasX = (mouseX - refs.move.current.x) / scale;
const canvasY = (mouseY - refs.move.current.y) / scale;
// Adjust scale
const zoomIn = event.deltaY < 0;
const newScale = zoomIn ? scale * zoomFactor : scale / zoomFactor;
if (newScale <= (maxZoom / 100) && newScale >= (minZoom / 100)) {
// Update origin to focus on mouse position
refs.move.current.x -= canvasX * (newScale - scale);
refs.move.current.y -= canvasY * (newScale - scale);
refs.scale.current = newScale;
render();
}
}
// pan
else if (!refs.mouseDown.current) {
refs.move.current.x -= event.deltaX;
refs.move.current.y -= event.deltaY;
render();
}
}, [minZoom, maxZoom]);
const onPaste = react_1.default.useCallback((event) => {
event.preventDefault();
// Get clipboard data
const items = Array.from(event.clipboardData.items);
// Loop through clipboard items to find an image
for (const item of items) {
if (item.type.startsWith('image/')) {
// Get the image file
const blob = item.getAsFile();
refs.image.current = new Image();
// Load the image and draw it on the canvas
refs.image.current.onload = () => {
refs.aspectRatio.current = refs.image.current.width / refs.image.current.height;
// Todo
// 1) Upload the image first, than read it in image src
// 2) Add url of the image
// instead of embeding the image
const item_ = {
i: (0, utils_1.getID)(),
v: 'i',
p: [],
ar: [],
s: {
// Todo
// remove in the future
image: refs.image.current,
aspectRatio: refs.aspectRatio.current
},
a: date_1.OnesyDate.milliseconds
};
add(item_);
setTool('image');
};
// Create an object URL for the blob and set it as the image source
refs.image.current.src = URL.createObjectURL(blob);
break;
}
}
}, []);
react_1.default.useEffect(() => {
const method = () => {
const width = refs.root.current.offsetWidth;
const height = refs.root.current.offsetHeight;
setSize({ width, height });
};
const onKeyUp = (event) => {
if (refs.toolUpdateAuto.current)
setTool(refs.previousTool.current || 'pen');
};
const onKeyDown = async (event) => {
var _a, _b, _c, _d;
refs.toolUpdateAuto.current = false;
const { key } = event;
const itemsAll = [...refs.items.current].filter(Boolean);
const t = refs.tool.current;
const zoom = refs.scale.current;
if (['a', 'A'].includes(key) && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
selectAll();
render();
}
else if (t === 'select' && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace'].includes(key)) {
const value = event.shiftKey ? 10 : 1;
if (key === 'ArrowUp')
onMoveItems(0, -value / zoom);
if (key === 'ArrowDown')
onMoveItems(0, value / zoom);
if (key === 'ArrowLeft')
onMoveItems(-value / zoom, 0);
if (key === 'ArrowRight')
onMoveItems(value / zoom, 0);
if (key === 'Backspace') {
const toRemove = [];
refs.items.current.forEach(item => {
if (item.se)
toRemove.push(item);
});
if (toRemove.length)
remove(toRemove);
}
render();
}
else if (key === 'Escape') {
setTool('select');
refs.textActive.current = null;
unselectAll();
filterItems();
render();
}
else if (tool === 'text') {
const selectedTextBox = itemsAll.find(item => item.v === 't' && item.se);
if (!selectedTextBox)
return;
const { line, char } = selectedTextBox.s.cursor;
const lines = selectedTextBox.s.lines;
const currentLine = lines[line];
if (['ArrowLeft', 'ArrowRight'].includes(key)) {
event.preventDefault();
selectedTextBox.s.cursor.char += key === 'ArrowLeft' ? -1 : 1;
if (selectedTextBox.s.cursor.char < 0) {
selectedTextBox.s.cursor.line--;
selectedTextBox.s.cursor.char = (_a = lines[selectedTextBox.s.cursor.line]) === null || _a === void 0 ? void 0 : _a.length;
}
else if ((selectedTextBox.s.cursor.char > ((_b = lines[line]) === null || _b === void 0 ? void 0 : _b.length)) && line !== lines.length - 1) {
selectedTextBox.s.cursor.line++;
selectedTextBox.s.cursor.char = 0;
}
selectedTextBox.s.cursor.line = (0, utils_1.clamp)(selectedTextBox.s.cursor.line, 0, lines.length - 1);
selectedTextBox.s.cursor.char = (0, utils_1.clamp)(selectedTextBox.s.cursor.char, 0, (_c = lines[selectedTextBox.s.cursor.line]) === null || _c === void 0 ? void