@@ -36,6 +36,14 @@ def:pub Canvas(props: dict) -> JsxElement {
3636 textInputRef = useRef(None );
3737 [spacePressed, setSpacePressed] = useState(False );
3838 [contextMenu, setContextMenu] = useState(None );
39+ clipboardRef = useRef([]);
40+ selectedElementRef = useRef(None );
41+ selectedElementsRef = useRef([]);
42+ altDragCopiedRef = useRef(False );
43+
44+ # Keep refs in sync with selection state
45+ selectedElementRef.current = selectionHook.selectedElement;
46+ selectedElementsRef.current = selectionHook.selectedElements;
3947
4048 can with [elementsHook .elements , canvasHook .canvasSize , viewportHook .zoom , viewportHook .panOffset , selectionHook .selectedElement , selectionHook .selectedElements , selectionHook .isBoxSelecting , selectionHook .boxStart , selectionHook .boxEnd ] entry {
4149 canvas = canvasRef.current;
@@ -78,6 +86,65 @@ def:pub Canvas(props: dict) -> JsxElement {
7886 }
7987 selectionHook.clearSelection();
8088 }
89+ # Copy (use refs for always-current selection state)
90+ if not textInputHook.textInput and (e.key == " c" or e.key == " C" ) and (e.ctrlKey or e.metaKey) {
91+ e.preventDefault();
92+ curMulti = selectedElementsRef.current;
93+ curSingle = selectedElementRef.current;
94+ if curMulti and curMulti.length > 0 {
95+ copied = [];
96+ ci = 0 ;
97+ while ci < curMulti.length {
98+ copied.push(Object.assign({}, curMulti[ci].element));
99+ ci = ci + 1 ;
100+ }
101+ clipboardRef.current = copied;
102+ } elif curSingle and curSingle.element {
103+ clipboardRef.current = [Object.assign({}, curSingle.element)];
104+ }
105+ }
106+ # Paste
107+ if not textInputHook.textInput and (e.key == " v" or e.key == " V" ) and (e.ctrlKey or e.metaKey) {
108+ e.preventDefault();
109+ if clipboardRef.current.length > 0 {
110+ newElements = [];
111+ pi = 0 ;
112+ while pi < clipboardRef.current.length {
113+ original = clipboardRef.current[pi];
114+ newEl = Object.assign({}, original);
115+ if newEl.type == " text" or newEl.type == " rectangle" or newEl.type == " circle" or newEl.type == " image" or newEl.type == " diamond" {
116+ newEl.x = original.x + 20 ;
117+ newEl.y = original.y + 20 ;
118+ } elif newEl.type == " line" or newEl.type == " arrow" {
119+ newEl.x1 = original.x1 + 20 ;
120+ newEl.y1 = original.y1 + 20 ;
121+ newEl.x2 = original.x2 + 20 ;
122+ newEl.y2 = original.y2 + 20 ;
123+ } elif newEl.type == " freehand" {
124+ newEl.points = original.points.map(lambda pt: dict -> dict {
125+ return {" x" : pt.x + 20 , " y" : pt.y + 20 };
126+ });
127+ }
128+ newElements.push(newEl);
129+ pi = pi + 1 ;
130+ }
131+ baseIndex = elementsHook.elements.length;
132+ elementsHook.addMultipleElements(newElements);
133+ # Auto-select pasted elements
134+ pastedSelection = [];
135+ psi = 0 ;
136+ while psi < newElements.length {
137+ pastedSelection.push({" element" : newElements[psi], " index" : baseIndex + psi});
138+ psi = psi + 1 ;
139+ }
140+ selectionHook.setSelectedElements(pastedSelection);
141+ if pastedSelection.length == 1 {
142+ selectionHook.setSelectedElement(pastedSelection[0 ]);
143+ } else {
144+ selectionHook.setSelectedElement(None );
145+ }
146+ }
147+ }
81148 # Tool switching shortcuts
82149 if not textInputHook.textInput and not e.ctrlKey and not e.metaKey and not e.shiftKey {
83150 shortcuts = TOOL_SHORTCUTS();
@@ -100,7 +167,7 @@ def:pub Canvas(props: dict) -> JsxElement {
100167 window.removeEventListener(" keydown" , handleKeyDown);
101168 window.removeEventListener(" keyup" , handleKeyUp);
102169 };
103- }, [selectionHook.selectedElement, selectionHook.selectedElements, textInputHook.textInput, elementsHook.elements]);
170+ }, [textInputHook.textInput, elementsHook.elements]);
104171
105172 useEffect(lambda -> any {
106173 def handlePaste(e: dict ) -> None {
@@ -275,17 +342,19 @@ def:pub Canvas(props: dict) -> JsxElement {
275342 }
276343
277344 def deleteSelected () -> None {
278- if selectionHook.selectedElements.length > 0 {
279- indices = [];
280- i = 0 ;
281- while i < selectionHook.selectedElements.length {
282- indices.push(selectionHook.selectedElements[i].index);
283- i = i + 1 ;
345+ curMulti = selectedElementsRef.current;
346+ curSingle = selectedElementRef.current;
347+ if curMulti and curMulti.length > 0 {
348+ delIndices = [];
349+ di = 0 ;
350+ while di < curMulti.length {
351+ delIndices.push(curMulti[di].index);
352+ di = di + 1 ;
284353 }
285- elementsHook.removeMultipleElements(indices );
354+ elementsHook.removeMultipleElements(delIndices );
286355 selectionHook.clearSelection();
287- } elif selectionHook.selectedElement {
288- elementsHook.removeElement(selectionHook.selectedElement .index);
356+ } elif curSingle {
357+ elementsHook.removeElement(curSingle .index);
289358 selectionHook.clearSelection();
290359 }
291360 }
@@ -304,24 +373,47 @@ def:pub Canvas(props: dict) -> JsxElement {
304373 setContextMenu(None );
305374 }
306375
376+ def offsetElement (original : dict , dx : float , dy : float ) -> dict {
377+ dup = Object.assign({}, original);
378+ if original.type == " text" or original.type == " rectangle" or original.type == " circle" or original.type == " image" or original.type == " diamond" {
379+ dup.x = original.x + dx;
380+ dup.y = original.y + dy;
381+ } elif original.type == " line" or original.type == " arrow" {
382+ dup.x1 = original.x1 + dx;
383+ dup.y1 = original.y1 + dy;
384+ dup.x2 = original.x2 + dx;
385+ dup.y2 = original.y2 + dy;
386+ } elif original.type == " freehand" {
387+ dup.points = original.points.map(lambda p: dict -> dict {
388+ return {" x" : p.x + dx, " y" : p.y + dy};
389+ });
390+ }
391+ return dup;
392+ }
393+
307394 def handleDuplicate () -> None {
308- if selectionHook.selectedElement {
309- original = selectionHook.selectedElement.element;
310- duplicate = Object.assign({}, original);
311- if original.type == " text" or original.type == " rectangle" or original.type == " circle" or original.type == " image" or original.type == " diamond" {
312- duplicate.x = original.x + 20 ;
313- duplicate.y = original.y + 20 ;
314- } elif original.type == " line" or original.type == " arrow" {
315- duplicate.x1 = original.x1 + 20 ;
316- duplicate.y1 = original.y1 + 20 ;
317- duplicate.x2 = original.x2 + 20 ;
318- duplicate.y2 = original.y2 + 20 ;
319- } elif original.type == " freehand" {
320- duplicate.points = original.points.map(lambda p: dict -> dict {
321- return {" x" : p.x + 20 , " y" : p.y + 20 };
322- });
395+ curMultiDup = selectedElementsRef.current;
396+ curSingleDup = selectedElementRef.current;
397+ if curMultiDup and curMultiDup.length > 0 {
398+ dups = [];
399+ ddi = 0 ;
400+ while ddi < curMultiDup.length {
401+ dups.push(offsetElement(curMultiDup[ddi].element, 20 , 20 ));
402+ ddi = ddi + 1 ;
323403 }
324- elementsHook.addElement(duplicate);
404+ dupBase = elementsHook.elements.length;
405+ elementsHook.addMultipleElements(dups);
406+ dupSel = [];
407+ dsi = 0 ;
408+ while dsi < dups.length {
409+ dupSel.push({" element" : dups[dsi], " index" : dupBase + dsi});
410+ dsi = dsi + 1 ;
411+ }
412+ selectionHook.setSelectedElements(dupSel);
413+ selectionHook.setSelectedElement(None );
414+ } elif curSingleDup and curSingleDup.element {
415+ singleDup = offsetElement(curSingleDup.element, 20 , 20 );
416+ elementsHook.addElement(singleDup);
325417 }
326418 setContextMenu(None );
327419 }
@@ -416,22 +508,53 @@ def:pub Canvas(props: dict) -> JsxElement {
416508
417509 elementInfo = selectionHook.findElementAtPoint(pos, elementsHook.elements);
418510 if elementInfo {
419- isInSelection = False ;
420- if selectionHook.selectedElements.length > 0 {
421- i = 0 ;
422- while i < selectionHook.selectedElements.length {
423- if selectionHook.selectedElements[i].index == elementInfo.index {
424- isInSelection = True ;
511+ altDragCopiedRef.current = False ;
512+
513+ if e.shiftKey {
514+ # Shift+Click: toggle element in multi-selection
515+ isAlreadySelected = False ;
516+ si = 0 ;
517+ while si < selectionHook.selectedElements.length {
518+ if selectionHook.selectedElements[si].index == elementInfo.index {
519+ isAlreadySelected = True ;
425520 }
426- i = i + 1 ;
521+ si = si + 1 ;
427522 }
428- }
429523
430- if isInSelection {
431- selectionHook.setIsDragging(True );
432- selectionHook.setDragStartPos(pos);
524+ if isAlreadySelected {
525+ selectionHook.removeFromSelection(elementInfo.index);
526+ } else {
527+ # Build the full list in one go to avoid stale state
528+ newMulti = selectionHook.selectedElements.slice();
529+ # If there's a single selectedElement not yet in the list, include it
530+ if selectionHook.selectedElement and newMulti.length == 0 {
531+ newMulti.push(selectionHook.selectedElement);
532+ }
533+ newMulti.push(elementInfo);
534+ selectionHook.setSelectedElements(newMulti);
535+ selectionHook.setSelectedElement(None );
536+ }
433537 } else {
434- selectionHook.startDragging(elementInfo, pos);
538+ # Normal click (no Shift)
539+ isInMulti = False ;
540+ if selectionHook.selectedElements.length > 0 {
541+ mi = 0 ;
542+ while mi < selectionHook.selectedElements.length {
543+ if selectionHook.selectedElements[mi].index == elementInfo.index {
544+ isInMulti = True ;
545+ }
546+ mi = mi + 1 ;
547+ }
548+ }
549+
550+ if isInMulti {
551+ # Clicking on element already in multi-selection: start group drag
552+ selectionHook.setIsDragging(True );
553+ selectionHook.setDragStartPos(pos);
554+ } else {
555+ # Clicking on new element: single-select it
556+ selectionHook.startDragging(elementInfo, pos);
557+ }
435558 }
436559 } else {
437560 selectionHook.clearSelection();
@@ -581,6 +704,39 @@ def:pub Canvas(props: dict) -> JsxElement {
581704 }
582705
583706 if selectionHook.isDragging and currentTool == " select" {
707+ # Alt+Drag to duplicate: clone originals in place, then drag copies
708+ if e.altKey and not altDragCopiedRef.current {
709+ if selectionHook.selectedElements.length > 1 {
710+ clones = [];
711+ aci = 0 ;
712+ while aci < selectionHook.selectedElements.length {
713+ clones.push(Object.assign({}, selectionHook.selectedElements[aci].element));
714+ aci = aci + 1 ;
715+ }
716+ altBase = elementsHook.elements.length;
717+ elementsHook.addMultipleElements(clones);
718+ # Point selection to the new copies (at end of array)
719+ newSel = [];
720+ nsi = 0 ;
721+ while nsi < clones.length {
722+ newSel.push({" element" : clones[nsi], " index" : altBase + nsi});
723+ nsi = nsi + 1 ;
724+ }
725+ selectionHook.setSelectedElements(newSel);
726+ selectionHook.setDragStartPos(pos);
727+ altDragCopiedRef.current = True ;
728+ return ;
729+ } elif selectionHook.selectedElement {
730+ singleClone = Object.assign({}, selectionHook.selectedElement.element);
731+ altSingleBase = elementsHook.elements.length;
732+ elementsHook.addElement(singleClone);
733+ selectionHook.setSelectedElement({" element" : singleClone, " index" : altSingleBase});
734+ selectionHook.setSelectedElements([]);
735+ altDragCopiedRef.current = True ;
736+ return ;
737+ }
738+ }
739+
584740 if selectionHook.selectedElements.length > 1 and selectionHook.dragStartPos {
585741 multiDx = pos.x - selectionHook.dragStartPos.x;
586742 multiDy = pos.y - selectionHook.dragStartPos.y;
@@ -650,6 +806,10 @@ def:pub Canvas(props: dict) -> JsxElement {
650806 canvas = canvasRef.current;
651807 ctx = canvas.getContext(" 2d" );
652808
809+ # Redraw everything first (clears canvas)
810+ canvasHook.redrawCanvas(elementsHook.elements, viewportHook.zoom, viewportHook.panOffset, selectionHook.selectedElement, selectionHook.selectedElements, selectionHook.isBoxSelecting, selectionHook.boxStart, selectionHook.boxEnd);
811+
812+ # Draw the in-progress freehand path on top
653813 ctx.save();
654814 ctx.translate(viewportHook.panOffset.x, viewportHook.panOffset.y);
655815 ctx.scale(viewportHook.zoom, viewportHook.zoom);
@@ -658,21 +818,22 @@ def:pub Canvas(props: dict) -> JsxElement {
658818 ctx.lineWidth = currentStrokeWidth;
659819 ctx.lineCap = " round" ;
660820 ctx.lineJoin = " round" ;
821+ ctx.setLineDash([]);
661822 ctx.beginPath();
662823
663- pathLen = drawingState.currentPath.length;
664- if pathLen >= 2 {
665- prev = drawingState.currentPath[pathLen - 2 ];
666- last = drawingState.currentPath[pathLen - 1 ];
667- midX = (last.x + pos.x) / 2 ;
668- midY = (last.y + pos.y) / 2 ;
669- ctx.moveTo(last.x, last.y);
670- ctx.quadraticCurveTo(last.x, last.y, midX, midY);
671- ctx.stroke();
672- } elif pathLen == 1 {
673- ctx.moveTo(drawingState.currentPath[0 ].x, drawingState.currentPath[0 ].y);
674- ctx.lineTo(pos.x, pos.y);
824+ if newPath.length >= 2 {
825+ ctx.moveTo(newPath[0 ].x, newPath[0 ].y);
826+ fpi = 1 ;
827+ while fpi < newPath.length {
828+ fpMidX = (newPath[fpi - 1 ].x + newPath[fpi].x) / 2 ;
829+ fpMidY = (newPath[fpi - 1 ].y + newPath[fpi].y) / 2 ;
830+ ctx.quadraticCurveTo(newPath[fpi - 1 ].x, newPath[fpi - 1 ].y, fpMidX, fpMidY);
831+ fpi = fpi + 1 ;
832+ }
675833 ctx.stroke();
834+ } elif newPath.length == 1 {
835+ ctx.arc(newPath[0 ].x, newPath[0 ].y, currentStrokeWidth / 2 , 0 , Math.PI * 2 );
836+ ctx.fill();
676837 }
677838
678839 ctx.restore();
0 commit comments