DesignGym / web /js /commands.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
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("+");
}