Download web/js/commands.js from FineEnvs/DesignGym: direct link, hf CLI and curl.
- Browser
- Download file 11.8 kB
-
https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/commands.js
- Command line
-
hf download hf://spaces/FineEnvs/DesignGym/web/js/commands.js
-
curl -L -o commands.js https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/commands.js
11.8 kB
| // Every user-facing action is a command. Menus, toolbars, context menus and | |
| // keyboard shortcuts all point at the same registry, so the Figma, Canva and | |
| // Photoshop skins differ only in where a command lives, never in what it does. | |
| import { unionBounds } from "./util.js"; | |
| export function createCommands(app) { | |
| const s = app.store; | |
| const sel = () => s.selected(); | |
| const one = () => (s.selection.length === 1 ? s.find(s.selection[0]) : null); | |
| const hasSel = () => s.selection.length > 0; | |
| const unlocked = () => sel().filter((e) => !e.locked); | |
| let clipboard = []; | |
| const addFromProps = async (el, dx = 20, dy = 20) => { | |
| const base = { x: el.x + dx, y: el.y + dy, angle: el.angle, opacity: el.opacity }; | |
| let res; | |
| if (el.kind === "image") { | |
| res = await s.op("add_image", { asset_id: el.asset_id, width: el.width, height: el.height, ...base }); | |
| } else if (el.kind === "shape") { | |
| res = await s.op("add_shape", { | |
| shape: el.shape, width: el.width, height: el.height, fill: el.fill || "none", | |
| stroke: el.stroke, stroke_width: el.stroke_width, radius: el.radius, ...base, | |
| }); | |
| } else { | |
| res = await s.op("add_text", { | |
| text: el.text, width: el.auto_width ? undefined : el.width, font_family: el.font_family, | |
| font_size: el.font_size, color: el.color, align: el.align, bold: el.bold, | |
| italic: el.italic, underline: el.underline, uppercase: el.uppercase, | |
| line_height: el.line_height, letter_spacing: el.letter_spacing, runs: el.runs, ...base, | |
| }); | |
| } | |
| return res?.element_id; | |
| }; | |
| const reorder = (to) => async () => { | |
| const ids = [...s.selection].sort((a, b) => s.index(a) - s.index(b)); | |
| const list = to === "front" || to === "backward" ? ids : ids.reverse(); | |
| for (const id of list) await s.op("reorder_element", { element_id: id, to }); | |
| }; | |
| const align = (alignment) => async () => { | |
| const ids = unlocked().map((e) => e.id); | |
| if (!ids.length) return; | |
| await s.op("align_elements", { | |
| element_ids: ids, alignment, relative_to: ids.length > 1 ? "selection" : "canvas", | |
| }); | |
| }; | |
| // Figma: ] / [ send to the very front / back, with Mod one step; elsewhere the reverse. | |
| const fg = app.skin.name === "figma"; | |
| const ik = app.skin.name === "inkscape"; // Inkscape: Home / End / PageUp / PageDown for z-order, H / V flip, S selector | |
| const texts = () => sel().filter((e) => e.kind === "text" && !e.locked); | |
| const textStyle = (prop) => () => { | |
| const t = texts(); | |
| const on = !t.every((e) => e[prop]); | |
| return s.updateMany(t.map((e) => [e.id, { [prop]: on }])); | |
| }; | |
| const cmds = { | |
| undo: { label: "Undo", key: "Mod+Z", icon: "undo-2", enabled: () => s.canUndo, run: () => s.undo() }, | |
| redo: { label: "Redo", key: "Mod+Shift+Z", icon: "redo-2", enabled: () => s.canRedo, run: () => s.redo() }, | |
| delete: { | |
| label: "Delete", key: "Delete", icon: "trash-2", enabled: hasSel, | |
| run: async () => { | |
| const ids = unlocked().map((e) => e.id); | |
| s.select([]); | |
| for (const id of ids) await s.op("delete_element", { element_id: id }); | |
| }, | |
| }, | |
| duplicate: { | |
| label: "Duplicate", key: "Mod+D", icon: "copy", enabled: hasSel, | |
| run: async () => { | |
| const out = []; | |
| for (const el of sel()) { | |
| const r = await s.op("duplicate_element", { element_id: el.id, dx: 20, dy: 20 }); | |
| if (r?.element_id) out.push(r.element_id); | |
| } | |
| s.select(out); | |
| }, | |
| }, | |
| copy: { | |
| label: "Copy", key: "Mod+C", icon: "copy", enabled: hasSel, | |
| run: () => { clipboard = sel().map((e) => JSON.parse(JSON.stringify(e))); app.toast(`Copied ${clipboard.length}`); }, | |
| }, | |
| cut: { | |
| label: "Cut", key: "Mod+X", icon: "scissors", enabled: hasSel, | |
| run: async () => { cmds.copy.run(); await cmds.delete.run(); }, | |
| }, | |
| paste: { | |
| label: "Paste", key: "Mod+V", icon: "clipboard-paste", enabled: () => clipboard.length > 0, | |
| run: async () => { | |
| const out = []; | |
| for (const el of clipboard) { | |
| const id = await addFromProps(el); | |
| if (id) out.push(id); | |
| } | |
| clipboard = clipboard.map((e) => ({ ...e, x: e.x + 20, y: e.y + 20 })); | |
| s.select(out); | |
| }, | |
| }, | |
| selectAll: { | |
| label: "Select all", key: "Mod+A", enabled: () => !!s.doc?.elements.length, | |
| run: () => s.select(s.doc.elements.filter((e) => e.visible !== false && !e.locked).map((e) => e.id)), | |
| }, | |
| deselect: { label: "Deselect", key: "Escape", enabled: hasSel, run: () => s.select([]) }, | |
| bold: { label: "Bold", key: "Mod+B", icon: "bold", enabled: () => texts().length > 0, run: textStyle("bold") }, | |
| italic: { label: "Italic", key: app.skin.name === "inkscape" ? "" : "Mod+I", icon: "italic", enabled: () => texts().length > 0, run: textStyle("italic") }, | |
| underline: { label: "Underline", key: "Mod+U", icon: "underline", enabled: () => texts().length > 0, run: textStyle("underline") }, | |
| rotateCw: { label: "Rotate 90° clockwise", icon: "rotate-cw", enabled: hasSel, | |
| run: () => s.updateMany(unlocked().map((e) => [e.id, { angle: (e.angle || 0) + 90 }])) }, | |
| rotateCcw: { label: "Rotate 90° counter-clockwise", icon: "rotate-ccw", enabled: hasSel, | |
| run: () => s.updateMany(unlocked().map((e) => [e.id, { angle: (e.angle || 0) - 90 }])) }, | |
| bringFront: { label: "Bring to front", key: ik ? "Home" : fg ? "]" : "Mod+]", icon: "bring-to-front", enabled: hasSel, run: reorder("front") }, | |
| bringForward: { label: "Bring forward", key: ik ? "PageUp" : fg ? "Mod+]" : "]", icon: "arrow-up", enabled: hasSel, run: reorder("forward") }, | |
| sendBackward: { label: "Send backward", key: ik ? "PageDown" : fg ? "Mod+[" : "[", icon: "arrow-down", enabled: hasSel, run: reorder("backward") }, | |
| sendBack: { label: "Send to back", key: ik ? "End" : fg ? "[" : "Mod+[", icon: "send-to-back", enabled: hasSel, run: reorder("back") }, | |
| alignLeft: { label: "Align left", icon: "align-start-vertical", enabled: hasSel, run: align("left") }, | |
| alignCenter: { label: "Align horizontal centers", icon: "align-center-vertical", enabled: hasSel, run: align("center") }, | |
| alignRight: { label: "Align right", icon: "align-end-vertical", enabled: hasSel, run: align("right") }, | |
| alignTop: { label: "Align top", icon: "align-start-horizontal", enabled: hasSel, run: align("top") }, | |
| alignMiddle: { label: "Align vertical centers", icon: "align-center-horizontal", enabled: hasSel, run: align("middle") }, | |
| alignBottom: { label: "Align bottom", icon: "align-end-horizontal", enabled: hasSel, run: align("bottom") }, | |
| distributeH: { | |
| label: "Distribute horizontally", icon: "align-horizontal-distribute-center", | |
| enabled: () => s.selection.length > 2, | |
| run: () => s.op("distribute_elements", { element_ids: s.selection, axis: "horizontal" }), | |
| }, | |
| distributeV: { | |
| label: "Distribute vertically", icon: "align-vertical-distribute-center", | |
| enabled: () => s.selection.length > 2, | |
| run: () => s.op("distribute_elements", { element_ids: s.selection, axis: "vertical" }), | |
| }, | |
| flipH: { | |
| label: "Flip horizontal", key: ik ? "H" : "Shift+H", icon: "flip-horizontal-2", enabled: hasSel, | |
| run: () => s.updateMany(unlocked().map((e) => [e.id, { flip_x: !e.flip_x }])), | |
| }, | |
| flipV: { | |
| label: "Flip vertical", key: ik ? "V" : "Shift+V", icon: "flip-vertical-2", enabled: hasSel, | |
| run: () => s.updateMany(unlocked().map((e) => [e.id, { flip_y: !e.flip_y }])), | |
| }, | |
| lock: { | |
| label: "Lock / unlock", key: "Mod+Shift+L", icon: "lock", enabled: hasSel, | |
| run: () => s.updateMany(sel().map((e) => [e.id, { locked: !e.locked }])), | |
| }, | |
| hide: { | |
| label: "Show / hide", key: "Mod+Shift+H", icon: "eye-off", enabled: hasSel, | |
| run: () => s.updateMany(sel().map((e) => [e.id, { visible: e.visible === false }])), | |
| }, | |
| freeTransform: { label: "Free Transform", key: app.skin.name === "photoshop" ? "Mod+T" : "", enabled: () => s.selection.length > 0, | |
| hidden: () => app.skin.name !== "photoshop", run: () => app.setFreeTransform(!app.freeTransform) }, | |
| toolMove: { label: "Move", key: ik ? "S" : "V", icon: "mouse-pointer-2", run: () => s.setTool("move") }, | |
| toolHand: { label: "Hand", key: ik ? "" : "H", icon: "hand", run: () => s.setTool("hand") }, | |
| toolText: { label: "Text", key: "T", icon: "type", run: () => s.setTool("text") }, | |
| toolRect: { label: "Rectangle", key: "R", icon: "square", run: () => s.setTool("rect") }, | |
| toolEllipse: { label: "Ellipse", key: ik ? "E" : "O", icon: "circle", run: () => s.setTool("ellipse") }, | |
| zoomIn: { label: "Zoom in", key: "Mod+=", icon: "zoom-in", run: () => app.stage.zoomBy(1.25) }, | |
| zoomOut: { label: "Zoom out", key: "Mod+-", icon: "zoom-out", run: () => app.stage.zoomBy(0.8) }, | |
| zoomFit: { label: "Zoom to fit", key: "Shift+1", icon: "maximize", run: () => app.stage.fit() }, | |
| zoom100: { label: "Zoom to 100%", key: "Shift+0", run: () => app.stage.setZoom(1) }, | |
| zoomSelection: { | |
| label: "Zoom to selection", key: "Shift+2", enabled: hasSel, | |
| run: () => { app.stage.fitRect(unionBounds(sel())); app.stage.autoFit = false; }, | |
| }, | |
| html: { label: "Edit as HTML…", key: "Mod+Alt+H", icon: "code", enabled: () => app.showCode, run: () => app.openHtml() }, | |
| exportPng: { label: "Export PNG", key: "Mod+Shift+S", icon: "download", run: () => app.exportPng() }, | |
| // Export is how a design is handed in, as in any editor: it ends the episode and scores it. | |
| submit: { label: "Export…", key: ik ? "" : "Mod+Shift+E", icon: "download", enabled: () => !s.done, run: () => app.confirmSubmit() }, | |
| shortcuts: { label: "Keyboard shortcuts", key: "Mod+/", icon: "keyboard", run: () => app.showShortcuts() }, | |
| // Images come from the computer's files, the way each editor brings them in. | |
| placeImage: { label: { inkscape: "Import…", photoshop: "Place Embedded…", canva: "Upload files", | |
| slides: "Upload from computer", powerpoint: "This Device…" }[app.skin.name] || "Place image…", | |
| key: app.skin.name === "inkscape" ? "Mod+I" : "Mod+Shift+K", icon: "image-plus", run: () => app.openFiles() }, | |
| addHeading: { | |
| label: "Add a heading", icon: "type", | |
| run: () => app.addTextPreset({ text: "Add a heading", font_size: Math.round(s.doc.height / 10), bold: true }), | |
| }, | |
| addSubheading: { | |
| label: "Add a subheading", icon: "type", | |
| run: () => app.addTextPreset({ text: "Add a subheading", font_size: Math.round(s.doc.height / 18), bold: true }), | |
| }, | |
| addBody: { | |
| label: "Add body text", icon: "text", | |
| run: () => app.addTextPreset({ text: "Add a little bit of body text", font_size: Math.round(s.doc.height / 36) }), | |
| }, | |
| addRect: { label: "Rectangle", icon: "square", run: () => app.addShapePreset("rect") }, | |
| addRounded: { label: "Rounded rectangle", icon: "rectangle-horizontal", run: () => app.addShapePreset("rect", 0.12) }, | |
| addEllipse: { label: "Circle", icon: "circle", run: () => app.addShapePreset("ellipse") }, | |
| }; | |
| for (const [id, c] of Object.entries(cmds)) c.id = id; | |
| return cmds; | |
| } | |
| // Keyboard: translate an event into the "Mod+Shift+Z" spelling used above. | |
| export function eventChord(e) { | |
| const parts = []; | |
| if (e.metaKey || e.ctrlKey) parts.push("Mod"); | |
| if (e.shiftKey) parts.push("Shift"); | |
| if (e.altKey) parts.push("Alt"); | |
| let k = e.key; | |
| if (k === " ") k = "Space"; | |
| if (k === "Backspace") k = "Delete"; | |
| if (k === "+") k = "="; | |
| if (e.code && e.code.startsWith("Digit") && e.shiftKey) k = e.code.slice(5); | |
| if (k.length === 1) k = k.toUpperCase(); | |
| if (!["Meta", "Control", "Shift", "Alt"].includes(e.key)) parts.push(k); | |
| return parts.join("+"); | |
| } | |