Skip to content

Commit 4e4c1b1

Browse files
committed
feat: Add keyboard shortcuts help and confirmation dialog; enhance canvas interactions
1 parent 2060278 commit 4e4c1b1

7 files changed

Lines changed: 381 additions & 6 deletions

File tree

components/Canvas.cl.jac

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import from "..constants.tools" { TOOL_SHORTCUTS }
1414
import from .canvas.CanvasRenderer { CanvasRenderer }
1515
import from .canvas.TextInput { TextInput }
1616
import from .canvas.ContextMenu { ContextMenu }
17+
import from .layout.ShortcutHelp { ShortcutHelp }
18+
import from .layout.ConfirmDialog { ConfirmDialog }
1719

1820
def:pub Canvas(props: dict) -> JsxElement {
21+
showShortcutsProp = props.showShortcuts or False;
22+
onToggleShortcuts = props.onToggleShortcuts;
1923
currentTool = props.currentTool or "select";
2024
currentColor = props.currentColor or "#000000";
2125
currentStrokeWidth = props.currentStrokeWidth or 2;
@@ -45,6 +49,8 @@ def:pub Canvas(props: dict) -> JsxElement {
4549
selectedElementsRef = useRef([]);
4650
altDragCopiedRef = useRef(False);
4751
drawingStateRef = useRef(drawingState);
52+
viewportHookRef = useRef(viewportHook);
53+
selectionHookRef = useRef(selectionHook);
4854
has isDraggingBend: bool = False;
4955
has isRotating: bool = False;
5056
has rotatingElementId: any = None;
@@ -54,10 +60,13 @@ def:pub Canvas(props: dict) -> JsxElement {
5460
has draggingVertexHandle: str = "";
5561
has isDraggingElbowSegment: bool = False;
5662
has draggingElbowSegIndex: int = 0;
63+
has showResetConfirm: bool = False;
5764

5865
selectedElementRef.current = selectionHook.selectedElement;
5966
selectedElementsRef.current = selectionHook.selectedElements;
6067
drawingStateRef.current = drawingState;
68+
viewportHookRef.current = viewportHook;
69+
selectionHookRef.current = selectionHook;
6170

6271
# Pass editing shape id to suppress its shapeText during editing
6372
editingShapeId = None;
@@ -225,21 +234,21 @@ def:pub Canvas(props: dict) -> JsxElement {
225234
canvas = canvasRef.current;
226235
if canvas {
227236
rect = canvas.getBoundingClientRect();
228-
viewportHook.handleZoom(-1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas);
237+
viewportHookRef.current.handleZoom(-1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas);
229238
}
230239
}
231240
if not textInputHook.textInput and e.key == "-" and (e.ctrlKey or e.metaKey) {
232241
e.preventDefault();
233242
canvas = canvasRef.current;
234243
if canvas {
235244
rect = canvas.getBoundingClientRect();
236-
viewportHook.handleZoom(1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas);
245+
viewportHookRef.current.handleZoom(1, rect.left + rect.width / 2, rect.top + rect.height / 2, canvas);
237246
}
238247
}
239248
if not textInputHook.textInput and e.key == "0" and (e.ctrlKey or e.metaKey) {
240249
e.preventDefault();
241-
viewportHook.setZoom(1);
242-
viewportHook.setPanOffset({"x": 0, "y": 0});
250+
viewportHookRef.current.setZoom(1);
251+
viewportHookRef.current.setPanOffset({"x": 0, "y": 0});
243252
}
244253
if not textInputHook.textInput and (e.key == "s" or e.key == "S") and (e.ctrlKey or e.metaKey) and not e.shiftKey {
245254
e.preventDefault();
@@ -249,6 +258,58 @@ def:pub Canvas(props: dict) -> JsxElement {
249258
e.preventDefault();
250259
triggerJaSketchLoad();
251260
}
261+
if not textInputHook.textInput and (e.key == "x" or e.key == "X") and (e.ctrlKey or e.metaKey) {
262+
e.preventDefault();
263+
curMultiCut = selectedElementsRef.current;
264+
curSingleCut = selectedElementRef.current;
265+
if curMultiCut and curMultiCut.length > 0 {
266+
copied = [];
267+
ci = 0;
268+
while ci < curMultiCut.length {
269+
copied.push(Object.assign({}, curMultiCut[ci].element));
270+
ci = ci + 1;
271+
}
272+
clipboardRef.current = copied;
273+
} elif curSingleCut and curSingleCut.element {
274+
clipboardRef.current = [Object.assign({}, curSingleCut.element)];
275+
}
276+
deleteSelected();
277+
}
278+
if not textInputHook.textInput and e.key == "Delete" and (e.ctrlKey or e.metaKey) {
279+
e.preventDefault();
280+
showResetConfirm = True;
281+
}
282+
if not textInputHook.textInput and (e.key == "<" or e.key == ",") and (e.ctrlKey or e.metaKey) and e.shiftKey {
283+
e.preventDefault();
284+
curSel = selectedElementRef.current;
285+
if curSel and curSel.element.type == "text" {
286+
element = curSel.element;
287+
newSize = element.fontSize or 16;
288+
newSize = newSize - 2;
289+
if newSize < 8 {
290+
newSize = 8;
291+
}
292+
elementsHook.updateElement(element.id, {**element, "fontSize": newSize});
293+
selectionHookRef.current.setSelectedElement({"element": {**element, "fontSize": newSize}, "id": element.id});
294+
}
295+
}
296+
if not textInputHook.textInput and (e.key == ">" or e.key == ".") and (e.ctrlKey or e.metaKey) and e.shiftKey {
297+
e.preventDefault();
298+
curSel = selectedElementRef.current;
299+
if curSel and curSel.element.type == "text" {
300+
element = curSel.element;
301+
newSize = element.fontSize or 16;
302+
newSize = newSize + 2;
303+
elementsHook.updateElement(element.id, {**element, "fontSize": newSize});
304+
selectionHookRef.current.setSelectedElement({"element": {**element, "fontSize": newSize}, "id": element.id});
305+
}
306+
}
307+
if not textInputHook.textInput and e.key == "?" {
308+
e.preventDefault();
309+
if onToggleShortcuts {
310+
onToggleShortcuts();
311+
}
312+
}
252313
if not textInputHook.textInput and not e.ctrlKey and not e.metaKey and not e.shiftKey and not e.altKey {
253314
if e.key == "9" {
254315
triggerImageUpload();
@@ -538,6 +599,7 @@ def:pub Canvas(props: dict) -> JsxElement {
538599

539600
def offsetElement(original: dict, dx: float, dy: float) -> dict {
540601
dup = Object.assign({}, original);
602+
dup.id = None;
541603
if original.type == "text" or original.type == "rectangle" or original.type == "circle" or original.type == "image" or original.type == "diamond" {
542604
dup.x = original.x + dx;
543605
dup.y = original.y + dy;
@@ -1322,7 +1384,9 @@ def:pub Canvas(props: dict) -> JsxElement {
13221384
clones = [];
13231385
aci = 0;
13241386
while aci < selectionHook.selectedElements.length {
1325-
clones.push(Object.assign({}, selectionHook.selectedElements[aci].element));
1387+
clone = Object.assign({}, selectionHook.selectedElements[aci].element);
1388+
clone.id = None;
1389+
clones.push(clone);
13261390
aci = aci + 1;
13271391
}
13281392
elementsHook.addMultipleElements(clones);
@@ -1339,6 +1403,7 @@ def:pub Canvas(props: dict) -> JsxElement {
13391403
return;
13401404
} elif selectionHook.selectedElement {
13411405
singleClone = Object.assign({}, selectionHook.selectedElement.element);
1406+
singleClone.id = None;
13421407
elementsHook.addElement(singleClone);
13431408
selectionHook.setSelectedElement({"element": singleClone, "id": singleClone.id});
13441409
selectionHook.setSelectedElements([]);
@@ -1864,6 +1929,22 @@ def:pub Canvas(props: dict) -> JsxElement {
18641929
{toastMessage and <div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg text-sm z-50 pointer-events-none animate-fade-in">
18651930
{toastMessage}
18661931
</div> or None}
1932+
1933+
{(showShortcutsProp or False) and <ShortcutHelp onClose={lambda -> None { if onToggleShortcuts { onToggleShortcuts(); } }} /> or None}
1934+
1935+
{showResetConfirm and <ConfirmDialog
1936+
title="Reset Canvas"
1937+
message="Reset the canvas? This will clear all elements."
1938+
onConfirm={lambda -> None {
1939+
elementsHook.clearElements();
1940+
selectionHook.clearSelection();
1941+
showResetConfirm = False;
1942+
}}
1943+
onCancel={lambda -> None { showResetConfirm = False; }}
1944+
confirmText="Reset"
1945+
cancelText="Cancel"
1946+
isDangerous={True}
1947+
/> or None}
18671948
</div>
18681949
);
18691950
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
def:pub ConfirmDialog(props: dict) -> JsxElement {
2+
def _noop() -> None {}
3+
title = props.title or "Confirm";
4+
message = props.message or "Are you sure?";
5+
onConfirm = props.onConfirm or _noop;
6+
onCancel = props.onCancel or _noop;
7+
confirmText = props.confirmText or "Confirm";
8+
cancelText = props.cancelText or "Cancel";
9+
isDangerous = props.isDangerous or False;
10+
11+
def handleBackdropClick(e: dict) -> None {
12+
if e.target == e.currentTarget {
13+
onCancel();
14+
}
15+
}
16+
17+
def handleEscape(e: dict) -> None {
18+
if e.key == "Escape" {
19+
onCancel();
20+
}
21+
}
22+
23+
can with entry {
24+
window.addEventListener("keydown", handleEscape);
25+
return lambda -> None {
26+
window.removeEventListener("keydown", handleEscape);
27+
};
28+
}
29+
30+
return <div
31+
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
32+
onClick={handleBackdropClick}
33+
>
34+
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full mx-4">
35+
<div className="px-6 py-4 border-b border-gray-200">
36+
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
37+
</div>
38+
39+
<div className="px-6 py-4">
40+
<p className="text-sm text-gray-600">{message}</p>
41+
</div>
42+
43+
<div className="px-6 py-4 border-t border-gray-200 flex gap-3 justify-end">
44+
<button
45+
onClick={onCancel}
46+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
47+
>
48+
{cancelText}
49+
</button>
50+
<button
51+
onClick={onConfirm}
52+
className={"px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors " + (isDangerous and "bg-red-600 hover:bg-red-700" or "bg-blue-600 hover:bg-blue-700")}
53+
>
54+
{confirmText}
55+
</button>
56+
</div>
57+
</div>
58+
</div>;
59+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
def:pub ShortcutHelp(props: dict) -> JsxElement {
2+
def _noop() -> None {}
3+
onClose = props.onClose or _noop;
4+
5+
toolShortcuts = [
6+
{"action": "Hand (pan)", "key": "Space"},
7+
{"action": "Select", "key": "1"},
8+
{"action": "Pencil", "key": "2"},
9+
{"action": "Line", "key": "3"},
10+
{"action": "Arrow", "key": "4"},
11+
{"action": "Rectangle", "key": "5"},
12+
{"action": "Diamond", "key": "6"},
13+
{"action": "Ellipse", "key": "7"},
14+
{"action": "Text", "key": "8"},
15+
{"action": "Image", "key": "9"},
16+
];
17+
18+
editorShortcuts = [
19+
{"action": "Delete", "key": "Delete"},
20+
{"action": "Cut", "key": "Ctrl+X"},
21+
{"action": "Copy", "key": "Ctrl+C"},
22+
{"action": "Paste", "key": "Ctrl+V"},
23+
{"action": "Duplicate", "key": "Ctrl+D or Alt+drag"},
24+
{"action": "Select All", "key": "Ctrl+A"},
25+
{"action": "Add to selection", "key": "Shift+click"},
26+
{"action": "Undo", "key": "Ctrl+Z"},
27+
{"action": "Redo", "key": "Ctrl+Y or Ctrl+Shift+Z"},
28+
{"action": "Group", "key": "Ctrl+G"},
29+
{"action": "Ungroup", "key": "Ctrl+Shift+G"},
30+
{"action": "Bring to front", "key": "Ctrl+]"},
31+
{"action": "Send to back", "key": "Ctrl+["},
32+
{"action": "Zoom in", "key": "Ctrl++"},
33+
{"action": "Zoom out", "key": "Ctrl+-"},
34+
{"action": "Reset zoom", "key": "Ctrl+0"},
35+
{"action": "Reset canvas", "key": "Ctrl+Delete"},
36+
{"action": "Increase font size", "key": "Ctrl+Shift+>"},
37+
{"action": "Decrease font size", "key": "Ctrl+Shift+<"},
38+
{"action": "Save", "key": "Ctrl+S"},
39+
{"action": "Open", "key": "Ctrl+O"},
40+
{"action": "Copy as PNG", "key": "Shift+Alt+C"},
41+
{"action": "Keyboard shortcuts", "key": "?"},
42+
];
43+
44+
def handleBackdropClick(e: dict) -> None {
45+
if e.target == e.currentTarget {
46+
onClose();
47+
}
48+
}
49+
50+
def handleEscape(e: dict) -> None {
51+
if e.key == "Escape" {
52+
onClose();
53+
}
54+
}
55+
56+
can with entry {
57+
window.addEventListener("keydown", handleEscape);
58+
return lambda -> None {
59+
window.removeEventListener("keydown", handleEscape);
60+
};
61+
}
62+
63+
return <div
64+
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
65+
onClick={handleBackdropClick}
66+
>
67+
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-11/12 max-h-[85vh] overflow-auto">
68+
<div className="sticky top-0 bg-white border-b border-gray-200 px-8 py-6 flex items-center justify-between">
69+
<h2 className="text-2xl font-bold text-gray-800">Keyboard Shortcuts</h2>
70+
<button
71+
onClick={onClose}
72+
className="text-gray-400 hover:text-gray-600 transition-colors"
73+
title="Close (Esc)"
74+
>
75+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
76+
<line x1="18" y1="6" x2="6" y2="18" />
77+
<line x1="6" y1="6" x2="18" y2="18" />
78+
</svg>
79+
</button>
80+
</div>
81+
82+
<div className="px-8 py-8">
83+
<div className="grid grid-cols-2 gap-12">
84+
<div>
85+
<h3 className="text-lg font-semibold text-gray-700 mb-4">Tools</h3>
86+
<div className="space-y-3">
87+
{[
88+
<div key={item["action"]} className="flex items-center justify-between text-sm">
89+
<span className="text-gray-700">{item["action"]}</span>
90+
<kbd className="bg-gray-100 text-gray-800 px-3 py-1 rounded-lg font-mono text-xs font-medium border border-gray-300">
91+
{item["key"]}
92+
</kbd>
93+
</div>
94+
for item in toolShortcuts
95+
]}
96+
</div>
97+
</div>
98+
99+
<div>
100+
<h3 className="text-lg font-semibold text-gray-700 mb-4">Editor</h3>
101+
<div className="space-y-3">
102+
{[
103+
<div key={item["action"]} className="flex items-center justify-between text-sm">
104+
<span className="text-gray-700">{item["action"]}</span>
105+
<kbd className="bg-gray-100 text-gray-800 px-3 py-1 rounded-lg font-mono text-xs font-medium border border-gray-300">
106+
{item["key"]}
107+
</kbd>
108+
</div>
109+
for item in editorShortcuts
110+
]}
111+
</div>
112+
</div>
113+
</div>
114+
</div>
115+
</div>
116+
</div>;
117+
}

components/layout/TopBar.cl.jac

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def:pub TopBar(props: dict) -> JsxElement {
5858
onImageLoad = props.onImageLoad;
5959
onSave = props.onSave;
6060
onLoad = props.onLoad;
61+
onShowShortcuts = props.onShowShortcuts;
6162
fileInputRef = useRef(None);
6263
jasketchFileInputRef = useRef(None);
6364

@@ -171,8 +172,15 @@ def:pub TopBar(props: dict) -> JsxElement {
171172
]}
172173
</div>
173174
174-
<div className="min-w-[120px] flex items-center justify-end gap-1">
175+
<div className="min-w-[150px] flex items-center justify-end gap-1">
175176
<span className="text-[10px] text-gray-400 hidden lg:block mr-2">Scroll to pan | Ctrl+scroll to zoom</span>
177+
<button
178+
onClick={lambda -> None { if onShowShortcuts { onShowShortcuts(); } }}
179+
title="Keyboard shortcuts (?)"
180+
className="w-8 h-8 rounded-md flex items-center justify-center text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-all duration-150"
181+
>
182+
<span className="text-sm font-semibold">?</span>
183+
</button>
176184
<button
177185
onClick={handleSave}
178186
title="Save to disk (Ctrl+S)"

0 commit comments

Comments
 (0)