From 4e4c1b1dfdc3abb4f9e8549f9d070677ae469d51 Mon Sep 17 00:00:00 2001 From: SahanUday Date: Fri, 17 Apr 2026 03:00:33 +0530 Subject: [PATCH] feat: Add keyboard shortcuts help and confirmation dialog; enhance canvas interactions --- components/Canvas.cl.jac | 91 +++++++++++++++++-- components/layout/ConfirmDialog.cl.jac | 59 +++++++++++++ components/layout/ShortcutHelp.cl.jac | 117 +++++++++++++++++++++++++ components/layout/TopBar.cl.jac | 10 ++- main.jac | 6 ++ services/export.cl.jac | 4 + tests/e2e/test_jasketch.py | 100 +++++++++++++++++++++ 7 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 components/layout/ConfirmDialog.cl.jac create mode 100644 components/layout/ShortcutHelp.cl.jac diff --git a/components/Canvas.cl.jac b/components/Canvas.cl.jac index f12da4b..16d6df0 100644 --- a/components/Canvas.cl.jac +++ b/components/Canvas.cl.jac @@ -14,8 +14,12 @@ import from "..constants.tools" { TOOL_SHORTCUTS } import from .canvas.CanvasRenderer { CanvasRenderer } import from .canvas.TextInput { TextInput } import from .canvas.ContextMenu { ContextMenu } +import from .layout.ShortcutHelp { ShortcutHelp } +import from .layout.ConfirmDialog { ConfirmDialog } def:pub Canvas(props: dict) -> JsxElement { + showShortcutsProp = props.showShortcuts or False; + onToggleShortcuts = props.onToggleShortcuts; currentTool = props.currentTool or "select"; currentColor = props.currentColor or "#000000"; currentStrokeWidth = props.currentStrokeWidth or 2; @@ -45,6 +49,8 @@ def:pub Canvas(props: dict) -> JsxElement { selectedElementsRef = useRef([]); altDragCopiedRef = useRef(False); drawingStateRef = useRef(drawingState); + viewportHookRef = useRef(viewportHook); + selectionHookRef = useRef(selectionHook); has isDraggingBend: bool = False; has isRotating: bool = False; has rotatingElementId: any = None; @@ -54,10 +60,13 @@ def:pub Canvas(props: dict) -> JsxElement { has draggingVertexHandle: str = ""; has isDraggingElbowSegment: bool = False; has draggingElbowSegIndex: int = 0; + has showResetConfirm: bool = False; selectedElementRef.current = selectionHook.selectedElement; selectedElementsRef.current = selectionHook.selectedElements; drawingStateRef.current = drawingState; + viewportHookRef.current = viewportHook; + selectionHookRef.current = selectionHook; # Pass editing shape id to suppress its shapeText during editing editingShapeId = None; @@ -225,7 +234,7 @@ def:pub Canvas(props: dict) -> JsxElement { canvas = canvasRef.current; if canvas { rect = canvas.getBoundingClientRect(); - viewportHook.handleZoom(-1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas); + viewportHookRef.current.handleZoom(-1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas); } } if not textInputHook.textInput and e.key == "-" and (e.ctrlKey or e.metaKey) { @@ -233,13 +242,13 @@ def:pub Canvas(props: dict) -> JsxElement { canvas = canvasRef.current; if canvas { rect = canvas.getBoundingClientRect(); - viewportHook.handleZoom(1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas); + viewportHookRef.current.handleZoom(1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas); } } if not textInputHook.textInput and e.key == "0" and (e.ctrlKey or e.metaKey) { e.preventDefault(); - viewportHook.setZoom(1); - viewportHook.setPanOffset({"x": 0, "y": 0}); + viewportHookRef.current.setZoom(1); + viewportHookRef.current.setPanOffset({"x": 0, "y": 0}); } if not textInputHook.textInput and (e.key == "s" or e.key == "S") and (e.ctrlKey or e.metaKey) and not e.shiftKey { e.preventDefault(); @@ -249,6 +258,58 @@ def:pub Canvas(props: dict) -> JsxElement { e.preventDefault(); triggerJaSketchLoad(); } + if not textInputHook.textInput and (e.key == "x" or e.key == "X") and (e.ctrlKey or e.metaKey) { + e.preventDefault(); + curMultiCut = selectedElementsRef.current; + curSingleCut = selectedElementRef.current; + if curMultiCut and curMultiCut.length > 0 { + copied = []; + ci = 0; + while ci < curMultiCut.length { + copied.push(Object.assign({}, curMultiCut[ci].element)); + ci = ci + 1; + } + clipboardRef.current = copied; + } elif curSingleCut and curSingleCut.element { + clipboardRef.current = [Object.assign({}, curSingleCut.element)]; + } + deleteSelected(); + } + if not textInputHook.textInput and e.key == "Delete" and (e.ctrlKey or e.metaKey) { + e.preventDefault(); + showResetConfirm = True; + } + if not textInputHook.textInput and (e.key == "<" or e.key == ",") and (e.ctrlKey or e.metaKey) and e.shiftKey { + e.preventDefault(); + curSel = selectedElementRef.current; + if curSel and curSel.element.type == "text" { + element = curSel.element; + newSize = element.fontSize or 16; + newSize = newSize - 2; + if newSize < 8 { + newSize = 8; + } + elementsHook.updateElement(element.id, {**element, "fontSize": newSize}); + selectionHookRef.current.setSelectedElement({"element": {**element, "fontSize": newSize}, "id": element.id}); + } + } + if not textInputHook.textInput and (e.key == ">" or e.key == ".") and (e.ctrlKey or e.metaKey) and e.shiftKey { + e.preventDefault(); + curSel = selectedElementRef.current; + if curSel and curSel.element.type == "text" { + element = curSel.element; + newSize = element.fontSize or 16; + newSize = newSize + 2; + elementsHook.updateElement(element.id, {**element, "fontSize": newSize}); + selectionHookRef.current.setSelectedElement({"element": {**element, "fontSize": newSize}, "id": element.id}); + } + } + if not textInputHook.textInput and e.key == "?" { + e.preventDefault(); + if onToggleShortcuts { + onToggleShortcuts(); + } + } if not textInputHook.textInput and not e.ctrlKey and not e.metaKey and not e.shiftKey and not e.altKey { if e.key == "9" { triggerImageUpload(); @@ -538,6 +599,7 @@ def:pub Canvas(props: dict) -> JsxElement { def offsetElement(original: dict, dx: float, dy: float) -> dict { dup = Object.assign({}, original); + dup.id = None; if original.type == "text" or original.type == "rectangle" or original.type == "circle" or original.type == "image" or original.type == "diamond" { dup.x = original.x + dx; dup.y = original.y + dy; @@ -1322,7 +1384,9 @@ def:pub Canvas(props: dict) -> JsxElement { clones = []; aci = 0; while aci < selectionHook.selectedElements.length { - clones.push(Object.assign({}, selectionHook.selectedElements[aci].element)); + clone = Object.assign({}, selectionHook.selectedElements[aci].element); + clone.id = None; + clones.push(clone); aci = aci + 1; } elementsHook.addMultipleElements(clones); @@ -1339,6 +1403,7 @@ def:pub Canvas(props: dict) -> JsxElement { return; } elif selectionHook.selectedElement { singleClone = Object.assign({}, selectionHook.selectedElement.element); + singleClone.id = None; elementsHook.addElement(singleClone); selectionHook.setSelectedElement({"element": singleClone, "id": singleClone.id}); selectionHook.setSelectedElements([]); @@ -1864,6 +1929,22 @@ def:pub Canvas(props: dict) -> JsxElement { {toastMessage and
{toastMessage}
or None} + + {(showShortcutsProp or False) and None { if onToggleShortcuts { onToggleShortcuts(); } }} /> or None} + + {showResetConfirm and None { + elementsHook.clearElements(); + selectionHook.clearSelection(); + showResetConfirm = False; + }} + onCancel={lambda -> None { showResetConfirm = False; }} + confirmText="Reset" + cancelText="Cancel" + isDangerous={True} + /> or None} ); } diff --git a/components/layout/ConfirmDialog.cl.jac b/components/layout/ConfirmDialog.cl.jac new file mode 100644 index 0000000..e0fb46b --- /dev/null +++ b/components/layout/ConfirmDialog.cl.jac @@ -0,0 +1,59 @@ +def:pub ConfirmDialog(props: dict) -> JsxElement { + def _noop() -> None {} + title = props.title or "Confirm"; + message = props.message or "Are you sure?"; + onConfirm = props.onConfirm or _noop; + onCancel = props.onCancel or _noop; + confirmText = props.confirmText or "Confirm"; + cancelText = props.cancelText or "Cancel"; + isDangerous = props.isDangerous or False; + + def handleBackdropClick(e: dict) -> None { + if e.target == e.currentTarget { + onCancel(); + } + } + + def handleEscape(e: dict) -> None { + if e.key == "Escape" { + onCancel(); + } + } + + can with entry { + window.addEventListener("keydown", handleEscape); + return lambda -> None { + window.removeEventListener("keydown", handleEscape); + }; + } + + return
+
+
+

{title}

+
+ +
+

{message}

+
+ +
+ + +
+
+
; +} diff --git a/components/layout/ShortcutHelp.cl.jac b/components/layout/ShortcutHelp.cl.jac new file mode 100644 index 0000000..110cac0 --- /dev/null +++ b/components/layout/ShortcutHelp.cl.jac @@ -0,0 +1,117 @@ +def:pub ShortcutHelp(props: dict) -> JsxElement { + def _noop() -> None {} + onClose = props.onClose or _noop; + + toolShortcuts = [ + {"action": "Hand (pan)", "key": "Space"}, + {"action": "Select", "key": "1"}, + {"action": "Pencil", "key": "2"}, + {"action": "Line", "key": "3"}, + {"action": "Arrow", "key": "4"}, + {"action": "Rectangle", "key": "5"}, + {"action": "Diamond", "key": "6"}, + {"action": "Ellipse", "key": "7"}, + {"action": "Text", "key": "8"}, + {"action": "Image", "key": "9"}, + ]; + + editorShortcuts = [ + {"action": "Delete", "key": "Delete"}, + {"action": "Cut", "key": "Ctrl+X"}, + {"action": "Copy", "key": "Ctrl+C"}, + {"action": "Paste", "key": "Ctrl+V"}, + {"action": "Duplicate", "key": "Ctrl+D or Alt+drag"}, + {"action": "Select All", "key": "Ctrl+A"}, + {"action": "Add to selection", "key": "Shift+click"}, + {"action": "Undo", "key": "Ctrl+Z"}, + {"action": "Redo", "key": "Ctrl+Y or Ctrl+Shift+Z"}, + {"action": "Group", "key": "Ctrl+G"}, + {"action": "Ungroup", "key": "Ctrl+Shift+G"}, + {"action": "Bring to front", "key": "Ctrl+]"}, + {"action": "Send to back", "key": "Ctrl+["}, + {"action": "Zoom in", "key": "Ctrl++"}, + {"action": "Zoom out", "key": "Ctrl+-"}, + {"action": "Reset zoom", "key": "Ctrl+0"}, + {"action": "Reset canvas", "key": "Ctrl+Delete"}, + {"action": "Increase font size", "key": "Ctrl+Shift+>"}, + {"action": "Decrease font size", "key": "Ctrl+Shift+<"}, + {"action": "Save", "key": "Ctrl+S"}, + {"action": "Open", "key": "Ctrl+O"}, + {"action": "Copy as PNG", "key": "Shift+Alt+C"}, + {"action": "Keyboard shortcuts", "key": "?"}, + ]; + + def handleBackdropClick(e: dict) -> None { + if e.target == e.currentTarget { + onClose(); + } + } + + def handleEscape(e: dict) -> None { + if e.key == "Escape" { + onClose(); + } + } + + can with entry { + window.addEventListener("keydown", handleEscape); + return lambda -> None { + window.removeEventListener("keydown", handleEscape); + }; + } + + return
+
+
+

Keyboard Shortcuts

+ +
+ +
+
+
+

Tools

+
+ {[ +
+ {item["action"]} + + {item["key"]} + +
+ for item in toolShortcuts + ]} +
+
+ +
+

Editor

+
+ {[ +
+ {item["action"]} + + {item["key"]} + +
+ for item in editorShortcuts + ]} +
+
+
+
+
+
; +} diff --git a/components/layout/TopBar.cl.jac b/components/layout/TopBar.cl.jac index d455904..265180b 100644 --- a/components/layout/TopBar.cl.jac +++ b/components/layout/TopBar.cl.jac @@ -58,6 +58,7 @@ def:pub TopBar(props: dict) -> JsxElement { onImageLoad = props.onImageLoad; onSave = props.onSave; onLoad = props.onLoad; + onShowShortcuts = props.onShowShortcuts; fileInputRef = useRef(None); jasketchFileInputRef = useRef(None); @@ -171,8 +172,15 @@ def:pub TopBar(props: dict) -> JsxElement { ]} -
+
Scroll to pan | Ctrl+scroll to zoom +