@onesy/ui-react 1.0.20 → 1.0.22
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/Expand/Expand.js +1 -1
- package/Whiteboard/Whiteboard.d.ts +35 -0
- package/Whiteboard/Whiteboard.js +1334 -0
- package/Whiteboard/index.d.ts +1 -0
- package/Whiteboard/index.js +8 -0
- package/Whiteboard/package.json +6 -0
- package/esm/Expand/Expand.js +1 -1
- package/esm/Whiteboard/Whiteboard.js +1415 -0
- package/esm/Whiteboard/index.js +1 -0
- package/esm/index.js +4 -2
- package/index.d.ts +2 -0
- package/index.js +4 -1
- package/package.json +1 -1
@@ -0,0 +1,1415 @@
|
|
1
|
+
import _extends from "@babel/runtime/helpers/extends";
|
2
|
+
import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
|
3
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
4
|
+
const _excluded = ["valueDefault", "onChange", "minZoom", "maxZoom", "grid", "settings", "className"];
|
5
|
+
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
|
6
|
+
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
|
7
|
+
import React from 'react';
|
8
|
+
import { arrayToParts, clamp, copy, getID, is, isEnvironment } from '@onesy/utils';
|
9
|
+
import { classNames, style as styleMethod, useOnesyTheme } from '@onesy/style-react';
|
10
|
+
import { OnesyDate } from '@onesy/date';
|
11
|
+
import LineElement from '../Line';
|
12
|
+
import { staticClassName } from '../utils';
|
13
|
+
const useStyle = styleMethod(theme => ({
|
14
|
+
root: {
|
15
|
+
position: 'relative'
|
16
|
+
},
|
17
|
+
canvas: {
|
18
|
+
position: 'absolute',
|
19
|
+
inset: 0,
|
20
|
+
width: '100%',
|
21
|
+
height: '100%',
|
22
|
+
imageRendering: 'pixelated',
|
23
|
+
background: '#fff',
|
24
|
+
appearance: 'none',
|
25
|
+
border: 'none',
|
26
|
+
userSelect: 'none',
|
27
|
+
transition: theme.methods.transitions.make('opacity'),
|
28
|
+
'&[disabled]': {
|
29
|
+
opacity: 0.7,
|
30
|
+
pointerEvents: 'none'
|
31
|
+
}
|
32
|
+
},
|
33
|
+
ui: {
|
34
|
+
zIndex: 0
|
35
|
+
},
|
36
|
+
object: {
|
37
|
+
cursor: 'crosshair'
|
38
|
+
},
|
39
|
+
pen: {
|
40
|
+
cursor: 'crosshair'
|
41
|
+
},
|
42
|
+
pan: {
|
43
|
+
cursor: 'grab'
|
44
|
+
},
|
45
|
+
panning: {
|
46
|
+
cursor: 'grabbing'
|
47
|
+
},
|
48
|
+
zoom: {
|
49
|
+
cursor: 'zoom-in'
|
50
|
+
},
|
51
|
+
eraser: {
|
52
|
+
cursor: 'not-allowed'
|
53
|
+
},
|
54
|
+
image: {
|
55
|
+
cursor: 'copy'
|
56
|
+
},
|
57
|
+
text: {
|
58
|
+
cursor: 'text'
|
59
|
+
}
|
60
|
+
}), {
|
61
|
+
name: 'onesy-Whiteboard'
|
62
|
+
});
|
63
|
+
const colorSelect = 'hsl(244deg 64% 64%)';
|
64
|
+
const colorSelectBackground = 'hsla(244deg 64% 64% / 4%)';
|
65
|
+
const Whiteboard = /*#__PURE__*/React.forwardRef((props_, ref) => {
|
66
|
+
const theme = useOnesyTheme();
|
67
|
+
const props = React.useMemo(() => _objectSpread(_objectSpread(_objectSpread({}, theme?.ui?.elements?.all?.props?.default), theme?.ui?.elements?.onesyWhiteboard?.props?.default), props_), [props_]);
|
68
|
+
const Line = React.useMemo(() => theme?.elements?.Line || LineElement, [theme]);
|
69
|
+
const {
|
70
|
+
valueDefault,
|
71
|
+
onChange: onChangeProps,
|
72
|
+
// 10%
|
73
|
+
minZoom = 10,
|
74
|
+
// 400%
|
75
|
+
maxZoom = 4000,
|
76
|
+
grid: gridProps = true,
|
77
|
+
settings = {
|
78
|
+
lineCap: 'round',
|
79
|
+
lineJoin: 'round',
|
80
|
+
lineWidth: 10,
|
81
|
+
fillStyle: 'lightgreen',
|
82
|
+
strokeStyle: 'lightgreen',
|
83
|
+
globalAlpha: 0.44
|
84
|
+
},
|
85
|
+
className
|
86
|
+
} = props,
|
87
|
+
other = _objectWithoutProperties(props, _excluded);
|
88
|
+
const {
|
89
|
+
classes
|
90
|
+
} = useStyle();
|
91
|
+
const [size, setSize] = React.useState({});
|
92
|
+
const [tool, setTool] = React.useState('select');
|
93
|
+
const [mouseDown, setMouseDown] = React.useState(false);
|
94
|
+
const [grid, setGrid] = React.useState(gridProps);
|
95
|
+
const [loaded, setLoaded] = React.useState(false);
|
96
|
+
const refs = {
|
97
|
+
root: React.useRef(null),
|
98
|
+
ui: React.useRef(null),
|
99
|
+
interactive: React.useRef(null),
|
100
|
+
on: React.useRef(false),
|
101
|
+
items: React.useRef(valueDefault || []),
|
102
|
+
previous: React.useRef({
|
103
|
+
x: 0,
|
104
|
+
y: 0
|
105
|
+
}),
|
106
|
+
previousMouse: React.useRef({
|
107
|
+
x: 0,
|
108
|
+
y: 0
|
109
|
+
}),
|
110
|
+
moveStarted: React.useRef(false),
|
111
|
+
undo: React.useRef([]),
|
112
|
+
redo: React.useRef([]),
|
113
|
+
move: React.useRef({
|
114
|
+
x: 0,
|
115
|
+
y: 0
|
116
|
+
}),
|
117
|
+
offset: React.useRef({
|
118
|
+
x: 0,
|
119
|
+
y: 0
|
120
|
+
}),
|
121
|
+
start: React.useRef({
|
122
|
+
x: 0,
|
123
|
+
y: 0
|
124
|
+
}),
|
125
|
+
end: React.useRef({
|
126
|
+
x: 0,
|
127
|
+
y: 0
|
128
|
+
}),
|
129
|
+
scale: React.useRef(1),
|
130
|
+
mouseDown: React.useRef(mouseDown),
|
131
|
+
mouseMove: React.useRef({
|
132
|
+
current: {
|
133
|
+
x: 0,
|
134
|
+
y: 0
|
135
|
+
},
|
136
|
+
previous: undefined,
|
137
|
+
delta: {
|
138
|
+
x: 0,
|
139
|
+
y: 0
|
140
|
+
}
|
141
|
+
}),
|
142
|
+
tool: React.useRef(tool),
|
143
|
+
previousTool: React.useRef(tool),
|
144
|
+
toolUpdateAuto: React.useRef(false),
|
145
|
+
remove: React.useRef([]),
|
146
|
+
grid: React.useRef(grid),
|
147
|
+
typing: React.useRef(false),
|
148
|
+
image: React.useRef(isEnvironment('browser') && new Image()),
|
149
|
+
aspectRatio: React.useRef(1),
|
150
|
+
select: React.useRef(null),
|
151
|
+
textActive: React.useRef(null),
|
152
|
+
textSettings: React.useRef({
|
153
|
+
lineHeight: 20,
|
154
|
+
padding: 5,
|
155
|
+
fillStyle: 'black'
|
156
|
+
})
|
157
|
+
};
|
158
|
+
refs.mouseDown.current = mouseDown;
|
159
|
+
refs.tool.current = tool;
|
160
|
+
refs.grid.current = grid;
|
161
|
+
const init = React.useCallback(() => {
|
162
|
+
// Todo
|
163
|
+
// items
|
164
|
+
// load all of the images in memory and attach theme to items as image elements
|
165
|
+
// once it's all done, setLoaded(true), render
|
166
|
+
|
167
|
+
setTimeout(() => {
|
168
|
+
render();
|
169
|
+
setLoaded(true);
|
170
|
+
}, 40);
|
171
|
+
}, []);
|
172
|
+
React.useEffect(() => {
|
173
|
+
if (!['zoom'].includes(tool)) refs.previousTool.current = tool;
|
174
|
+
}, [tool]);
|
175
|
+
const onChange = React.useCallback(() => {
|
176
|
+
if (is('function', onChangeProps)) onChangeProps(refs.items.current);
|
177
|
+
}, [onChangeProps]);
|
178
|
+
const getItems = React.useCallback(function () {
|
179
|
+
let selected = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : undefined;
|
180
|
+
return refs.items.current.filter(item => selected === undefined || item.se === selected);
|
181
|
+
}, []);
|
182
|
+
const getItem = React.useCallback(() => refs.items.current[refs.items.current.length - 1], []);
|
183
|
+
const filterItems = React.useCallback(() => {
|
184
|
+
const toRemove = refs.items.current.filter(item => {
|
185
|
+
if (refs.tool.current === 'text' && item !== refs.textActive.current) item.se = false;
|
186
|
+
const lines = item.s?.lines || [];
|
187
|
+
return !(item.v !== 't' || item === refs.textActive.current || lines.length > 1 || lines[0].length);
|
188
|
+
});
|
189
|
+
if (toRemove.length) remove(toRemove);
|
190
|
+
}, []);
|
191
|
+
const add = React.useCallback(toAdd => {
|
192
|
+
const itemsAdd = (Array.isArray(toAdd) ? toAdd : [toAdd]).filter(Boolean);
|
193
|
+
const items = getItems();
|
194
|
+
|
195
|
+
// add to undo stack snapshot of current state
|
196
|
+
refs.undo.current.push([...items]);
|
197
|
+
|
198
|
+
// clear redo stack
|
199
|
+
refs.redo.current = [];
|
200
|
+
refs.items.current.push(...itemsAdd);
|
201
|
+
}, []);
|
202
|
+
const remove = React.useCallback(toRemove => {
|
203
|
+
const itemsRemove = (Array.isArray(toRemove) ? toRemove : [toRemove]).filter(Boolean);
|
204
|
+
const items = getItems();
|
205
|
+
const IDs = itemsRemove?.map(item => item.i);
|
206
|
+
const toRemoveIDs = items.filter(item => IDs.includes(item.i)).map(item => item.i);
|
207
|
+
if (!toRemoveIDs) return;
|
208
|
+
|
209
|
+
// add to undo stack snapshot of current state
|
210
|
+
refs.undo.current.push([...items]);
|
211
|
+
|
212
|
+
// clear redo stack
|
213
|
+
refs.redo.current = [];
|
214
|
+
refs.items.current = refs.items.current.filter(item => !toRemoveIDs.includes(item.i));
|
215
|
+
}, []);
|
216
|
+
const reset = React.useCallback(() => {
|
217
|
+
refs.on.current = false;
|
218
|
+
refs.moveStarted.current = false;
|
219
|
+
refs.image.current = null;
|
220
|
+
|
221
|
+
// new move start
|
222
|
+
refs.offset.current.x = refs.move.current.x;
|
223
|
+
refs.offset.current.y = refs.move.current.y;
|
224
|
+
}, []);
|
225
|
+
const transform = React.useCallback(coordinate => coordinate / refs.scale.current, []);
|
226
|
+
const selectAll = React.useCallback(() => {
|
227
|
+
return [...refs.items.current].filter(Boolean).map(item => {
|
228
|
+
item.se = true;
|
229
|
+
return item;
|
230
|
+
});
|
231
|
+
}, []);
|
232
|
+
const unselectAll = React.useCallback(eventReact => {
|
233
|
+
const event = eventReact?.nativeEvent || eventReact;
|
234
|
+
const shift = event?.shiftKey;
|
235
|
+
return [...refs.items.current].filter(Boolean).map(item => {
|
236
|
+
if (shift) return item;
|
237
|
+
item.se = false;
|
238
|
+
return item;
|
239
|
+
});
|
240
|
+
}, []);
|
241
|
+
const onInteractionDown = React.useCallback((body, eventReact) => {
|
242
|
+
const event = eventReact?.nativeEvent || eventReact;
|
243
|
+
const {
|
244
|
+
offsetX: x,
|
245
|
+
offsetY: y,
|
246
|
+
clientX,
|
247
|
+
clientY
|
248
|
+
} = body;
|
249
|
+
refs.on.current = true;
|
250
|
+
refs.previous.current = {
|
251
|
+
x,
|
252
|
+
y
|
253
|
+
};
|
254
|
+
refs.mouseMove.current = {
|
255
|
+
current: {
|
256
|
+
x: 0,
|
257
|
+
y: 0
|
258
|
+
},
|
259
|
+
previous: undefined,
|
260
|
+
delta: {
|
261
|
+
x: 0,
|
262
|
+
y: 0
|
263
|
+
}
|
264
|
+
};
|
265
|
+
const shift = event?.shiftKey;
|
266
|
+
const ui = refs.ui.current.getContext('2d');
|
267
|
+
const rect = refs.ui.current.getBoundingClientRect();
|
268
|
+
refs.start.current.x = clientX - rect.left;
|
269
|
+
refs.start.current.y = clientY - rect.top;
|
270
|
+
refs.textActive.current = null;
|
271
|
+
const start = refs.start.current;
|
272
|
+
const startTransformed = {
|
273
|
+
x: transform(start.x - refs.move.current.x),
|
274
|
+
y: transform(start.y - refs.move.current.y)
|
275
|
+
};
|
276
|
+
Object.keys(settings).forEach(item_ => ui[item_] = settings[item_]);
|
277
|
+
let item;
|
278
|
+
const items = getItems();
|
279
|
+
const t = refs.tool.current;
|
280
|
+
refs.select.current = null;
|
281
|
+
if (t === 'select') {
|
282
|
+
// z-index top to bottom order
|
283
|
+
const itemsReversed = [...items].reverse();
|
284
|
+
const itemsClicked = itemsReversed.filter(item_ => {
|
285
|
+
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];
|
286
|
+
});
|
287
|
+
const itemsSelected = getItems(true);
|
288
|
+
const clicked = itemsClicked[0];
|
289
|
+
if (!clicked) {
|
290
|
+
unselectAll();
|
291
|
+
refs.select.current = {
|
292
|
+
p: [],
|
293
|
+
ar: []
|
294
|
+
};
|
295
|
+
}
|
296
|
+
if (!shift && !(clicked?.se && itemsSelected.length > 1)) unselectAll();
|
297
|
+
if (clicked) {
|
298
|
+
clicked.se = shift ? !clicked.se : true;
|
299
|
+
}
|
300
|
+
} else if (t === 'text') {
|
301
|
+
const {
|
302
|
+
lineHeight,
|
303
|
+
padding
|
304
|
+
} = refs.textSettings.current;
|
305
|
+
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]);
|
306
|
+
if (selectedText) {
|
307
|
+
refs.textActive.current = selectedText;
|
308
|
+
selectedText.se = true;
|
309
|
+
const relativeY = startTransformed.y - selectedText.c[1] - padding;
|
310
|
+
const lineIndex = Math.floor(relativeY / lineHeight);
|
311
|
+
const clickedLine = selectedText.s?.lines?.[lineIndex] || '';
|
312
|
+
const relativeX = startTransformed.x - selectedText.c[0] - padding;
|
313
|
+
let charIndex = clickedLine.length;
|
314
|
+
for (let i = 0; i < clickedLine.length; i++) {
|
315
|
+
if (relativeX < ui.measureText(clickedLine.slice(0, i + 1)).width) {
|
316
|
+
charIndex = i;
|
317
|
+
break;
|
318
|
+
}
|
319
|
+
}
|
320
|
+
selectedText.s.cursor = {
|
321
|
+
line: lineIndex,
|
322
|
+
char: charIndex
|
323
|
+
};
|
324
|
+
} else {
|
325
|
+
item = {
|
326
|
+
i: getID(),
|
327
|
+
v: 't',
|
328
|
+
p: [startTransformed.x, startTransformed.y],
|
329
|
+
ar: [15, lineHeight + padding * 2],
|
330
|
+
s: _objectSpread(_objectSpread({}, refs.textSettings.current), {}, {
|
331
|
+
lines: [''],
|
332
|
+
cursor: {
|
333
|
+
line: 0,
|
334
|
+
char: 0
|
335
|
+
}
|
336
|
+
}),
|
337
|
+
se: true,
|
338
|
+
a: OnesyDate.milliseconds
|
339
|
+
};
|
340
|
+
refs.textActive.current = item;
|
341
|
+
}
|
342
|
+
} else {
|
343
|
+
// pen
|
344
|
+
if (t === 'pen') {
|
345
|
+
// point
|
346
|
+
item = {
|
347
|
+
i: getID(),
|
348
|
+
v: 'dp',
|
349
|
+
p: [transform(x - refs.move.current.x), transform(y - refs.move.current.y)],
|
350
|
+
ar: [ui.lineWidth / 2, 0, Math.PI * 2],
|
351
|
+
s: copy(settings),
|
352
|
+
a: OnesyDate.milliseconds
|
353
|
+
};
|
354
|
+
}
|
355
|
+
// circle, rectangle, line, line-arrow
|
356
|
+
if (['circle', 'rectangle', 'triangle', 'line', 'line-arrow'].includes(t)) {
|
357
|
+
item = {
|
358
|
+
i: getID(),
|
359
|
+
p: [],
|
360
|
+
ar: [],
|
361
|
+
s: copy(settings),
|
362
|
+
a: OnesyDate.milliseconds
|
363
|
+
};
|
364
|
+
}
|
365
|
+
// pan
|
366
|
+
if (t === 'pan') {
|
367
|
+
// new move start
|
368
|
+
refs.offset.current.x = refs.move.current.x;
|
369
|
+
refs.offset.current.y = refs.move.current.y;
|
370
|
+
}
|
371
|
+
// image
|
372
|
+
else if (t === 'image' && refs.image.current.complete && refs.image.current.src) {
|
373
|
+
refs.aspectRatio.current = refs.image.current.width / refs.image.current.height;
|
374
|
+
|
375
|
+
// Todo
|
376
|
+
// add url of the image
|
377
|
+
// instead of embeding the image
|
378
|
+
item = {
|
379
|
+
i: getID(),
|
380
|
+
v: 'i',
|
381
|
+
p: [],
|
382
|
+
ar: [],
|
383
|
+
s: {
|
384
|
+
// Todo
|
385
|
+
// remove in the future
|
386
|
+
image: refs.image.current,
|
387
|
+
aspectRatio: refs.aspectRatio.current
|
388
|
+
},
|
389
|
+
a: OnesyDate.milliseconds
|
390
|
+
};
|
391
|
+
}
|
392
|
+
}
|
393
|
+
if (item) add(item);
|
394
|
+
filterItems();
|
395
|
+
|
396
|
+
// render
|
397
|
+
render();
|
398
|
+
setMouseDown(true);
|
399
|
+
}, []);
|
400
|
+
const onMouseDown = React.useCallback(event => {
|
401
|
+
const {
|
402
|
+
offsetX,
|
403
|
+
offsetY,
|
404
|
+
clientX,
|
405
|
+
clientY
|
406
|
+
} = event.nativeEvent;
|
407
|
+
onInteractionDown({
|
408
|
+
offsetX,
|
409
|
+
offsetY,
|
410
|
+
clientX,
|
411
|
+
clientY
|
412
|
+
}, event);
|
413
|
+
}, [onInteractionDown]);
|
414
|
+
const onTouchStart = React.useCallback(event => {
|
415
|
+
// Get the first touch point
|
416
|
+
const touch = event.touches[0];
|
417
|
+
const {
|
418
|
+
clientX,
|
419
|
+
clientY
|
420
|
+
} = touch;
|
421
|
+
let {
|
422
|
+
offsetX,
|
423
|
+
offsetY
|
424
|
+
} = touch;
|
425
|
+
const targetElement = touch.target;
|
426
|
+
if (targetElement instanceof HTMLElement) {
|
427
|
+
// Get the bounding rectangle of the target element
|
428
|
+
const rect = targetElement.getBoundingClientRect();
|
429
|
+
|
430
|
+
// Calculate the offsetX and offsetY
|
431
|
+
offsetX = touch.clientX - rect.left;
|
432
|
+
offsetY = touch.clientY - rect.top;
|
433
|
+
}
|
434
|
+
onInteractionDown({
|
435
|
+
offsetX,
|
436
|
+
offsetY,
|
437
|
+
clientX,
|
438
|
+
clientY
|
439
|
+
}, event);
|
440
|
+
}, [onInteractionDown]);
|
441
|
+
const removeItems = React.useCallback(() => {
|
442
|
+
// remove
|
443
|
+
if (refs.remove.current.length) {
|
444
|
+
const toRemove = [];
|
445
|
+
for (const item of refs.remove.current) {
|
446
|
+
const index = refs.items.current.findIndex(itemItems => itemItems === item);
|
447
|
+
if (index > -1) toRemove.push(item);
|
448
|
+
}
|
449
|
+
if (toRemove.length) remove(toRemove);
|
450
|
+
refs.remove.current = [];
|
451
|
+
}
|
452
|
+
}, []);
|
453
|
+
const onUpdateCoordinates = React.useCallback(() => {
|
454
|
+
const items = getItems();
|
455
|
+
items.forEach(item => {
|
456
|
+
const p = item?.p || [];
|
457
|
+
const ar = item?.ar || [];
|
458
|
+
const s = item?.s || {};
|
459
|
+
if (p.length) {
|
460
|
+
// cache
|
461
|
+
// x1, y1, width. height for position on the screen
|
462
|
+
const v = item.v;
|
463
|
+
|
464
|
+
// draw point
|
465
|
+
if (v === 'dp') {
|
466
|
+
const lineWidth = s.lineWidth || 10;
|
467
|
+
item.c = [p[0] - lineWidth / 2, p[1] - lineWidth / 2, lineWidth, lineWidth];
|
468
|
+
}
|
469
|
+
// draw line
|
470
|
+
else if (v === 'dl') {
|
471
|
+
const x = [];
|
472
|
+
const y = [];
|
473
|
+
for (let i = 0; i < p.length; i += 2) {
|
474
|
+
x.push(p[i]);
|
475
|
+
y.push(p[i + 1]);
|
476
|
+
}
|
477
|
+
const xMin = Math.min(...x);
|
478
|
+
const yMin = Math.min(...y);
|
479
|
+
const xMax = Math.max(...x);
|
480
|
+
const yMax = Math.max(...y);
|
481
|
+
item.c = [xMin, yMin, xMax - xMin, yMax - yMin];
|
482
|
+
}
|
483
|
+
// object line, object arrow
|
484
|
+
else if (['ol', 'oa'].includes(v)) {
|
485
|
+
const x = [p[0], ar[0]];
|
486
|
+
const y = [p[1], ar[1]];
|
487
|
+
const xMin = Math.min(...x);
|
488
|
+
const yMin = Math.min(...y);
|
489
|
+
const xMax = Math.max(...x);
|
490
|
+
const yMax = Math.max(...y);
|
491
|
+
item.c = [xMin, yMin, xMax - xMin, yMax - yMin];
|
492
|
+
}
|
493
|
+
// object rectangle, object square
|
494
|
+
else if (['or', 'os'].includes(v)) {
|
495
|
+
item.c = [...p, ...ar];
|
496
|
+
}
|
497
|
+
// object circle, object ellipse
|
498
|
+
else if (['oc', 'oe'].includes(v)) {
|
499
|
+
if (v === 'oc') {
|
500
|
+
item.c = [p[0] - ar[0], p[1] - ar[0], ar[0] * 2, ar[0] * 2];
|
501
|
+
} else {
|
502
|
+
item.c = [p[0] - ar[0], p[1] - ar[1], ar[0] * 2, ar[1] * 2];
|
503
|
+
}
|
504
|
+
}
|
505
|
+
// object triangle, object triangle equilateral
|
506
|
+
else if (['ot', 'ote'].includes(v)) {
|
507
|
+
const [x1, y1, x2] = p;
|
508
|
+
const {
|
509
|
+
height
|
510
|
+
} = s;
|
511
|
+
item.c = [x1, y1 - height, x2 - x1, height];
|
512
|
+
}
|
513
|
+
// image
|
514
|
+
else if (['i', 't'].includes(v)) {
|
515
|
+
item.c = [...p, ...ar];
|
516
|
+
}
|
517
|
+
}
|
518
|
+
});
|
519
|
+
}, []);
|
520
|
+
const onSelect = React.useCallback(() => {
|
521
|
+
const select = refs.select.current;
|
522
|
+
if (!select) return;
|
523
|
+
const px1 = select.p[0];
|
524
|
+
const px2 = px1 + select.ar[0];
|
525
|
+
const py1 = select.p[1];
|
526
|
+
const py2 = py1 + select.ar[1];
|
527
|
+
const min = {
|
528
|
+
x: Math.min(px1, px2),
|
529
|
+
y: Math.min(py1, py2)
|
530
|
+
};
|
531
|
+
const max = {
|
532
|
+
x: Math.max(px1, px2),
|
533
|
+
y: Math.max(py1, py2)
|
534
|
+
};
|
535
|
+
const items = getItems();
|
536
|
+
items.forEach(item => {
|
537
|
+
const {
|
538
|
+
c
|
539
|
+
} = item;
|
540
|
+
const [x1, y1] = c;
|
541
|
+
let [x2, y2] = c;
|
542
|
+
x2 = x1 + x2;
|
543
|
+
y2 = y1 + y2;
|
544
|
+
const minItem = {
|
545
|
+
x: Math.min(x1, x2),
|
546
|
+
y: Math.min(y1, y2)
|
547
|
+
};
|
548
|
+
const maxItem = {
|
549
|
+
x: Math.max(x1, x2),
|
550
|
+
y: Math.max(y1, y2)
|
551
|
+
};
|
552
|
+
const selected = minItem.x >= min.x && maxItem.x <= max.x && minItem.y >= min.y && maxItem.y <= max.y;
|
553
|
+
if (selected) item.se = true;
|
554
|
+
});
|
555
|
+
}, []);
|
556
|
+
const onMouseUp = React.useCallback(event => {
|
557
|
+
if (refs.mouseDown.current) {
|
558
|
+
refs.select.current = null;
|
559
|
+
|
560
|
+
// update coordinates
|
561
|
+
onUpdateCoordinates();
|
562
|
+
|
563
|
+
// reset
|
564
|
+
reset();
|
565
|
+
|
566
|
+
// remove
|
567
|
+
removeItems();
|
568
|
+
console.log('items', refs.items.current);
|
569
|
+
|
570
|
+
// onChange
|
571
|
+
onChange();
|
572
|
+
setMouseDown(false);
|
573
|
+
if (['circle', 'rectangle', 'triangle', 'line', 'line-arrow', 'image'].includes(refs.previousTool.current)) {
|
574
|
+
setTool('select');
|
575
|
+
}
|
576
|
+
render();
|
577
|
+
}
|
578
|
+
}, [onChange]);
|
579
|
+
const updateTextBoxDimensions = React.useCallback(item => {
|
580
|
+
const ui = refs.ui.current.getContext('2d');
|
581
|
+
const {
|
582
|
+
lineHeight,
|
583
|
+
padding
|
584
|
+
} = refs.textSettings.current;
|
585
|
+
ui.font = '16px Arial';
|
586
|
+
const maxWidth = Math.max(...item.s.lines.map(line => ui.measureText(line).width));
|
587
|
+
item.ar[0] = maxWidth + padding * 2.5;
|
588
|
+
item.ar[1] = item.s.lines.length * lineHeight + padding * 2;
|
589
|
+
}, []);
|
590
|
+
const getPath = React.useCallback(item => {
|
591
|
+
const path = new Path2D();
|
592
|
+
const {
|
593
|
+
v,
|
594
|
+
p,
|
595
|
+
ar
|
596
|
+
} = item;
|
597
|
+
|
598
|
+
// draw line
|
599
|
+
if (v === 'dl') {
|
600
|
+
const points = arrayToParts(p, 2);
|
601
|
+
for (let i = 0; i < points.length - 1; i++) {
|
602
|
+
const current = points[i];
|
603
|
+
const next = points[i + 1];
|
604
|
+
|
605
|
+
// calculate the control point for the curve
|
606
|
+
const midX = (current[0] + next[0]) / 2;
|
607
|
+
const midY = (current[1] + next[1]) / 2;
|
608
|
+
if (i === 0) {
|
609
|
+
// start from the first point
|
610
|
+
path.moveTo(current[0], current[1]);
|
611
|
+
}
|
612
|
+
path.quadraticCurveTo(current[0], current[1], midX, midY);
|
613
|
+
}
|
614
|
+
}
|
615
|
+
// draw point, object circle
|
616
|
+
else if (['dp', 'oc'].includes(v) && ar.length === 3) {
|
617
|
+
path.arc(p[0], p[1], ...ar);
|
618
|
+
}
|
619
|
+
// object ellipse
|
620
|
+
else if (v === 'oe' && ar.length === 5) {
|
621
|
+
path.ellipse(p[0], p[1], ...ar);
|
622
|
+
}
|
623
|
+
// object rectangle
|
624
|
+
else if (['or', 'os'].includes(v)) {
|
625
|
+
path.roundRect(p[0], p[1], ...ar);
|
626
|
+
}
|
627
|
+
// object line
|
628
|
+
else if (['ol', 'oa'].includes(v) && ar.length === 2) {
|
629
|
+
path.moveTo(p[0], p[1]);
|
630
|
+
path.lineTo(...ar);
|
631
|
+
|
632
|
+
// draw an arrow
|
633
|
+
if (v === 'oa') {
|
634
|
+
// Length of the arrowhead
|
635
|
+
const headLength = 40;
|
636
|
+
const angle = Math.atan2(ar[1] - p[1], ar[0] - p[0]);
|
637
|
+
path.moveTo(ar[0], ar[1]);
|
638
|
+
path.lineTo(ar[0] - headLength * Math.cos(angle - Math.PI / 6), ar[1] - headLength * Math.sin(angle - Math.PI / 6));
|
639
|
+
path.moveTo(ar[0], ar[1]);
|
640
|
+
path.lineTo(ar[0] - headLength * Math.cos(angle + Math.PI / 6), ar[1] - headLength * Math.sin(angle + Math.PI / 6));
|
641
|
+
}
|
642
|
+
}
|
643
|
+
// object triangle
|
644
|
+
else if (['ot', 'ote'].includes(v)) {
|
645
|
+
path.moveTo(p[0], p[1]);
|
646
|
+
path.lineTo(p[2], p[3]);
|
647
|
+
path.lineTo(p[4], p[5]);
|
648
|
+
path.closePath();
|
649
|
+
}
|
650
|
+
return path;
|
651
|
+
}, []);
|
652
|
+
const draw = React.useCallback(item => {
|
653
|
+
const ui = refs.ui.current.getContext('2d');
|
654
|
+
|
655
|
+
// settings
|
656
|
+
Object.keys(item.s || {}).forEach(key => ui[key] = item.s?.[key]);
|
657
|
+
ui.globalAlpha = refs.remove.current.includes(item) ? 0.25 : item.s?.globalAlpha !== undefined ? item.s?.globalAlpha : 1;
|
658
|
+
ui.beginPath();
|
659
|
+
const path = getPath(item);
|
660
|
+
const v = item.v;
|
661
|
+
if (['dp'].includes(v)) ui.fill(path);else if (['dl', 'oc', 'oe', 'or', 'os', 'ol', 'oa', 'ot', 'ote'].includes(v)) ui.stroke(path);
|
662
|
+
}, []);
|
663
|
+
const drawGrid = React.useCallback(() => {
|
664
|
+
const uiCanvas = refs.ui.current;
|
665
|
+
const ui = refs.ui.current.getContext('2d');
|
666
|
+
const zoom = refs.scale.current;
|
667
|
+
const gridSize = 70;
|
668
|
+
const offsetX = refs.move.current.x / zoom;
|
669
|
+
const offsetY = refs.move.current.y / zoom;
|
670
|
+
|
671
|
+
// Calculate start positions based on offsets
|
672
|
+
const startX = Math.floor(-offsetX / gridSize) * gridSize;
|
673
|
+
const startY = Math.floor(-offsetY / gridSize) * gridSize;
|
674
|
+
const width = uiCanvas.clientWidth * 1.5 / (zoom < 1 ? zoom : 1);
|
675
|
+
const height = uiCanvas.clientHeight * 1.5 / (zoom < 1 ? zoom : 1);
|
676
|
+
if (gridSize < 30) return;
|
677
|
+
|
678
|
+
// Draw main grid lines
|
679
|
+
ui.globalAlpha = 1;
|
680
|
+
ui.lineWidth = (zoom < 0.5 ? 0.3 : zoom <= 1 ? 0.5 : 0.7) / zoom;
|
681
|
+
ui.strokeStyle = '#ccc';
|
682
|
+
|
683
|
+
// grid
|
684
|
+
for (let x = startX; x < width + Math.abs(startX); x += gridSize) {
|
685
|
+
ui.beginPath();
|
686
|
+
ui.moveTo(x, startY);
|
687
|
+
ui.lineTo(x, height + startY);
|
688
|
+
ui.stroke();
|
689
|
+
}
|
690
|
+
for (let y = startY; y < height + Math.abs(startY); y += gridSize) {
|
691
|
+
ui.beginPath();
|
692
|
+
ui.moveTo(startX, y);
|
693
|
+
ui.lineTo(width + startX, y);
|
694
|
+
ui.stroke();
|
695
|
+
}
|
696
|
+
|
697
|
+
// subgrid
|
698
|
+
if (gridSize * zoom > 100) {
|
699
|
+
// Draw subgrid lines if zoomed in
|
700
|
+
const subGridSize = gridSize / 5;
|
701
|
+
ui.lineWidth = (zoom <= 5 ? 0.6 : zoom <= 10 ? 0.8 : 1) / zoom;
|
702
|
+
ui.strokeStyle = '#ddd';
|
703
|
+
const dash = zoom < 1 ? 3 * zoom : 3 / zoom;
|
704
|
+
ui.setLineDash([dash, dash]);
|
705
|
+
for (let x = startX; x < width + Math.abs(startX); x += subGridSize) {
|
706
|
+
// without overlap
|
707
|
+
if (!(x % gridSize)) continue;
|
708
|
+
ui.beginPath();
|
709
|
+
ui.moveTo(x, startY);
|
710
|
+
ui.lineTo(x, height + startY);
|
711
|
+
ui.stroke();
|
712
|
+
}
|
713
|
+
for (let y = startY; y < height + Math.abs(startY); y += subGridSize) {
|
714
|
+
// without overlap
|
715
|
+
if (!(y % gridSize)) continue;
|
716
|
+
ui.beginPath();
|
717
|
+
ui.moveTo(startX, y);
|
718
|
+
ui.lineTo(width + startX, y);
|
719
|
+
ui.stroke();
|
720
|
+
}
|
721
|
+
ui.setLineDash([]); // Reset line dash
|
722
|
+
}
|
723
|
+
}, []);
|
724
|
+
const drawImage = React.useCallback(item => {
|
725
|
+
const ui = refs.ui.current.getContext('2d');
|
726
|
+
ui.globalAlpha = 1;
|
727
|
+
ui.drawImage(item.s.image || refs.image.current, ...item.p, ...item.ar);
|
728
|
+
}, []);
|
729
|
+
const drawCursor = React.useCallback(item => {
|
730
|
+
if (!item || !item.s.cursor) return;
|
731
|
+
const ui = refs.ui.current.getContext('2d');
|
732
|
+
const {
|
733
|
+
line,
|
734
|
+
char
|
735
|
+
} = item.s.cursor;
|
736
|
+
const currentLine = item.s.lines[line] || '';
|
737
|
+
const textWidth = ui.measureText(currentLine.slice(0, char)).width;
|
738
|
+
const {
|
739
|
+
padding,
|
740
|
+
lineHeight
|
741
|
+
} = refs.textSettings.current;
|
742
|
+
const cursorX = item.p[0] + padding + textWidth;
|
743
|
+
const cursorY = item.p[1] + padding + (line + 1) * lineHeight;
|
744
|
+
ui.fillStyle = 'black';
|
745
|
+
ui.fillRect(cursorX, cursorY - lineHeight + 3, 2, lineHeight - 5);
|
746
|
+
}, []);
|
747
|
+
const drawText = React.useCallback(item => {
|
748
|
+
const ui = refs.ui.current.getContext('2d');
|
749
|
+
const zoom = refs.scale.current;
|
750
|
+
const [x, y] = item.p;
|
751
|
+
const [width, height] = item.ar;
|
752
|
+
const {
|
753
|
+
lineHeight,
|
754
|
+
padding,
|
755
|
+
fillStyle
|
756
|
+
} = refs.textSettings.current;
|
757
|
+
const selected = refs.tool.current === 'text' && item.se;
|
758
|
+
|
759
|
+
// Draw the box
|
760
|
+
ui.globalAlpha = 1;
|
761
|
+
ui.fillStyle = 'transparent';
|
762
|
+
ui.fillRect(x, y, width, height);
|
763
|
+
ui.lineWidth = 1 / zoom;
|
764
|
+
ui.strokeStyle = selected ? colorSelect : 'transparent';
|
765
|
+
ui.strokeRect(x, y, width, height);
|
766
|
+
|
767
|
+
// Draw the text
|
768
|
+
ui.fillStyle = fillStyle || 'black';
|
769
|
+
ui.font = '16px Arial';
|
770
|
+
item.s.lines.forEach((line, index) => {
|
771
|
+
ui.fillText(line, x + padding, y + padding + (index + 1) * lineHeight - 5);
|
772
|
+
});
|
773
|
+
if (selected) drawCursor(item);
|
774
|
+
}, []);
|
775
|
+
const drawSelect = React.useCallback(item => {
|
776
|
+
const ui = refs.ui.current.getContext('2d');
|
777
|
+
const [x, y, width, height] = item.c || [];
|
778
|
+
const path = new Path2D();
|
779
|
+
path.rect(x, y, width, height);
|
780
|
+
ui.globalAlpha = 1;
|
781
|
+
ui.strokeStyle = colorSelect;
|
782
|
+
ui.lineCap = 'square';
|
783
|
+
ui.lineJoin = 'bevel';
|
784
|
+
ui.lineWidth = 1 / refs.scale.current;
|
785
|
+
ui.stroke(path);
|
786
|
+
}, []);
|
787
|
+
const drawSelection = React.useCallback(() => {
|
788
|
+
const ui = refs.ui.current.getContext('2d');
|
789
|
+
const zoom = refs.scale.current;
|
790
|
+
|
791
|
+
// canvas selection
|
792
|
+
const select = refs.select.current;
|
793
|
+
if (select) {
|
794
|
+
ui.globalAlpha = 1;
|
795
|
+
ui.globalCompositeOperation = 'source-over';
|
796
|
+
ui.lineWidth = 1 / zoom;
|
797
|
+
ui.lineCap = 'square';
|
798
|
+
ui.lineJoin = 'bevel';
|
799
|
+
ui.strokeStyle = colorSelect;
|
800
|
+
ui.fillStyle = colorSelectBackground;
|
801
|
+
const path = getPath(select);
|
802
|
+
ui.fill(path);
|
803
|
+
ui.stroke(path);
|
804
|
+
}
|
805
|
+
}, []);
|
806
|
+
const render = React.useCallback(() => {
|
807
|
+
const ui = refs.ui.current.getContext('2d');
|
808
|
+
const items = refs.items.current.filter(Boolean);
|
809
|
+
|
810
|
+
// methods
|
811
|
+
ui.clearRect(0, 0, refs.ui.current.width, refs.ui.current.height);
|
812
|
+
ui.save();
|
813
|
+
|
814
|
+
// pan
|
815
|
+
ui.translate(refs.move.current.x, refs.move.current.y);
|
816
|
+
// zoom
|
817
|
+
ui.scale(refs.scale.current, refs.scale.current);
|
818
|
+
|
819
|
+
// grid
|
820
|
+
if (refs.grid.current) drawGrid();
|
821
|
+
|
822
|
+
// draw
|
823
|
+
items.forEach(item => {
|
824
|
+
// image
|
825
|
+
if (item.v === 'i' && item.ar.length === 2) drawImage(item);
|
826
|
+
// text
|
827
|
+
else if (item.v === 't') drawText(item);
|
828
|
+
// other
|
829
|
+
else draw(item);
|
830
|
+
|
831
|
+
// select
|
832
|
+
if (item.se) drawSelect(item);
|
833
|
+
});
|
834
|
+
|
835
|
+
// canvas selection
|
836
|
+
drawSelection();
|
837
|
+
ui.restore();
|
838
|
+
}, []);
|
839
|
+
|
840
|
+
// Snap angle to nearest multiple of 15 degrees
|
841
|
+
const snapToAngle = React.useCallback((dx, dy) => {
|
842
|
+
// Current angle in radians
|
843
|
+
const angle = Math.atan2(dy, dx);
|
844
|
+
// Snap to nearest 15 degrees
|
845
|
+
const snappedAngle = Math.round(angle / (Math.PI / 12)) * (Math.PI / 12);
|
846
|
+
// Length of the vector
|
847
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
848
|
+
return {
|
849
|
+
x: Math.cos(snappedAngle) * length,
|
850
|
+
y: Math.sin(snappedAngle) * length
|
851
|
+
};
|
852
|
+
}, []);
|
853
|
+
const onMoveItems = React.useCallback((x, y) => {
|
854
|
+
const itemsSelected = getItems(true);
|
855
|
+
itemsSelected.forEach(item => {
|
856
|
+
const v = item.v;
|
857
|
+
|
858
|
+
// draw line
|
859
|
+
if (v === 'dl') {
|
860
|
+
item.p = item.p.map((value, index) => {
|
861
|
+
return index % 2 ? value + y : value + x;
|
862
|
+
});
|
863
|
+
}
|
864
|
+
|
865
|
+
// rectangle, draw point, object circle, ellipse, object line, object arrow, object triangle, image, text
|
866
|
+
if (['or', 'os', 'dp', 'oc', 'oe', 'ol', 'oa', 'ot', 'ote', 'i', 't'].includes(v)) {
|
867
|
+
item.p[0] += x;
|
868
|
+
item.p[1] += y;
|
869
|
+
}
|
870
|
+
|
871
|
+
// object line
|
872
|
+
if (['ol', 'oa'].includes(v)) {
|
873
|
+
item.ar[0] += x;
|
874
|
+
item.ar[1] += y;
|
875
|
+
}
|
876
|
+
|
877
|
+
// object triangle
|
878
|
+
if (['ot', 'ote'].includes(v)) {
|
879
|
+
item.p[2] += x;
|
880
|
+
item.p[4] += x;
|
881
|
+
item.p[3] += y;
|
882
|
+
item.p[5] += y;
|
883
|
+
}
|
884
|
+
});
|
885
|
+
onUpdateCoordinates();
|
886
|
+
}, []);
|
887
|
+
const onMove = React.useCallback((body, event) => {
|
888
|
+
if (!refs.on.current) return;
|
889
|
+
const {
|
890
|
+
offsetX: x,
|
891
|
+
offsetY: y,
|
892
|
+
clientX,
|
893
|
+
clientY
|
894
|
+
} = body;
|
895
|
+
const xo = transform(x - refs.move.current.x);
|
896
|
+
const yo = transform(y - refs.move.current.y);
|
897
|
+
const ui = refs.ui.current.getContext('2d');
|
898
|
+
const rect = refs.ui.current.getBoundingClientRect();
|
899
|
+
const currentX = clientX - rect.left;
|
900
|
+
const currentY = clientY - rect.top;
|
901
|
+
const start = refs.start.current;
|
902
|
+
const startTransformed = {
|
903
|
+
x: transform(start.x - refs.move.current.x),
|
904
|
+
y: transform(start.y - refs.move.current.y)
|
905
|
+
};
|
906
|
+
const item = getItem();
|
907
|
+
const items = getItems();
|
908
|
+
const t = refs.tool.current;
|
909
|
+
const shiftKey = event.shiftKey;
|
910
|
+
const zoom = refs.scale.current;
|
911
|
+
refs.mouseMove.current.current = {
|
912
|
+
x: clientX / zoom,
|
913
|
+
y: clientY / zoom
|
914
|
+
};
|
915
|
+
refs.mouseMove.current.delta = {
|
916
|
+
x: refs.mouseMove.current.previous ? refs.mouseMove.current.current.x - refs.mouseMove.current.previous.x : 0,
|
917
|
+
y: refs.mouseMove.current.previous ? refs.mouseMove.current.current.y - refs.mouseMove.current.previous.y : 0
|
918
|
+
};
|
919
|
+
refs.mouseMove.current.previous = _objectSpread({}, refs.mouseMove.current.current);
|
920
|
+
const delta = refs.mouseMove.current.delta;
|
921
|
+
|
922
|
+
// select
|
923
|
+
if (t === 'select') {
|
924
|
+
if (!refs.moveStarted.current) refs.moveStarted.current = true;
|
925
|
+
if (refs.select.current) {
|
926
|
+
unselectAll();
|
927
|
+
const width = currentX - start.x;
|
928
|
+
const height = currentY - start.y;
|
929
|
+
const isSquare = shiftKey;
|
930
|
+
const radius = 0;
|
931
|
+
if (isSquare) {
|
932
|
+
const side = Math.min(Math.abs(width), Math.abs(height));
|
933
|
+
refs.select.current.v = 'os';
|
934
|
+
refs.select.current.p = [startTransformed.x, startTransformed.y];
|
935
|
+
refs.select.current.ar = [transform(Math.sign(width) * side), transform(Math.sign(height) * side), radius];
|
936
|
+
} else {
|
937
|
+
refs.select.current.v = 'or';
|
938
|
+
refs.select.current.p = [startTransformed.x, startTransformed.y];
|
939
|
+
refs.select.current.ar = [transform(width), transform(height), radius];
|
940
|
+
}
|
941
|
+
} else onMoveItems(delta.x, delta.y);
|
942
|
+
}
|
943
|
+
// pen
|
944
|
+
else if (t === 'pen' && item) {
|
945
|
+
// same path from draw point, to move
|
946
|
+
if (!refs.moveStarted.current) {
|
947
|
+
item.v = 'dl';
|
948
|
+
refs.moveStarted.current = true;
|
949
|
+
}
|
950
|
+
|
951
|
+
// Add the current point to the path
|
952
|
+
item.p.push(xo, yo);
|
953
|
+
}
|
954
|
+
// pan
|
955
|
+
else if (t === 'pan') {
|
956
|
+
refs.move.current.x = x - refs.previous.current.x + refs.offset.current.x;
|
957
|
+
refs.move.current.y = y - refs.previous.current.y + refs.offset.current.y;
|
958
|
+
}
|
959
|
+
// eraser
|
960
|
+
else if (t === 'eraser') {
|
961
|
+
// find all items that x, y collides with, with certain radius
|
962
|
+
for (const i of items) {
|
963
|
+
const isPointInStroke = ui.isPointInStroke(getPath(i), xo, yo);
|
964
|
+
if (isPointInStroke) refs.remove.current.push(i);
|
965
|
+
}
|
966
|
+
}
|
967
|
+
// object line, object arrow
|
968
|
+
else if (['line', 'line-arrow'].includes(t)) {
|
969
|
+
const snapAt15Degrees = shiftKey;
|
970
|
+
let endX = currentX;
|
971
|
+
let endY = currentY;
|
972
|
+
if (snapAt15Degrees) {
|
973
|
+
const snapped = snapToAngle(currentX - start.x, currentY - start.y);
|
974
|
+
endX = start.x + snapped.x;
|
975
|
+
endY = start.y + snapped.y;
|
976
|
+
}
|
977
|
+
item.v = t === 'line' ? 'ol' : 'oa';
|
978
|
+
item.p = [startTransformed.x, startTransformed.y];
|
979
|
+
item.ar = [transform(endX - refs.move.current.x), transform(endY - refs.move.current.y)];
|
980
|
+
}
|
981
|
+
// object circle
|
982
|
+
else if (t === 'circle') {
|
983
|
+
const width = currentX - start.x;
|
984
|
+
const height = currentY - start.y;
|
985
|
+
const isCircle = shiftKey;
|
986
|
+
if (isCircle) {
|
987
|
+
const radius = Math.min(Math.abs(width), Math.abs(height)) / 2;
|
988
|
+
const centerX = start.x + Math.sign(width) * radius;
|
989
|
+
const centerY = start.y + Math.sign(height) * radius;
|
990
|
+
item.v = 'oc';
|
991
|
+
item.p = [transform(centerX - refs.move.current.x), transform(centerY - refs.move.current.y)];
|
992
|
+
item.ar = [transform(radius), 0, Math.PI * 2];
|
993
|
+
} else {
|
994
|
+
item.v = 'oe';
|
995
|
+
item.p = [transform(start.x + width / 2 - refs.move.current.x), transform(start.y + height / 2 - refs.move.current.y)];
|
996
|
+
item.ar = [transform(Math.abs(width) / 2), transform(Math.abs(height) / 2), 0, 0, Math.PI * 2];
|
997
|
+
}
|
998
|
+
}
|
999
|
+
// object rectangle
|
1000
|
+
else if (t === 'rectangle') {
|
1001
|
+
const width = currentX - start.x;
|
1002
|
+
const height = currentY - start.y;
|
1003
|
+
const isSquare = shiftKey;
|
1004
|
+
const radius = 0;
|
1005
|
+
if (isSquare) {
|
1006
|
+
const side = Math.min(Math.abs(width), Math.abs(height));
|
1007
|
+
item.v = 'os';
|
1008
|
+
item.p = [startTransformed.x, startTransformed.y];
|
1009
|
+
item.ar = [transform(Math.sign(width) * side), transform(Math.sign(height) * side), radius];
|
1010
|
+
} else {
|
1011
|
+
item.v = 'or';
|
1012
|
+
item.p = [startTransformed.x, startTransformed.y];
|
1013
|
+
item.ar = [transform(width), transform(height), radius];
|
1014
|
+
}
|
1015
|
+
}
|
1016
|
+
// object triangle
|
1017
|
+
else if (['triangle'].includes(t)) {
|
1018
|
+
const endX = xo;
|
1019
|
+
const endY = yo;
|
1020
|
+
const base = Math.abs(endX - startTransformed.x);
|
1021
|
+
const height = shiftKey ? base * Math.sqrt(3) / 2 : Math.abs(endY - startTransformed.y);
|
1022
|
+
const points = [startTransformed.x, startTransformed.y, endX, startTransformed.y, startTransformed.x + (endX - startTransformed.x) / 2, startTransformed.y - height];
|
1023
|
+
item.v = shiftKey ? 'ote' : 'ot';
|
1024
|
+
item.p = points;
|
1025
|
+
item.s = _objectSpread(_objectSpread({}, item.s), {}, {
|
1026
|
+
height
|
1027
|
+
});
|
1028
|
+
}
|
1029
|
+
// image
|
1030
|
+
else if (t === 'image' && refs.image.current.complete && refs.image.current.src) {
|
1031
|
+
const width = transform(currentX - start.x);
|
1032
|
+
const height = transform(currentY - start.y);
|
1033
|
+
const keepAspectRatio = !shiftKey;
|
1034
|
+
let currentWidth;
|
1035
|
+
let currentHeight;
|
1036
|
+
if (keepAspectRatio) {
|
1037
|
+
if (Math.abs(width / refs.aspectRatio.current) <= Math.abs(height)) {
|
1038
|
+
currentWidth = width;
|
1039
|
+
currentHeight = width / refs.aspectRatio.current;
|
1040
|
+
} else {
|
1041
|
+
currentWidth = height * refs.aspectRatio.current;
|
1042
|
+
currentHeight = height;
|
1043
|
+
}
|
1044
|
+
} else {
|
1045
|
+
currentWidth = width;
|
1046
|
+
currentHeight = height;
|
1047
|
+
}
|
1048
|
+
if (keepAspectRatio) {
|
1049
|
+
if (width < 0 && currentWidth > 0 || width > 0 && currentWidth < 0) currentWidth *= -1;
|
1050
|
+
if (height < 0 && currentHeight > 0 || height > 0 && currentHeight < 0) currentHeight *= -1;
|
1051
|
+
}
|
1052
|
+
item.p = [startTransformed.x, startTransformed.y];
|
1053
|
+
item.ar = [currentWidth, currentHeight];
|
1054
|
+
}
|
1055
|
+
|
1056
|
+
// select box
|
1057
|
+
onSelect();
|
1058
|
+
|
1059
|
+
// render
|
1060
|
+
render();
|
1061
|
+
}, []);
|
1062
|
+
const onMouseMove = React.useCallback(event => {
|
1063
|
+
const {
|
1064
|
+
offsetX,
|
1065
|
+
offsetY,
|
1066
|
+
clientX,
|
1067
|
+
clientY
|
1068
|
+
} = event;
|
1069
|
+
onMove({
|
1070
|
+
offsetX,
|
1071
|
+
offsetY,
|
1072
|
+
clientX,
|
1073
|
+
clientY
|
1074
|
+
}, event);
|
1075
|
+
}, [onMove]);
|
1076
|
+
const onTouchMove = React.useCallback(event => {
|
1077
|
+
// Get the first touch point
|
1078
|
+
const touch = event.touches[0];
|
1079
|
+
const {
|
1080
|
+
clientX,
|
1081
|
+
clientY
|
1082
|
+
} = touch;
|
1083
|
+
let {
|
1084
|
+
offsetX,
|
1085
|
+
offsetY
|
1086
|
+
} = touch;
|
1087
|
+
const targetElement = touch.target;
|
1088
|
+
if (targetElement instanceof HTMLElement) {
|
1089
|
+
// Get the bounding rectangle of the target element
|
1090
|
+
const rect = targetElement.getBoundingClientRect();
|
1091
|
+
|
1092
|
+
// Calculate the offsetX and offsetY
|
1093
|
+
offsetX = touch.clientX - rect.left;
|
1094
|
+
offsetY = touch.clientY - rect.top;
|
1095
|
+
}
|
1096
|
+
onMove({
|
1097
|
+
offsetX,
|
1098
|
+
offsetY,
|
1099
|
+
clientX,
|
1100
|
+
clientY
|
1101
|
+
}, event);
|
1102
|
+
}, [onInteractionDown]);
|
1103
|
+
const undo = React.useCallback(() => {
|
1104
|
+
if (!refs.undo.current.length) return;
|
1105
|
+
|
1106
|
+
// add current state to redo
|
1107
|
+
refs.redo.current.push([...getItems()]);
|
1108
|
+
|
1109
|
+
// restore the undo state
|
1110
|
+
refs.items.current = refs.undo.current.pop();
|
1111
|
+
|
1112
|
+
// render
|
1113
|
+
render();
|
1114
|
+
}, []);
|
1115
|
+
const redo = React.useCallback(() => {
|
1116
|
+
if (!refs.redo.current.length) return;
|
1117
|
+
|
1118
|
+
// add current state to undo
|
1119
|
+
refs.undo.current.push([...getItems()]);
|
1120
|
+
|
1121
|
+
// restore the redo state
|
1122
|
+
refs.items.current = refs.redo.current.pop();
|
1123
|
+
|
1124
|
+
// render
|
1125
|
+
render();
|
1126
|
+
}, []);
|
1127
|
+
const onWheel = React.useCallback(eventReact => {
|
1128
|
+
const event = eventReact.nativeEvent;
|
1129
|
+
|
1130
|
+
// zoom
|
1131
|
+
if (event.metaKey || event.ctrlKey) {
|
1132
|
+
setTool('zoom');
|
1133
|
+
refs.toolUpdateAuto.current = true;
|
1134
|
+
const zoomFactor = 1.054;
|
1135
|
+
const mouseX = event.offsetX;
|
1136
|
+
const mouseY = event.offsetY;
|
1137
|
+
const scale = refs.scale.current;
|
1138
|
+
|
1139
|
+
// Convert mouse position to canvas coordinates
|
1140
|
+
const canvasX = (mouseX - refs.move.current.x) / scale;
|
1141
|
+
const canvasY = (mouseY - refs.move.current.y) / scale;
|
1142
|
+
|
1143
|
+
// Adjust scale
|
1144
|
+
const zoomIn = event.deltaY < 0;
|
1145
|
+
const newScale = zoomIn ? scale * zoomFactor : scale / zoomFactor;
|
1146
|
+
if (newScale <= maxZoom / 100 && newScale >= minZoom / 100) {
|
1147
|
+
// Update origin to focus on mouse position
|
1148
|
+
refs.move.current.x -= canvasX * (newScale - scale);
|
1149
|
+
refs.move.current.y -= canvasY * (newScale - scale);
|
1150
|
+
refs.scale.current = newScale;
|
1151
|
+
render();
|
1152
|
+
}
|
1153
|
+
}
|
1154
|
+
// pan
|
1155
|
+
else if (!refs.mouseDown.current) {
|
1156
|
+
refs.move.current.x -= event.deltaX;
|
1157
|
+
refs.move.current.y -= event.deltaY;
|
1158
|
+
render();
|
1159
|
+
}
|
1160
|
+
}, [minZoom, maxZoom]);
|
1161
|
+
const onPaste = React.useCallback(event => {
|
1162
|
+
event.preventDefault();
|
1163
|
+
|
1164
|
+
// Get clipboard data
|
1165
|
+
const items = Array.from(event.clipboardData.items);
|
1166
|
+
|
1167
|
+
// Loop through clipboard items to find an image
|
1168
|
+
for (const item of items) {
|
1169
|
+
if (item.type.startsWith('image/')) {
|
1170
|
+
// Get the image file
|
1171
|
+
const blob = item.getAsFile();
|
1172
|
+
refs.image.current = new Image();
|
1173
|
+
|
1174
|
+
// Load the image and draw it on the canvas
|
1175
|
+
refs.image.current.onload = () => {
|
1176
|
+
refs.aspectRatio.current = refs.image.current.width / refs.image.current.height;
|
1177
|
+
|
1178
|
+
// Todo
|
1179
|
+
// 1) Upload the image first, than read it in image src
|
1180
|
+
// 2) Add url of the image
|
1181
|
+
// instead of embeding the image
|
1182
|
+
const item_ = {
|
1183
|
+
i: getID(),
|
1184
|
+
v: 'i',
|
1185
|
+
p: [],
|
1186
|
+
ar: [],
|
1187
|
+
s: {
|
1188
|
+
// Todo
|
1189
|
+
// remove in the future
|
1190
|
+
image: refs.image.current,
|
1191
|
+
aspectRatio: refs.aspectRatio.current
|
1192
|
+
},
|
1193
|
+
a: OnesyDate.milliseconds
|
1194
|
+
};
|
1195
|
+
add(item_);
|
1196
|
+
setTool('image');
|
1197
|
+
};
|
1198
|
+
|
1199
|
+
// Create an object URL for the blob and set it as the image source
|
1200
|
+
refs.image.current.src = URL.createObjectURL(blob);
|
1201
|
+
break;
|
1202
|
+
}
|
1203
|
+
}
|
1204
|
+
}, []);
|
1205
|
+
React.useEffect(() => {
|
1206
|
+
const method = () => {
|
1207
|
+
const width = refs.root.current.offsetWidth;
|
1208
|
+
const height = refs.root.current.offsetHeight;
|
1209
|
+
setSize({
|
1210
|
+
width,
|
1211
|
+
height
|
1212
|
+
});
|
1213
|
+
};
|
1214
|
+
const onKeyUp = event => {
|
1215
|
+
if (refs.toolUpdateAuto.current) setTool(refs.previousTool.current || 'pen');
|
1216
|
+
};
|
1217
|
+
const onKeyDown = async event => {
|
1218
|
+
refs.toolUpdateAuto.current = false;
|
1219
|
+
const {
|
1220
|
+
key
|
1221
|
+
} = event;
|
1222
|
+
const itemsAll = [...refs.items.current].filter(Boolean);
|
1223
|
+
const t = refs.tool.current;
|
1224
|
+
const zoom = refs.scale.current;
|
1225
|
+
if (['a', 'A'].includes(key) && (event.metaKey || event.ctrlKey)) {
|
1226
|
+
event.preventDefault();
|
1227
|
+
selectAll();
|
1228
|
+
render();
|
1229
|
+
} else if (t === 'select' && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace'].includes(key)) {
|
1230
|
+
const value = event.shiftKey ? 10 : 1;
|
1231
|
+
if (key === 'ArrowUp') onMoveItems(0, -value / zoom);
|
1232
|
+
if (key === 'ArrowDown') onMoveItems(0, value / zoom);
|
1233
|
+
if (key === 'ArrowLeft') onMoveItems(-value / zoom, 0);
|
1234
|
+
if (key === 'ArrowRight') onMoveItems(value / zoom, 0);
|
1235
|
+
if (key === 'Backspace') {
|
1236
|
+
const toRemove = [];
|
1237
|
+
refs.items.current.forEach(item => {
|
1238
|
+
if (item.se) toRemove.push(item);
|
1239
|
+
});
|
1240
|
+
if (toRemove.length) remove(toRemove);
|
1241
|
+
}
|
1242
|
+
render();
|
1243
|
+
} else if (key === 'Escape') {
|
1244
|
+
setTool('select');
|
1245
|
+
refs.textActive.current = null;
|
1246
|
+
unselectAll();
|
1247
|
+
filterItems();
|
1248
|
+
render();
|
1249
|
+
} else if (tool === 'text') {
|
1250
|
+
const selectedTextBox = itemsAll.find(item => item.v === 't' && item.se);
|
1251
|
+
if (!selectedTextBox) return;
|
1252
|
+
const {
|
1253
|
+
line,
|
1254
|
+
char
|
1255
|
+
} = selectedTextBox.s.cursor;
|
1256
|
+
const lines = selectedTextBox.s.lines;
|
1257
|
+
const currentLine = lines[line];
|
1258
|
+
if (['ArrowLeft', 'ArrowRight'].includes(key)) {
|
1259
|
+
event.preventDefault();
|
1260
|
+
selectedTextBox.s.cursor.char += key === 'ArrowLeft' ? -1 : 1;
|
1261
|
+
if (selectedTextBox.s.cursor.char < 0) {
|
1262
|
+
selectedTextBox.s.cursor.line--;
|
1263
|
+
selectedTextBox.s.cursor.char = lines[selectedTextBox.s.cursor.line]?.length;
|
1264
|
+
} else if (selectedTextBox.s.cursor.char > lines[line]?.length && line !== lines.length - 1) {
|
1265
|
+
selectedTextBox.s.cursor.line++;
|
1266
|
+
selectedTextBox.s.cursor.char = 0;
|
1267
|
+
}
|
1268
|
+
selectedTextBox.s.cursor.line = clamp(selectedTextBox.s.cursor.line, 0, lines.length - 1);
|
1269
|
+
selectedTextBox.s.cursor.char = clamp(selectedTextBox.s.cursor.char, 0, lines[selectedTextBox.s.cursor.line]?.length);
|
1270
|
+
}
|
1271
|
+
if (['ArrowUp', 'ArrowDown'].includes(key)) {
|
1272
|
+
event.preventDefault();
|
1273
|
+
selectedTextBox.s.cursor.line += key === 'ArrowUp' ? -1 : 1;
|
1274
|
+
selectedTextBox.s.cursor.line = clamp(selectedTextBox.s.cursor.line, 0, lines.length - 1);
|
1275
|
+
selectedTextBox.s.cursor.char = clamp(selectedTextBox.s.cursor.char, 0, lines[selectedTextBox.s.cursor.line]?.length);
|
1276
|
+
} else if (key === 'Enter') {
|
1277
|
+
event.preventDefault();
|
1278
|
+
const newLine = currentLine.slice(char);
|
1279
|
+
lines[line] = currentLine.slice(0, char);
|
1280
|
+
lines.splice(line + 1, 0, newLine);
|
1281
|
+
selectedTextBox.s.cursor.line++;
|
1282
|
+
selectedTextBox.s.cursor.char = 0;
|
1283
|
+
} else if (key === 'Backspace') {
|
1284
|
+
event.preventDefault();
|
1285
|
+
if (char > 0) {
|
1286
|
+
lines[line] = currentLine.slice(0, char - 1) + currentLine.slice(char);
|
1287
|
+
selectedTextBox.s.cursor.char--;
|
1288
|
+
} else if (line > 0) {
|
1289
|
+
const prevLine = lines[line - 1];
|
1290
|
+
selectedTextBox.s.cursor.char = prevLine.length;
|
1291
|
+
lines[line - 1] += lines[line];
|
1292
|
+
lines.splice(line, 1);
|
1293
|
+
selectedTextBox.s.cursor.line--;
|
1294
|
+
}
|
1295
|
+
} else if (key.length === 1) {
|
1296
|
+
if ([' '].includes(key)) event.preventDefault();
|
1297
|
+
let textClipboard = '';
|
1298
|
+
const isPaste = (event.ctrlKey || event.metaKey) && ['v', 'V'].includes(key);
|
1299
|
+
if (isPaste) {
|
1300
|
+
try {
|
1301
|
+
textClipboard = await window.navigator.clipboard.readText();
|
1302
|
+
} catch (error) {}
|
1303
|
+
}
|
1304
|
+
const text = isPaste ? textClipboard : key;
|
1305
|
+
selectedTextBox.s.lines[line] = currentLine.slice(0, char) + text + currentLine.slice(char);
|
1306
|
+
selectedTextBox.s.cursor.char += text.length;
|
1307
|
+
}
|
1308
|
+
updateTextBoxDimensions(selectedTextBox);
|
1309
|
+
selectedTextBox.c = [...selectedTextBox.p, ...selectedTextBox.ar];
|
1310
|
+
render();
|
1311
|
+
} else {
|
1312
|
+
if (event.metaKey && key === 'z') {
|
1313
|
+
if (event.shiftKey) redo();else undo();
|
1314
|
+
}
|
1315
|
+
if (key === ' ') {
|
1316
|
+
refs.toolUpdateAuto.current = true;
|
1317
|
+
setTool('pan');
|
1318
|
+
}
|
1319
|
+
|
1320
|
+
// tools
|
1321
|
+
if (event.shiftKey) {
|
1322
|
+
if (['E', 'D', 'P', 'S', 'C', 'R', 'I', 'L', 'A', 'T', 'G'].includes(key)) refs.toolUpdateAuto.current = false;
|
1323
|
+
if (key === 'E') setTool('eraser');
|
1324
|
+
if (key === 'D') setTool('pen');
|
1325
|
+
if (key === 'P') setTool('pan');
|
1326
|
+
if (key === 'S') setTool('select');
|
1327
|
+
if (key === 'C') setTool('circle');
|
1328
|
+
if (key === 'R') setTool('rectangle');
|
1329
|
+
if (key === 'I') setTool('triangle');
|
1330
|
+
if (key === 'L') setTool('line');
|
1331
|
+
if (key === 'A') setTool('line-arrow');
|
1332
|
+
if (key === 'T') setTool('text');
|
1333
|
+
if (key === 'G') {
|
1334
|
+
setGrid(previous => !previous);
|
1335
|
+
render();
|
1336
|
+
}
|
1337
|
+
}
|
1338
|
+
}
|
1339
|
+
};
|
1340
|
+
window.addEventListener('resize', method);
|
1341
|
+
window.document.addEventListener('mouseup', onMouseUp);
|
1342
|
+
window.document.addEventListener('touchend', onMouseUp);
|
1343
|
+
window.document.addEventListener('mousemove', onMouseMove);
|
1344
|
+
window.document.addEventListener('touchmove', onTouchMove);
|
1345
|
+
window.document.addEventListener('keyup', onKeyUp);
|
1346
|
+
window.document.addEventListener('keydown', onKeyDown);
|
1347
|
+
window.document.addEventListener('paste', onPaste);
|
1348
|
+
method();
|
1349
|
+
init();
|
1350
|
+
return () => {
|
1351
|
+
window.removeEventListener('resize', method);
|
1352
|
+
window.document.removeEventListener('mouseup', onMouseUp);
|
1353
|
+
window.document.removeEventListener('touchend', onMouseUp);
|
1354
|
+
window.document.removeEventListener('mousemove', onMouseMove);
|
1355
|
+
window.document.removeEventListener('touchmove', onTouchMove);
|
1356
|
+
window.document.removeEventListener('keyup', onKeyUp);
|
1357
|
+
window.document.removeEventListener('keydown', onKeyDown);
|
1358
|
+
window.document.removeEventListener('paste', onPaste);
|
1359
|
+
};
|
1360
|
+
}, []);
|
1361
|
+
const onChangeInputFile = React.useCallback(event => {
|
1362
|
+
const file = event.target.files[0];
|
1363
|
+
if (file) {
|
1364
|
+
const reader = new FileReader();
|
1365
|
+
reader.onload = eventReader => {
|
1366
|
+
refs.image.current = new Image();
|
1367
|
+
refs.image.current.src = eventReader.target.result;
|
1368
|
+
refs.toolUpdateAuto.current = true;
|
1369
|
+
event.target.value = '';
|
1370
|
+
setTool('image');
|
1371
|
+
};
|
1372
|
+
reader.readAsDataURL(file);
|
1373
|
+
}
|
1374
|
+
}, []);
|
1375
|
+
const propsCanvas = {
|
1376
|
+
width: size.width,
|
1377
|
+
height: size.height,
|
1378
|
+
disabled: !loaded,
|
1379
|
+
style: {
|
1380
|
+
width: size.width,
|
1381
|
+
height: size.height
|
1382
|
+
}
|
1383
|
+
};
|
1384
|
+
return /*#__PURE__*/React.createElement(Line, _extends({
|
1385
|
+
ref: item => {
|
1386
|
+
if (ref) {
|
1387
|
+
if (is('function', ref)) ref(item);else ref.current = item;
|
1388
|
+
}
|
1389
|
+
refs.root.current = item;
|
1390
|
+
},
|
1391
|
+
flex: true,
|
1392
|
+
fullWidth: true,
|
1393
|
+
className: classNames([staticClassName('Whiteboard', theme) && ['onesy-Whiteboard-root'], className, classes.root])
|
1394
|
+
}, other), /*#__PURE__*/React.createElement("div", {
|
1395
|
+
id: "controls",
|
1396
|
+
style: {
|
1397
|
+
position: 'absolute',
|
1398
|
+
zIndex: 14,
|
1399
|
+
top: 12,
|
1400
|
+
left: '50%'
|
1401
|
+
}
|
1402
|
+
}, /*#__PURE__*/React.createElement("input", {
|
1403
|
+
type: "file",
|
1404
|
+
accept: "image/*",
|
1405
|
+
onChange: onChangeInputFile
|
1406
|
+
})), /*#__PURE__*/React.createElement("canvas", _extends({
|
1407
|
+
ref: refs.ui,
|
1408
|
+
onMouseDown: onMouseDown,
|
1409
|
+
onTouchStart: onTouchStart,
|
1410
|
+
onWheel: onWheel,
|
1411
|
+
className: classNames([classes.canvas, classes.interactive, tool === 'pan' && classes[!mouseDown ? 'pan' : 'panning'], tool === 'image' && classes.image, tool === 'text' && classes.text, ['pen', 'eraser', 'zoom'].includes(tool) && classes[tool], ['circle', 'rectangle', 'triangle', 'line', 'arrow'].includes(tool) && classes.object])
|
1412
|
+
}, propsCanvas)));
|
1413
|
+
});
|
1414
|
+
Whiteboard.displayName = 'onesy-Whiteboard';
|
1415
|
+
export default Whiteboard;
|