Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions components/Canvas.cl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -225,21 +234,21 @@ 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) {
e.preventDefault();
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();
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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([]);
Expand Down Expand Up @@ -1864,6 +1929,22 @@ def:pub Canvas(props: dict) -> JsxElement {
{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">
{toastMessage}
</div> or None}

{(showShortcutsProp or False) and <ShortcutHelp onClose={lambda -> None { if onToggleShortcuts { onToggleShortcuts(); } }} /> or None}

{showResetConfirm and <ConfirmDialog
title="Reset Canvas"
message="Reset the canvas? This will clear all elements."
onConfirm={lambda -> None {
elementsHook.clearElements();
selectionHook.clearSelection();
showResetConfirm = False;
}}
onCancel={lambda -> None { showResetConfirm = False; }}
confirmText="Reset"
cancelText="Cancel"
isDangerous={True}
/> or None}
</div>
);
}
59 changes: 59 additions & 0 deletions components/layout/ConfirmDialog.cl.jac
Original file line number Diff line number Diff line change
@@ -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 <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
</div>

<div className="px-6 py-4">
<p className="text-sm text-gray-600">{message}</p>
</div>

<div className="px-6 py-4 border-t border-gray-200 flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
{cancelText}
</button>
<button
onClick={onConfirm}
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")}
>
{confirmText}
</button>
</div>
</div>
</div>;
}
117 changes: 117 additions & 0 deletions components/layout/ShortcutHelp.cl.jac
Original file line number Diff line number Diff line change
@@ -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 <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-11/12 max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-8 py-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-800">Keyboard Shortcuts</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Close (Esc)"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>

<div className="px-8 py-8">
<div className="grid grid-cols-2 gap-12">
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-4">Tools</h3>
<div className="space-y-3">
{[
<div key={item["action"]} className="flex items-center justify-between text-sm">
<span className="text-gray-700">{item["action"]}</span>
<kbd className="bg-gray-100 text-gray-800 px-3 py-1 rounded-lg font-mono text-xs font-medium border border-gray-300">
{item["key"]}
</kbd>
</div>
for item in toolShortcuts
]}
</div>
</div>

<div>
<h3 className="text-lg font-semibold text-gray-700 mb-4">Editor</h3>
<div className="space-y-3">
{[
<div key={item["action"]} className="flex items-center justify-between text-sm">
<span className="text-gray-700">{item["action"]}</span>
<kbd className="bg-gray-100 text-gray-800 px-3 py-1 rounded-lg font-mono text-xs font-medium border border-gray-300">
{item["key"]}
</kbd>
</div>
for item in editorShortcuts
]}
</div>
</div>
</div>
</div>
</div>
</div>;
}
10 changes: 9 additions & 1 deletion components/layout/TopBar.cl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -171,8 +172,15 @@ def:pub TopBar(props: dict) -> JsxElement {
]}
</div>

<div className="min-w-[120px] flex items-center justify-end gap-1">
<div className="min-w-[150px] flex items-center justify-end gap-1">
<span className="text-[10px] text-gray-400 hidden lg:block mr-2">Scroll to pan | Ctrl+scroll to zoom</span>
<button
onClick={lambda -> None { if onShowShortcuts { onShowShortcuts(); } }}
title="Keyboard shortcuts (?)"
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"
>
<span className="text-sm font-semibold">?</span>
</button>
<button
onClick={handleSave}
title="Save to disk (Ctrl+S)"
Expand Down
Loading
Loading