UNPKG

@onesy/ui-react

Version:
1,171 lines 57.7 kB
"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