File size: 11,765 Bytes
df84e45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// 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("+");
}