// Six editor skins over one set of components. // // A skin decides where things live (panels, toolbars, menus), what they are // called, and a few interaction details (Canva's rotate handle and floating // toolbar, Figma's always-on size badge). It never decides what a command // does. Randomising the skin per episode is how a computer-use agent is kept // from learning pixel positions instead of learning the editor. import { fmt, h } from "./util.js"; import { preUploaded } from "./files.js"; import { CANVA_PALETTE, colorPanel, fontPanel, spacingPopover } from "./canva.js"; import { openColorDialog } from "./photoshop.js"; import { colorGallery, commandSearch, fontCombo, fontDialog, formatPane, lineSpacingMenu, sizeCombo } from "./ppt.js"; import { alignMenu, backgroundDialog, colorMenu, fontMenu, formatOptions, menuSearch, spacingMenu } from "./slides.js"; import { alignDropdown, dock, documentProperties, exportDialog, fillAndStroke, paletteBar, spin, textAndFont, transformDialog, zoomSpin, } from "./inkscape.js"; import { hasStyle } from "./fonts.js"; import { icon } from "./icons.js"; import { assetsPanel, inspector, layersPanel, selectionModel, shapesPanel, statusText, textPresets, } from "./panels.js"; import { closePopover, colorField, colorPicker, commandButton, fontPicker, iconButton, menu, modal, numberField, popover, segmented, slider, toggle, } from "./widgets.js"; function tabs(defs, onChange, { cls = "" } = {}) { const bar = h("div", { class: `tabs ${cls}`, role: "tablist" }); let current = defs[0][0]; const btns = defs.map(([id, label]) => { const b = h("button", { class: "tab", role: "tab", type: "button", dataset: { tab: id } }, label); b.addEventListener("click", () => set(id)); bar.append(b); return b; }); const set = (id) => { current = id; for (const b of btns) { b.classList.toggle("on", b.dataset.tab === id); b.setAttribute("aria-selected", b.dataset.tab === id ? "true" : "false"); } onChange(id); }; bar.set = set; bar.current = () => current; return bar; } function mountSwitcher(host, panels) { let active = null; return (id) => { host.textContent = ""; active = panels[id]; if (active) { host.append(active.el); active.refresh(); } }; } function zoomLabel(app) { const b = h("button", { class: "zoom-label", type: "button", "aria-label": "Zoom" }, "100%"); b.addEventListener("click", () => menu(b, [ { cmd: app.commands.zoomIn }, { cmd: app.commands.zoomOut }, "-", { cmd: app.commands.zoomFit }, { cmd: app.commands.zoom100 }, { cmd: app.commands.zoomSelection }, { label: "Zoom to 50%", run: () => app.stage.setZoom(0.5) }, { label: "Zoom to 200%", run: () => app.stage.setZoom(2) }, ], { align: "end" })); app.on("zoom", (z) => { b.textContent = `${Math.round(z * 100)}%`; }); return b; } function submitButton(app, cls, label = "Export") { const b = h("button", { class: `btn submit-btn ${cls}`, type: "button", dataset: { command: "submit" } }); b.innerHTML = `${icon("download", 14)}${label}`; b.addEventListener("click", () => app.commands.submit.run()); b.refresh = () => { b.disabled = app.store.done; b.querySelector("span").textContent = app.store.done ? "Exported" : label; }; return b; } function mainMenuItems(app) { // Figma's main menu: File / Edit / View / Object / Text / Arrange open beside it, as submenus. const C = app.commands; return [ { label: "File", items: [{ label: "Place image…", cmd: C.placeImage }, ...(app.showCode ? [{ cmd: C.html }] : []), "-", { cmd: C.submit }] }, { label: "Edit", items: [{ cmd: C.undo }, { cmd: C.redo }, "-", { cmd: C.copy }, { cmd: C.cut }, { cmd: C.paste }, { cmd: C.duplicate }, { cmd: C.delete }, "-", { cmd: C.selectAll }] }, { label: "View", items: [{ label: "Zoom in", cmd: C.zoomIn }, { label: "Zoom out", cmd: C.zoomOut }, { cmd: C.zoom100 }, { cmd: C.zoomFit }, { cmd: C.zoomSelection }] }, { label: "Object", items: [{ cmd: C.bringFront }, { cmd: C.bringForward }, { cmd: C.sendBackward }, { cmd: C.sendBack }, "-", { cmd: C.flipH }, { cmd: C.flipV }, "-", { label: "Show/Hide selection", cmd: C.hide }, { label: "Lock/Unlock selection", cmd: C.lock }] }, { label: "Text", items: [{ cmd: C.bold }, { cmd: C.italic }, { cmd: C.underline }] }, { label: "Arrange", items: [{ cmd: C.alignLeft }, { cmd: C.alignCenter }, { cmd: C.alignRight }, "-", { cmd: C.alignTop }, { cmd: C.alignMiddle }, { cmd: C.alignBottom }, "-", { cmd: C.distributeH }, { cmd: C.distributeV }] }, "-", { label: "Help and account", items: [{ cmd: C.shortcuts }] }, ]; } function avatar(app) { const agent = app.params.agent; return h("div", { class: `avatar ${agent ? "agent" : ""}`, title: agent ? "Agent session" : "You" }, agent ? "AI" : "Y"); } // ================================================================ Figma function figma(app, root) { // Modelled on Figma's current layout: a narrow navigation rail (File, Assets, ...), the file // panel (name, Pages, Layers), the canvas with the floating toolbar, and the properties panel // (Design / Prototype tabs over Position, Layout, Typography, Fill, Appearance). const C = app.commands; const s = app.store; const layers = layersPanel(app, { thumbs: false, frame: "Frame 1" }); const insp = inspector(app, { variant: "figma" }); // left: navigation rail + file panel const logo = h("button", { class: "fg-logo", type: "button", "aria-label": "Main menu" }); logo.innerHTML = `${icon("pen-tool", 12)}${icon("chevron-down", 10)}`; logo.addEventListener("click", () => menu(logo, mainMenuItems(app))); const navItem = (id, ic, label, run) => { const b = h("button", { class: "fg-nav-btn", type: "button", dataset: { nav: id }, "aria-label": label }, h("span", { class: "fg-nav-ic", html: icon(ic, 16) }), h("span", { class: "fg-nav-label" }, label)); b.addEventListener("click", run); return b; }; const leftHost = h("div", { class: "fg-left-body" }); const fileName = h("div", { class: "fg-file-name" }, "Untitled"); const fileHead = h("div", { class: "fg-filehead" }, h("div", { class: "fg-file" }, h("div", { class: "fg-file-row" }, fileName), h("div", { class: "fg-file-sub" }, "Drafts")), ); const pages = h("div", { class: "fg-sec" }, h("div", { class: "fg-sec-head" }, h("span", {}, "Pages")), h("div", { class: "fg-page on" }, h("span", { class: "fg-page-dot" }), "Page 1")); const layersHead = h("div", { class: "fg-sec-head fg-layers-head" }, h("span", {}, "Layers")); const views = { file: { el: h("div", { class: "fg-view" }, fileHead, pages, layersHead, layers.el), refresh: layers.refresh }, }; const showLeft = mountSwitcher(leftHost, views); let leftView = "file"; const nav = h("nav", { class: "fg-nav", "aria-label": "Navigation" }, logo, navItem("file", "file", "File", () => setLeft("file")), h("span", { class: "grow" }), iconButton("help-circle", "Help and resources", () => C.shortcuts.run(), { cls: "fg-mini" })); const setLeft = (id) => { leftView = id; showLeft(id); nav.querySelectorAll(".fg-nav-btn").forEach((b) => b.classList.toggle("on", b.dataset.nav === id)); }; const left = h("aside", { class: "fg-panel fg-left" }, leftHost); // right: header (avatar, present, Export), Design / Prototype tabs + zoom, then the panel const rightHost = h("div", { class: "fg-right-body" }); const showRight = mountSwitcher(rightHost, { design: insp }); const rt = tabs([["design", "Design"]], showRight, { cls: "fg-tabs" }); const submit = submitButton(app, "fg-share"); const right = h("aside", { class: "fg-panel fg-right" }, h("div", { class: "fg-head fg-head-right" }, h("span", { class: "grow" }), submit), h("div", { class: "fg-tabrow" }, rt, h("span", { class: "grow" }), zoomLabel(app)), rightHost); // canvas + floating toolbar: Move, Frame, Shape, Pen, Text, Comment, Actions | Draw / Design / Dev const stageHost = h("div", { class: "fg-center stage-host" }); const toolBtn = (id, tool) => { const b = commandButton(C[id], { cls: "fg-tool" }); b.refresh = () => { b.classList.toggle("on", s.tool === tool || (tool === "rect" && s.tool === "ellipse")); }; return b; }; const caret = (title, items) => { const b = iconButton("chevron-down", title, () => menu(b, items), { cls: "fg-caret", size: 10 }); return b; }; const moveBtn = toolBtn("toolMove", "move"); const rectBtn = toolBtn("toolRect", "rect"); const textBtn = toolBtn("toolText", "text"); const tools = [moveBtn, rectBtn, textBtn]; const toolbar = h("div", { class: "fg-toolbar", role: "toolbar", "aria-label": "Tools" }, moveBtn, caret("Move tools", [{ cmd: C.toolMove }, { cmd: C.toolHand }, { cmd: C.zoomFit }]), rectBtn, caret("Shape tools", [{ cmd: C.toolRect }, { cmd: C.toolEllipse }, "-", { label: "Image/video…", cmd: C.placeImage }]), textBtn, ...(app.showCode ? [h("span", { class: "fg-tsep" }), commandButton(C.html, { cls: "fg-tool" })] : [])); stageHost.append(toolbar); root.append(nav, left, stageHost, right); setLeft("file"); rt.set("design"); const refreshables = [...tools, submit]; return { stageHost, panels: [layers, insp], // Figma: chosen images wait on the cursor for a click (or a drag to size them); dropped files land where dropped. placeAssets: (assets, point, how) => (how.dropped || point ? app.placeAt(assets, point) : app.stage.startPlacing(assets)), fileOptions: () => ({ title: "Open" }), // Figma's canvas menu, trimmed to what works here, in its order and with its shortcuts. contextItems: (onElement) => (onElement ? [{ cmd: C.copy }, { label: "Paste here", cmd: C.paste }, "-", { cmd: C.bringFront }, { cmd: C.sendBack }, "-", { label: "Show/Hide", cmd: C.hide }, { label: "Lock/Unlock", cmd: C.lock }, "-", { cmd: C.flipH }, { cmd: C.flipV }] : [{ label: "Paste here", cmd: C.paste }, "-", { cmd: C.selectAll }, "-", { cmd: C.zoomFit }, { cmd: C.zoom100 }]), refresh() { fileName.textContent = s.task?.title || "Untitled"; refreshables.forEach((b) => b.refresh?.()); }, reveal(which) { if (which === "assets") app.openFiles(); if (which === "position") rt.set("design"); }, leftView: () => leftView, }; } // ================================================================ Canva function canvaContextBar(app) { // Canva's floating toolbar: a pill over the page that appears only while something is selected, // with the selection's own tools (text: font, size, colour, style, spacing; image: edit, flip ...). const s = app.store; const C = app.commands; const m = selectionModel(app); const bar = h("div", { class: "cv-context", role: "toolbar", "aria-label": "Element toolbar" }); const controls = []; const add = (c) => { controls.push(c); return c; }; const textOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined); const word = (label, run, cls = "") => h("button", { class: `btn cv-textbtn ${cls}`, type: "button", onclick: run }, label); // Font and colour open in Canva's side panel (see canva()). const font = add(fontPicker({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]), cls: "cv-font", docFonts: () => s.task?.fonts_used || [], open: () => app.skinApi.reveal("font"), preview: false })); const size = add(numberField({ label: "", title: "Font size", digits: 2, min: 1, max: 2000, cls: "cv-size", get: tget("font_size"), set: (v) => m.set({ font_size: v }, ["text"]) })); const stepSize = (d) => m.set((e) => ({ font_size: Math.max(1, Math.round(e.font_size + d)) }), ["text"]); const sizeGroup = h("div", { class: "cv-size-group" }, iconButton("minus", "Decrease font size", () => stepSize(-1)), size, iconButton("plus", "Increase font size", () => stepSize(1))); const colorBtn = h("button", { class: "icon-btn cv-textcolor", type: "button", title: "Text colour", "aria-label": "Text colour" }); colorBtn.innerHTML = `A`; colorBtn.addEventListener("click", () => app.skinApi.reveal("color")); const tog = (prop, iconName, title, face = null) => { const b = add(toggle(iconName, title, () => m.common(prop, ["text"]) === true, (v) => m.set({ [prop]: v }, ["text"]))); const base = b.set; b.set = () => { base(); const fam = textOnly() ? m.common("font_family", ["text"]) : null; b.disabled = !!(face && fam && !hasStyle(fam, face) && !hasStyle(fam, "bolditalic")); }; return b; }; const bold = tog("bold", "bold", "Bold", "bold"); const italic = tog("italic", "italic", "Italic", "italic"); const underline = tog("underline", "underline", "Underline"); const upper = tog("uppercase", "case-upper", "Uppercase"); const alignOrder = ["left", "center", "right"]; const alignBtn = iconButton("align-center", "Alignment", () => { const cur = m.common("align", ["text"]) || "left"; m.set({ align: alignOrder[(alignOrder.indexOf(cur) + 1) % 3] }, ["text"]); }); const spacingBtn = iconButton("letter-spacing", "Spacing", () => spacingPopover(spacingBtn, m, tget)); const fill = add(colorField({ label: "", showHex: false, cls: "cv-fill", get: () => (m.els().every((e) => e.kind === "shape") ? m.common("fill", ["shape"]) : undefined), set: (c) => m.set({ fill: c }, ["shape"]), docColors: () => [], open: () => app.skinApi.reveal("color") })); const borderBtn = iconButton("square-dashed", "Border style", () => { const sc = colorField({ label: "Border colour", get: () => m.common("stroke", ["shape"]), set: (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 4 }), ["shape"]) }); const sw = numberField({ label: "Weight", min: 0, get: () => m.common("stroke_width", ["shape"]), set: (v) => m.set({ stroke_width: v }, ["shape"]) }); const rr = numberField({ label: "Corner rounding", min: 0, get: () => m.common("radius", ["shape"]), set: (v) => m.set({ radius: v }, ["shape"]) }); [sc, sw, rr].forEach((c) => c.set()); popover(borderBtn, h("div", { class: "cv-pop-body" }, h("div", { class: "cv-pop-title" }, "Border"), sc, sw, rr)); }); const flipBtn = word("Flip", () => menu(flipBtn, [{ cmd: C.flipH }, { cmd: C.flipV }])); const positionBtn = h("button", { class: "btn cv-textbtn", type: "button", "aria-label": "Position", dataset: { open: "position" } }, "Position"); positionBtn.addEventListener("click", () => app.skinApi.reveal("position")); const transparency = iconButton("grid-3x3", "Transparency", () => { const sl = slider({ label: "Transparency", name: "opacity", unit: "%", min: 0, max: 100, get: () => { const v = m.common("opacity"); return v === null || v === undefined ? v : Math.round(v * 100); }, set: (v) => m.set({ opacity: v / 100 }) }); sl.set(); popover(transparency, h("div", { class: "cv-pop-body" }, sl), { align: "end" }); }); const sep = () => h("span", { class: "cv-sep" }); const groups = { text: h("div", { class: "cv-group" }, font, sizeGroup, colorBtn, bold, italic, underline, upper, alignBtn, spacingBtn), image: h("div", { class: "cv-group" }, flipBtn), shape: h("div", { class: "cv-group" }, fill, borderBtn), common: h("div", { class: "cv-group" }, transparency, positionBtn), }; bar.append(groups.text, groups.image, groups.shape, groups.common); bar.refresh = () => { const els = m.els(); const kinds = new Set(els.map((e) => e.kind)); bar.hidden = els.length === 0; groups.text.hidden = !(els.length && kinds.size === 1 && kinds.has("text")); groups.shape.hidden = !(els.length && kinds.size === 1 && kinds.has("shape")); groups.image.hidden = !(els.length && kinds.size === 1 && kinds.has("image")); colorBtn.querySelector(".bar").style.background = tget("color")() || "#000"; const al = m.common("align", ["text"]) || "left"; alignBtn.innerHTML = icon(`align-${al}`, 16); for (const c of controls) c.set?.(); }; return bar; } function canvaPosition(app) { // Canva's Position panel: an Arrange tab (order, align to page, exact numbers) and a Layers tab. const C = app.commands; const cb = (id, label) => { const b = h("button", { class: "btn cv-arrange", type: "button", dataset: { command: id } }, h("span", { html: icon(C[id].icon, 16) }), label); b.addEventListener("click", () => C[id].run()); return b; }; const insp = inspector(app, { variant: "canva" }); const layers = layersPanel(app, { thumbs: true }); const arrange = { el: h("div", { class: "panel-body cv-position" }, h("div", { class: "panel-heading" }, "Arrange"), h("div", { class: "cv-arrange-grid" }, cb("bringForward", "Forward"), cb("sendBackward", "Backward"), cb("bringFront", "To front"), cb("sendBack", "To back")), h("div", { class: "panel-heading" }, "Align to page"), h("div", { class: "cv-arrange-grid three" }, cb("alignTop", "Top"), cb("alignLeft", "Left"), cb("alignMiddle", "Middle"), cb("alignCenter", "Centre"), cb("alignBottom", "Bottom"), cb("alignRight", "Right")), h("div", { class: "panel-heading" }, "Advanced"), insp.el), refresh: insp.refresh }; const host = h("div", {}); const show = mountSwitcher(host, { arrange, layers }); const bar = tabs([["arrange", "Arrange"], ["layers", "Layers"]], show, { cls: "cv-pos-tabs" }); bar.set("arrange"); return { el: h("div", {}, bar, host), refresh: () => { arrange.refresh(); layers.refresh(); }, tabs: bar }; } function canva(app, root) { // Modelled on Canva's current editor: gradient header (menu, File, Resize, undo/redo, saved; design // type, people, Share), the side rail and its flyouts, the contextual toolbar, the page with its // header, and the footer with Notes / Duration / Timer, page count and zoom. const s = app.store; const C = app.commands; const flyHost = h("div", { class: "cv-fly-body" }); const flyTitle = h("div", { class: "cv-fly-title" }); // Canva's Background panel: a row of colours led by the palette button (which opens the colour panel). const bgRow = h("div", { class: "cv-bgrow" }); const background = { el: h("div", { class: "panel-body cv-bgpanel" }, bgRow), refresh: () => { const cur = (s.doc?.background || "").toLowerCase(); bgRow.textContent = ""; const pal = h("button", { class: "cv-swatch cv-bgpalette", type: "button", title: "Background color", "aria-label": "Background color", html: icon("palette", 16), onclick: () => app.skinApi.reveal("bgcolor") }); bgRow.append(pal, ...["#ffffff", "#000000", ...CANVA_PALETTE.filter((c) => !["#000000", "#ffffff"].includes(c)).slice(0, 12)].map((c) => h("button", { class: `cv-swatch ${c === cur ? "on" : ""}`, type: "button", title: c.toUpperCase(), style: { background: c }, dataset: { color: c }, onclick: () => s.op("set_background", { color: c }) }))); } }; const elementsPanel = shapesPanel(app); // Uploads holds what has been uploaded: some episodes start with the images already there, // others with an empty panel, where Upload files opens the computer's file window first. app.uploaded = new Set(preUploaded(app) ? s.assets.map((a) => a.id) : []); const uploadBtn = h("button", { class: "btn btn-primary cv-upload", type: "button", dataset: { open: "files" }, onclick: () => app.openFiles() }, "Upload files"); const uploadsAssets = assetsPanel(app, { cols: 2, search: true, placeholder: "Search keywords, tags, color", filter: (a) => app.uploaded.has(a.id), empty: "Media you upload will appear here.", top: uploadBtn }); const uploads = { el: h("div", { class: "cv-uploads" }, uploadsAssets.el), refresh: uploadsAssets.refresh }; const position = canvaPosition(app); const panels = { elements: { title: "Elements", panel: elementsPanel }, text: { title: "Text", panel: textPresets(app) }, uploads: { title: "Uploads", panel: uploads }, background: { title: "Background", panel: background }, }; // Position, colour and font open in the side panel's place, as in the real editor. const m = selectionModel(app); const docColors = () => [...new Set([s.doc?.background, ...s.doc.elements.map((e) => (e.kind === "text" ? e.color : e.kind === "shape" ? e.fill : null))].filter(Boolean))]; const onlyText = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const colors = colorPanel(app, { docColors, kind: () => (onlyText() ? "Text colors" : "Colors"), get: () => (onlyText() ? m.common("color", ["text"]) : m.common("fill", ["shape"])), set: (c) => (onlyText() ? m.set({ color: c }, ["text"]) : m.set({ fill: c }, ["shape"])) }); const fonts = fontPanel(app, { get: () => m.common("font_family", ["text"]), set: (f) => m.set({ font_family: f }, ["text"]), docFonts: () => [...s.doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family), ...(s.task?.fonts_used || [])] }); const bgColors = colorPanel(app, { docColors, kind: "Colors", get: () => s.doc?.background, set: (c) => s.op("set_background", { color: c }) }); const editViews = { position: { title: "Position", view: position }, color: { title: "Text color", view: colors }, font: { title: "Font", view: fonts }, bgcolor: { title: "Background color", view: bgColors } }; const editTitle = h("div", { class: "cv-edit-title" }, "Position"); const editBody = h("div", { class: "cv-edit-body" }); const editPanel = h("div", { class: "cv-editpanel closed", dataset: { panel: "" } }, h("div", { class: "cv-edit-head" }, editTitle, iconButton("x", "Close", () => openEdit(null), { cls: "cv-edit-close" })), editBody); const openEdit = (id) => { if (id && current) open(null); // the edit card takes the side panel's place editPanel.dataset.panel = id || ""; editPanel.classList.toggle("closed", !id); editBody.textContent = ""; if (!id) return; const v = editViews[id]; editTitle.textContent = id === "color" && !onlyText() ? "Color" : v.title; editPanel.dataset.panel = id; editBody.append(v.view.el); v.view.refresh(); if (id === "position") position.tabs.set(position.tabs.current()); v.view.focus?.(); }; let current = null; let lastSel = ""; const fly = h("div", { class: "cv-flyout", dataset: { panel: "" } }, h("div", { class: "cv-fly-head" }, flyTitle, iconButton("x", "Close", () => open(null), { cls: "cv-fly-close" })), flyHost, h("button", { class: "cv-fly-handle", type: "button", "aria-label": "Hide panel", html: icon("chevron-left", 14), onclick: () => open(null) })); const railBtns = {}; const rail = h("nav", { class: "cv-rail", "aria-label": "Side panels" }); const railDefs = [["elements", "shapes", "Elements"], ["text", "type", "Text"], ["uploads", "cloud-upload", "Uploads"], null, ["background", "grid-3x3", "Background"]]; for (const d of railDefs) { if (!d) { rail.append(h("span", { class: "cv-rail-sep" })); continue; } const [id, ic, label] = d; const b = h("button", { class: "cv-rail-btn", type: "button", dataset: { panel: id } }, h("span", { class: "cv-rail-ic", html: icon(ic, 22) }), h("span", {}, label)); b.addEventListener("click", () => open(current === id ? null : id)); railBtns[id] = b; rail.append(b); } const codeRail = h("button", { class: "cv-rail-btn", type: "button", dataset: { command: "html" } }, h("span", { class: "cv-rail-ic", html: icon("code", 22) }), h("span", {}, "Code")); codeRail.addEventListener("click", () => C.html.run()); if (app.showCode) rail.append(codeRail); const open = (id) => { if (id && editPanel.dataset.panel) { editPanel.dataset.panel = ""; editPanel.classList.add("closed"); } current = id; for (const [k, b] of Object.entries(railBtns)) b.classList.toggle("on", k === id); fly.classList.toggle("closed", !id); fly.dataset.panel = id || ""; flyHost.textContent = ""; if (id) { flyTitle.textContent = panels[id].title; flyHost.append(panels[id].panel.el); panels[id].panel.refresh(); } requestAnimationFrame(() => app.stage?.drawOverlay()); }; const fileBtn = h("button", { class: "cv-hbtn", type: "button" }, "File"); fileBtn.addEventListener("click", () => menu(fileBtn, [...(app.showCode ? [{ cmd: C.html }, "-"] : []), { label: "Download / Export…", cmd: C.submit }, "-", { cmd: C.shortcuts }])); const undo = commandButton(C.undo, { cls: "cv-hicon" }); const redo = commandButton(C.redo, { cls: "cv-hicon" }); const saved = h("span", { class: "cv-saved", title: "All changes saved" }, h("span", { html: icon("cloud-check", 18) })); const title = h("div", { class: "cv-title" }, "Untitled design"); const submit = submitButton(app, "cv-share"); const header = h("header", { class: "cv-header" }, fileBtn, h("span", { class: "cv-hsep" }), undo, redo, saved, h("span", { class: "grow" }), title, avatar(app), submit); const ctx = canvaContextBar(app); const stageHost = h("div", { class: "cv-stage stage-host" }); const zoomRange = h("input", { type: "range", min: "10", max: "400", value: "100", class: "cv-zoom-range", "aria-label": "Zoom" }); zoomRange.addEventListener("input", () => app.stage.setZoom(zoomRange.value / 100)); const zl = zoomLabel(app); app.on("zoom", (z) => { zoomRange.value = String(Math.round(z * 100)); }); const footer = h("footer", { class: "cv-footer" }, h("span", { class: "grow" }), zoomRange, zl, h("span", { class: "cv-pagecount" }, "Page 1 / 1"), iconButton("maximize", "Fit to screen", () => C.zoomFit.run(), { cls: "cv-ficon" }), iconButton("help-circle", "Keyboard shortcuts", () => C.shortcuts.run(), { cls: "cv-ficon" })); const main = h("div", { class: "cv-main" }, h("div", { class: "cv-stage-wrap" }, stageHost, ctx), footer); root.append(header, h("div", { class: "cv-body" }, rail, fly, editPanel, main)); open("uploads"); return { stageHost, panels: [...Object.values(panels).map((p) => p.panel), position, { el: colors.el, refresh: colors.refresh }, { el: fonts.el, refresh: fonts.refresh }, { el: bgColors.el, refresh: bgColors.refresh }], refresh() { // Canva names a new design after its first text, until it is renamed. const first = s.doc.elements.find((e) => e.kind === "text" && e.text.trim()); title.textContent = first ? first.text.trim().split("\n")[0].slice(0, 40) : "Untitled design"; ctx.refresh(); [undo, redo].forEach((b) => b.refresh()); submit.refresh(); if (editPanel.dataset.panel && !s.selection.length) openEdit(null); // On narrow windows the side panel floats over the page; selecting on the canvas puts it away. if (s.selection.length && current && matchMedia("(max-width: 920px)").matches && s.selection.join() !== lastSel) open(null); lastSel = s.selection.join(); }, reveal(which) { if (which === "assets") open("uploads"); else if (which in editViews) openEdit(editPanel.dataset.panel === which ? null : which); // the button toggles its panel }, // Canva: Upload files only uploads (the images then wait in Uploads); a file dropped on the page is placed there too. placeAssets: async (assets, point, how) => { assets.forEach((a) => app.uploaded.add(a.id)); if (how.dropped) await app.placeAt(assets, point); open("uploads"); uploadsAssets.refresh(); }, }; } // ================================================================ Photoshop function photoshop(app, root) { const s = app.store; const C = app.commands; const m = selectionModel(app); const menus = { File: [{ label: "Place Embedded…", cmd: C.placeImage }, ...(app.showCode ? [{ cmd: C.html }] : []), "-", { label: "Export As…", cmd: C.submit }], Edit: [{ cmd: C.undo }, { cmd: C.redo }, "-", { cmd: C.cut }, { cmd: C.copy }, { cmd: C.paste }, { label: "Clear", cmd: C.delete }, "-", { cmd: C.freeTransform }, { label: "Transform: Flip Horizontal", cmd: C.flipH }, { label: "Transform: Flip Vertical", cmd: C.flipV }, "-", { cmd: C.shortcuts }], Layer: [{ label: "New Text Layer", cmd: C.addBody }, { label: "New Shape Layer", cmd: C.addRect }, { label: "Duplicate Layer", cmd: C.duplicate }, { label: "Delete Layer", cmd: C.delete }, "-", { header: "Arrange" }, { cmd: C.bringFront }, { cmd: C.bringForward }, { cmd: C.sendBackward }, { cmd: C.sendBack }, "-", { header: "Align" }, { label: "Left Edges", cmd: C.alignLeft }, { label: "Horizontal Centers", cmd: C.alignCenter }, { label: "Right Edges", cmd: C.alignRight }, { label: "Top Edges", cmd: C.alignTop }, { label: "Vertical Centers", cmd: C.alignMiddle }, { label: "Bottom Edges", cmd: C.alignBottom }, "-", { label: "Lock Layers…", cmd: C.lock }, { label: "Hide Layers", cmd: C.hide }], Type: [{ label: "Add Heading", cmd: C.addHeading }, { label: "Add Subheading", cmd: C.addSubheading }, { label: "Add Paragraph", cmd: C.addBody }], Select: [{ label: "All Layers", cmd: C.selectAll }, { cmd: C.deselect }], View: [{ cmd: C.zoomIn }, { cmd: C.zoomOut }, { label: "Fit on Screen", cmd: C.zoomFit }, { label: "100%", cmd: C.zoom100 }], Window: [{ label: "Color", run: () => dock0.set("color") }, { label: "Swatches", run: () => dock0.set("swatches") }, "-", { label: "Properties", run: () => dock1.set("properties") }, "-", { label: "Layers", run: () => dock2.set("layers") }], Help: [{ cmd: C.shortcuts }], }; // Photoshop's menu bar order, less the menus with nothing that works here (Image, Filter, 3D, Plugins). const order = ["File", "Edit", "Layer", "Type", "Select", "View", "Window", "Help"]; for (const k of Object.keys(menus)) if (!order.includes(k)) order.push(k); const sorted = Object.fromEntries(order.filter((k) => menus[k]).map((k) => [k, menus[k]])); for (const k of Object.keys(menus)) delete menus[k]; Object.assign(menus, sorted); const menubar = h("div", { class: "ps-menubar", role: "menubar" }, h("div", { class: "ps-logo brand-mark mark-pixel", title: "Pixel", html: icon("grid-3x3", 13) })); for (const [name, items] of Object.entries(menus)) { const b = h("button", { class: "ps-menu", type: "button", role: "menuitem" }, name); b.addEventListener("click", () => menu(b, items)); b.addEventListener("pointerenter", () => { if (document.querySelector(".pop-menu") && !b.matches(":focus")) { closePopover(); b.click(); } }); menubar.append(b); } const submit = submitButton(app, "ps-share"); menubar.append(h("span", { class: "grow" }), submit); // options bar: contents follow the active tool / selection const options = h("div", { class: "ps-options" }); // Every colour swatch opens Photoshop's Color Picker dialog; Add to Swatches feeds the Swatches panel. app.psSwatches = app.psSwatches || []; app.addSwatch = (c) => { if (!app.psSwatches.includes(c)) app.psSwatches.push(c); swatchPanel.refresh(); }; const dialog = (title) => (chip, get, set) => openColorDialog(chip, { title, get, set, addSwatch: app.addSwatch }); app.autoSelect = true; // Photoshop / Photopea: Auto-Select (Layer) on app.transformControls = false; // and Show Transform Controls off: handles come with Mod+T const fgColor = () => app.fgColor; const optControls = []; const opt = (c) => { optControls.push(c); return c; }; const textOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined); const psFont = opt(fontPicker({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]), cls: "ps-font", docFonts: () => s.task?.fonts_used || [] })); const psStyle = h("select", { class: "ps-select", "aria-label": "Font style" }, ["Regular", "Bold", "Italic", "Bold Italic"].map((o) => h("option", { value: o }, o))); psStyle.addEventListener("change", () => { const v = psStyle.value; m.set({ bold: v.includes("Bold"), italic: v.includes("Italic") }, ["text"]); }); const psSize = opt(numberField({ label: "", iconName: "type", title: "Font size", unit: "px", digits: 2, min: 1, max: 2000, get: tget("font_size"), set: (v) => m.set({ font_size: v }, ["text"]), cls: "ps-num" })); const psAlign = opt(segmented([["left", "align-left", "Left align text"], ["center", "align-center", "Center text"], ["right", "align-right", "Right align text"]], tget("align"), (v) => m.set({ align: v }, ["text"]))); const psColor = opt(colorField({ label: "", showHex: false, get: tget("color"), set: (c) => m.set({ color: c }, ["text"]), cls: "ps-color", open: dialog("Color Picker (Text Color)") })); // While typing, the options bar ends with Cancel (x) and Commit (check), as in Photoshop. const cancelEdit = iconButton("x", "Cancel any current edits (Esc)", () => app.stage.cancelEdit(), { cls: "ps-edit-x" }); const commitEdit = iconButton("check", "Commit any current edits", () => app.stage.commitEdit(), { cls: "ps-edit-ok" }); const editBtns = h("div", { class: "ps-editbtns" }, cancelEdit, commitEdit); app.on("editing", () => app.skinApi?.refresh()); const typeOpts = h("div", { class: "ps-optgroup" }, h("span", { class: "ps-tool-ic", html: icon("type", 16) }), psFont, psStyle, psSize, psAlign, psColor, h("span", { class: "grow" }), editBtns); const alignBtns = ["alignLeft", "alignCenter", "alignRight", "alignTop", "alignMiddle", "alignBottom", "distributeH", "distributeV"].map((id) => commandButton(C[id])); const check = (label, get, set, cls) => { const box = h("input", { type: "checkbox", class: `ps-check ${cls}`, "aria-label": label }); box.addEventListener("change", () => { set(box.checked); app.stage.drawOverlay(); app.refreshAll(); }); const l = h("label", { class: "ps-checklabel" }, box, label); l.set = () => { box.checked = !!get(); }; return opt(l); }; const autoSel = check("Auto-Select", () => app.autoSelect, (v) => { app.autoSelect = v; }, "ps-autoselect"); const showTc = check("Show Transform Controls", () => app.transformControls, (v) => { app.transformControls = v; }, "ps-transform"); const moveOpts = h("div", { class: "ps-optgroup" }, h("span", { class: "ps-tool-ic", html: icon("move", 16) }), autoSel, h("span", { class: "ps-optlabel" }, "Layer"), showTc, h("span", { class: "tb-sep" }), ...alignBtns); const shapeFill = opt(colorField({ label: "Fill:", showHex: false, get: () => (m.els().length && m.els().every((e) => e.kind === "shape") ? m.common("fill", ["shape"]) : app.fgColor), set: (c) => { if (m.els().length) m.set({ fill: c }, ["shape"]); else app.setFg(c); }, cls: "ps-color", open: dialog("Color Picker (Fill Color)") })); const shapeStroke = opt(colorField({ label: "Stroke:", showHex: false, get: () => (m.els().every((e) => e.kind === "shape") ? m.common("stroke", ["shape"]) : undefined), set: (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 4 }), ["shape"]), cls: "ps-color", open: dialog("Color Picker (Stroke Color)") })); const shapeW = opt(numberField({ label: "", iconName: "minus", title: "Stroke width", unit: "px", min: 0, get: () => (m.els().every((e) => e.kind === "shape") ? m.common("stroke_width", ["shape"]) : undefined), set: (v) => m.set({ stroke_width: v }, ["shape"]), cls: "ps-num" })); const shapeOpts = h("div", { class: "ps-optgroup" }, h("span", { class: "ps-tool-ic", html: icon("square", 16) }), h("span", { class: "ps-optlabel" }, "Shape"), shapeFill, shapeStroke, shapeW); const handOpts = h("div", { class: "ps-optgroup" }, h("span", { class: "ps-tool-ic", html: icon("hand", 16) }), h("button", { class: "btn ps-btn", type: "button", onclick: () => C.zoom100.run() }, "100%"), h("button", { class: "btn ps-btn", type: "button", onclick: () => C.zoomFit.run() }, "Fit Screen")); options.append(moveOpts, typeOpts, shapeOpts, handOpts); // Tools column, in Photoshop's order, holding the tools that work here: Move, Type, the shape tools, Hand and Zoom. const toolBtns = []; const toolcol = h("div", { class: "ps-tools", role: "toolbar", "aria-label": "Tools" }); const live = (id, tool, title) => { const b = commandButton(C[id], { cls: "ps-tool" }); b.title = b.ariaLabel = title; b.setAttribute("aria-label", title); b.refresh = () => b.classList.toggle("on", s.tool === tool); toolBtns.push(b); return b; }; const sep = () => h("span", { class: "ps-tsep" }); toolcol.append( live("toolMove", "move", "Move Tool (V)"), sep(), live("toolText", "text", "Horizontal Type Tool (T)"), live("toolRect", "rect", "Rectangle Tool (U)"), live("toolEllipse", "ellipse", "Ellipse Tool (U)"), sep(), live("toolHand", "hand", "Hand Tool (H)"), iconButton("zoom-in", "Zoom Tool (Z)", () => C.zoomIn.run(), { cls: "ps-tool" })); const fgChip = h("button", { class: "ps-fg", type: "button", title: "Set foreground color", "aria-label": "Set foreground color" }); fgChip.addEventListener("click", () => openColorDialog(fgChip, { title: "Color Picker (Foreground Color)", get: () => app.fgColor, set: (c) => app.setFg(c), addSwatch: app.addSwatch })); const swatches = h("div", { class: "ps-swatches" }, fgChip, h("span", { class: "ps-bgc" })); toolcol.append(sep(), swatches); // document area const docTab = h("div", { class: "ps-doctab on" }, h("span", { class: "ps-doctab-name" }), ); const stageHost = h("div", { class: "ps-stage stage-host" }); const status = h("div", { class: "ps-status" }); const docArea = h("div", { class: "ps-docarea" }, h("div", { class: "ps-doctabs" }, docTab), stageHost, status); // Dock, as Photoshop's Essentials workspace (Color | Swatches, Properties | Libraries, Layers), // less the panels with nothing to do here. The Color panel sets the foreground colour. const colorBody = h("div", { class: "ps-colorpanel" }); const colorPanel = { el: colorBody, refresh: () => { if (colorBody.dataset.fg === app.fgColor) return; colorBody.dataset.fg = app.fgColor; colorBody.textContent = ""; colorBody.append(colorPicker(app.fgColor || "#000000", (c) => app.setFg(c))); } }; const SWATCHES = ["#000000", "#ffffff", "#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff", "#7f7f7f", "#c3c3c3", "#ed1c24", "#ff7f27", "#fff200", "#22b14c", "#00a2e8", "#3f48cc", "#a349a4", "#b97a57", "#ffaec9", "#ffc90e", "#efe4b0", "#b5e61d", "#99d9ea", "#7092be", "#c8bfe7", "#880015", "#1d1d1d", "#3d3d3d", "#5e5e5e", "#9e9e9e"]; const swatchGrid = h("div", { class: "ps-swatch-grid" }); const swatchPanel = { el: swatchGrid, refresh() { swatchGrid.textContent = ""; swatchGrid.append(...[...SWATCHES, ...app.psSwatches].map((c) => h("button", { class: "ps-swatch", type: "button", title: c, "aria-label": `Swatch ${c}`, style: { background: c }, onclick: () => app.setFg(c) }))); } }; swatchPanel.refresh(); const d0host = h("div", { class: "ps-dock-body" }); const show0 = mountSwitcher(d0host, { color: colorPanel, swatches: swatchPanel }); const dock0 = tabs([["color", "Color"], ["swatches", "Swatches"]], show0, { cls: "ps-tabs" }); const propsPanel = inspector(app, { variant: "photoshop" }); const d1host = h("div", { class: "ps-dock-body" }); const group1 = { properties: propsPanel }; const tabs1 = [["properties", "Properties"]]; const show1 = mountSwitcher(d1host, group1); const dock1 = tabs(tabs1, show1, { cls: "ps-tabs" }); const layers = layersPanel(app, { thumbs: true, background: true, compact: true }); const opac = opt(numberField({ label: "Opacity:", unit: "%", min: 0, max: 100, cls: "ps-num", get: () => { const v = m.common("opacity"); return v === null || v === undefined ? v : Math.round(v * 100); }, set: (v) => m.set({ opacity: v / 100 }) })); const lockBtn = commandButton(C.lock, { cls: "ps-mini" }); const layerHead = h("div", { class: "ps-layer-head" }, h("div", { class: "ps-row" }, h("span", { class: "ps-optlabel" }, "Lock:"), lockBtn, h("span", { class: "grow" }), opac)); const layerFoot = h("div", { class: "ps-layer-foot" }, commandButton(C.duplicate, { cls: "ps-mini" }), iconButton("square-plus", "New shape layer", () => C.addRect.run(), { cls: "ps-mini" }), commandButton(C.delete, { cls: "ps-mini" })); const layersWrap = { el: h("div", { class: "ps-layers" }, layerHead, layers.el, layerFoot), refresh: () => { layers.refresh(); lockBtn.refresh(); } }; const d2host = h("div", { class: "ps-dock-body grow" }); const show2 = mountSwitcher(d2host, { layers: layersWrap }); const dock2 = tabs([["layers", "Layers"]], show2, { cls: "ps-tabs" }); const dock = h("aside", { class: "ps-dock" }, h("div", { class: "ps-group ps-group-color" }, dock0, d0host), h("div", { class: "ps-group" }, dock1, d1host), h("div", { class: "ps-group grow" }, dock2, d2host)); root.append(menubar, options, h("div", { class: "ps-body" }, toolcol, docArea, dock)); dock0.set("color"); dock1.set("properties"); dock2.set("layers"); return { stageHost, panels: [colorPanel, propsPanel, layersWrap], refresh() { const tool = s.tool; const sel = m.els(); const kinds = new Set(sel.map((e) => e.kind)); const textSel = sel.length && kinds.size === 1 && kinds.has("text"); const shapeSel = sel.length && kinds.size === 1 && kinds.has("shape"); typeOpts.hidden = !(tool === "text" || textSel); editBtns.hidden = !app.stage.editing; shapeOpts.hidden = !(tool === "rect" || tool === "ellipse" || shapeSel) || !typeOpts.hidden; handOpts.hidden = tool !== "hand"; moveOpts.hidden = !typeOpts.hidden || !shapeOpts.hidden || !handOpts.hidden; const b = m.common("bold", ["text"]), it = m.common("italic", ["text"]); psStyle.value = b && it ? "Bold Italic" : b ? "Bold" : it ? "Italic" : "Regular"; psStyle.disabled = !textSel; for (const c of optControls) c.set?.(); alignBtns.forEach((x) => x.refresh()); toolBtns.forEach((x) => x.refresh()); fgChip.style.background = fgColor(); submit.refresh(); const z = Math.round(app.stage.zoom * 1000) / 10; const layer = sel.length === 1 ? sel[0] : null; const layerName = layer ? (layer.kind === "text" ? (layer.text || "").split("\n")[0].slice(0, 24) : layer.name || (layer.kind === "image" ? `Layer ${layer.asset_id}` : "Shape")) : "Background"; docTab.querySelector(".ps-doctab-name").textContent = `${(s.task?.title || "Untitled").slice(0, 28)}.psd @ ${z}% (${layerName}, RGB/8)`; status.textContent = statusText(app).replace(/ · /g, " | "); colorPanel.refresh(); }, reveal(which) { if (which === "assets") app.openFiles(); }, }; } // ================================================================ shared bits for the document-style skins // Text controls as they appear in slide and vector editors' toolbars: font, size with // steppers, B / I / U, colour, alignment. `prefix` namespaces the classes (sl-, pp-, ik-). function textControls(app, prefix, { steppers = true, colorButton = true, toggles = true } = {}) { const s = app.store; const m = selectionModel(app); const controls = []; const add = (c) => { controls.push(c); return c; }; const textOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined); const font = add(fontPicker({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]), cls: `${prefix}-font`, docFonts: () => s.task?.fonts_used || [] })); const size = add(numberField({ label: "", title: "Font size", digits: 2, min: 1, max: 2000, cls: `${prefix}-size`, get: tget("font_size"), set: (v) => m.set({ font_size: v }, ["text"]) })); const step = (d) => m.set((e) => ({ font_size: Math.max(1, Math.round(e.font_size + d)) }), ["text"]); const sizeGroup = steppers ? h("div", { class: `${prefix}-sizegroup` }, iconButton("minus", "Decrease font size", () => step(-1)), size, iconButton("plus", "Increase font size", () => step(1))) : size; const tog = (prop, ic, title, face) => { const b = add(toggle(ic, title, () => m.common(prop, ["text"]) === true, (v) => m.set({ [prop]: v }, ["text"]))); const base = b.set; b.set = () => { base(); const fam = textOnly() ? m.common("font_family", ["text"]) : null; b.disabled = !textOnly() || !!(face && fam && !hasStyle(fam, face) && !hasStyle(fam, "bolditalic")); }; return b; }; const styleBtns = toggles ? [tog("bold", "bold", "Bold", "bold"), tog("italic", "italic", "Italic", "italic"), tog("underline", "underline", "Underline", null)] : []; let colorBtn = null; if (colorButton) { colorBtn = h("button", { class: `icon-btn ${prefix}-textcolor`, type: "button", title: "Text colour", "aria-label": "Text colour" }); colorBtn.innerHTML = `A`; colorBtn.addEventListener("click", () => { const field = colorField({ get: tget("color"), set: (c) => m.set({ color: c }, ["text"]), label: "Text colour", docColors: () => [...new Set(s.doc.elements.map((e) => e.color || e.fill).filter(Boolean))] }); field.set(); document.body.append(field); field.style.position = "fixed"; const r = colorBtn.getBoundingClientRect(); Object.assign(field.style, { left: `${r.left}px`, top: `${r.top}px`, opacity: "0", pointerEvents: "none" }); field.chip.click(); setTimeout(() => field.remove(), 0); }); } const align = add(segmented([["left", "align-left", "Align left"], ["center", "align-center", "Center"], ["right", "align-right", "Align right"]], tget("align"), (v) => m.set({ align: v }, ["text"]))); const refresh = () => { for (const c of controls) c.set?.(); if (colorBtn) colorBtn.querySelector(".bar").style.background = tget("color")() || "#000"; }; return { font, size, sizeGroup, styleBtns, colorBtn, align, refresh, textOnly }; } // A right-hand task pane that shows one panel at a time (Format options, Pictures, ...). function taskPane(app, cls, defs) { const host = h("div", { class: "pane-body" }); const title = h("div", { class: "pane-title" }); const el = h("aside", { class: `task-pane ${cls} closed`, dataset: { panel: "" } }, h("div", { class: "pane-head" }, title, iconButton("x", "Close", () => open(null), { cls: "pane-close" })), host); const open = (id) => { if (id && !defs[id]) return; el.dataset.panel = id || ""; el.classList.toggle("closed", !id); host.textContent = ""; if (id) { title.textContent = typeof defs[id].title === "function" ? defs[id].title() : defs[id].title; host.append(defs[id].panel.el); defs[id].panel.refresh(); } requestAnimationFrame(() => app.stage?.drawOverlay()); }; return { el, open, current: () => el.dataset.panel, retitle: () => { const id = el.dataset.panel; if (id) title.textContent = typeof defs[id].title === "function" ? defs[id].title() : defs[id].title; } }; } function thumbStrip(app, cls, label) { const s = app.store; const img = h("img", { class: "thumb-img", alt: "Slide 1", draggable: "false" }); const card = h("div", { class: "thumb-card on" }, h("span", { class: "thumb-n" }, "1"), h("div", { class: "thumb-frame" }, img)); const el = h("aside", { class: `thumb-strip ${cls}`, "aria-label": label }, card); let last = -1, timer = null; const refresh = () => { if (!s.doc || s.version === last) return; last = s.version; clearTimeout(timer); // a live miniature of the current slide, as slide editors show timer = setTimeout(() => { img.src = `/api/episodes/${s.episodeId}/render.png?size=240&v=${s.version}`; }, 400); }; return { el, refresh }; } // ================================================================ Slides (Google Slides-like) function slides(app, root) { // Google Slides (checked in the browser): menus with a "Menus" search; one pill toolbar whose text // controls are font ▾, - size +, B I U, text colour, align ▾, line spacing ▾ and Format options; // Slides' colour menu, font menu and Fonts dialog; rulers; the filmstrip; Format options on demand. const s = app.store; const C = app.commands; const m = selectionModel(app); const fo = formatOptions(app, m); const images = assetsPanel(app, { cols: 2, search: true, heading: "Google Drive · Recent", placeholder: "Search Drive" }); const pane = taskPane(app, "sl-side", { format: { title: "Format options", panel: fo }, images: { title: "Drive & Photos", panel: images } }); const textOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const shapeOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "shape"); }; const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined); const toggleText = (prop) => () => m.set((e) => ({ [prop]: !m.els().every((x) => x[prop]) }), ["text"]); const menus = { File: [...(app.showCode ? [{ cmd: C.html }, "-"] : []), { label: "Download / Export…", cmd: C.submit }, "-", { cmd: C.shortcuts }], Edit: [{ cmd: C.undo }, { cmd: C.redo }, "-", { cmd: C.cut }, { cmd: C.copy }, { cmd: C.paste }, { cmd: C.duplicate }, { cmd: C.delete }, "-", { cmd: C.selectAll }], View: [{ cmd: C.zoomIn }, { cmd: C.zoomOut }, { label: "Fit", cmd: C.zoomFit }, { cmd: C.zoom100 }], Insert: [{ label: "Image", items: [{ label: "Upload from computer", cmd: C.placeImage }, { label: "Drive & Photos", run: () => pane.open("images") }] }, { label: "Text box", cmd: C.toolText }, { label: "Shape", items: [{ label: "Rectangle", cmd: C.toolRect }, { label: "Oval", cmd: C.toolEllipse }] }], Format: [{ label: "Text", items: [{ label: "Bold", key: "Mod+B", run: toggleText("bold"), enabled: textOnly() }, { label: "Italic", key: "Mod+I", run: toggleText("italic"), enabled: textOnly() }, { label: "Underline", key: "Mod+U", run: toggleText("underline"), enabled: textOnly() }, "-", { label: "Capitalization", items: [{ label: "UPPERCASE", icon: m.common("uppercase", ["text"]) ? "check" : null, run: () => m.set({ uppercase: true }, ["text"]), enabled: textOnly() }, { label: "As typed", icon: textOnly() && !m.common("uppercase", ["text"]) ? "check" : null, run: () => m.set({ uppercase: false }, ["text"]), enabled: textOnly() }] }] }, { label: "Align & indent", items: ["left", "center", "right"].map((v) => ({ label: v[0].toUpperCase() + v.slice(1), run: () => m.set({ align: v }, ["text"]), enabled: textOnly() })) }, { label: "Line & paragraph spacing", items: [{ label: "Single", run: () => m.set({ line_height: 1 }, ["text"]) }, { label: "1.15", run: () => m.set({ line_height: 1.15 }, ["text"]) }, { label: "1.5", run: () => m.set({ line_height: 1.5 }, ["text"]) }, { label: "Double", run: () => m.set({ line_height: 2 }, ["text"]) }] }, "-", { label: "Format options", run: () => pane.open("format") }], Slide: [{ label: "Change background", run: () => backgroundDialog(app) }], Arrange: [{ label: "Order", items: [{ cmd: C.bringFront }, { cmd: C.bringForward }, { cmd: C.sendBackward }, { cmd: C.sendBack }] }, { label: "Align", items: [{ cmd: C.alignLeft }, { cmd: C.alignCenter }, { cmd: C.alignRight }, "-", { cmd: C.alignTop }, { cmd: C.alignMiddle }, { cmd: C.alignBottom }] }, { label: "Rotate", items: [{ label: "Rotate clockwise 90°", cmd: C.rotateCw }, { label: "Rotate counter-clockwise 90°", cmd: C.rotateCcw }, "-", { label: "Flip horizontally", cmd: C.flipH }, { label: "Flip vertically", cmd: C.flipV }] }], Help: [{ cmd: C.shortcuts }], }; const menubar = h("div", { class: "sl-menubar", role: "menubar" }); for (const [name, items] of Object.entries(menus)) { const b = h("button", { class: "sl-menu", type: "button", role: "menuitem" }, name); // Menus rebuild on open so enabled / checked states follow the selection. b.addEventListener("click", () => menu(b, typeof items === "function" ? items() : name === "Format" ? rebuildFormat() : items)); menubar.append(b); } const rebuildFormat = () => { const fmt0 = menus.Format; const t = textOnly(); return fmt0.map((it) => (it && it.items && it.label === "Text" ? { ...it, items: [ { label: "Bold", key: "Mod+B", run: toggleText("bold"), enabled: t }, { label: "Italic", key: "Mod+I", run: toggleText("italic"), enabled: t }, { label: "Underline", key: "Mod+U", run: toggleText("underline"), enabled: t }, "-", { label: "Capitalization", items: [{ label: "UPPERCASE", icon: t && m.common("uppercase", ["text"]) ? "check" : null, run: () => m.set({ uppercase: true }, ["text"]), enabled: t }, { label: "As typed", icon: t && !m.common("uppercase", ["text"]) ? "check" : null, run: () => m.set({ uppercase: false }, ["text"]), enabled: t }] }] } : it && it.items && it.label === "Align & indent" ? { ...it, items: it.items.map((x) => ({ ...x, enabled: t })) } : it)); }; const title = h("div", { class: "sl-doctitle" }, "Untitled presentation"); const submit = submitButton(app, "sl-share"); const header = h("header", { class: "sl-header" }, h("span", { class: "brand-mark mark-deck", html: icon("rectangle-horizontal", 16) }), h("div", { class: "sl-titlebox" }, h("div", { class: "sl-titlerow" }, title, h("span", { class: "sl-ico", title: "All changes saved", html: icon("cloud-check", 15) })), menubar), h("span", { class: "grow" }), submit, avatar(app)); // toolbar const controls = []; const add = (c) => { controls.push(c); return c; }; const tool = (id, t) => { const b = commandButton(C[id], { cls: "sl-tool" }); b.refresh = () => b.classList.toggle("on", s.tool === t); return b; }; const tools = [tool("toolMove", "move"), tool("toolText", "text")]; const imageBtn = iconButton("image", "Insert image", () => menu(imageBtn, [{ label: "Upload from computer", cmd: C.placeImage }, { label: "Drive & Photos", run: () => pane.open("images") }]), { cls: "sl-tool" }); imageBtn.dataset.open = "images"; const shapeBtn = iconButton("shapes", "Shape", () => menu(shapeBtn, [{ cmd: C.toolRect }, { cmd: C.toolEllipse }]), { cls: "sl-tool" }); const undo = commandButton(C.undo, { cls: "sl-tool" }); const redo = commandButton(C.redo, { cls: "sl-tool" }); const zoomBtn = h("button", { class: "sl-zoom sl-drop", type: "button", "aria-label": "Zoom" }); zoomBtn.addEventListener("click", () => menu(zoomBtn, [{ label: "Fit", run: () => C.zoomFit.run() }, "-", ...[50, 100, 200].map((z) => ({ label: `${z}%`, run: () => app.stage.setZoom(z / 100) })), "-", { cmd: C.zoomIn }, { cmd: C.zoomOut }])); // "Fit" while the slide is at the zoom Fit gives, a percentage otherwise. const isFit = () => { const st = app.stage, d = s.doc, r = st.el.getBoundingClientRect(), pad = app.skin?.fitPadding ?? 48; return d && Math.abs(st.zoom - Math.min((r.width - pad * 2) / d.width, (r.height - pad * 2) / d.height)) < 1e-3; }; const formatBtn = h("button", { class: "btn sl-textbtn sl-formatbtn", type: "button", title: "Format options", dataset: { open: "format" }, html: `${icon("sliders-horizontal", 16)}Format options` }); formatBtn.addEventListener("click", () => pane.open(pane.current() === "format" ? null : "format")); const bgBtn = h("button", { class: "btn sl-textbtn sl-bgbtn", type: "button" }, "Background"); bgBtn.addEventListener("click", () => backgroundDialog(app)); const pageGroup = h("div", { class: "sl-group sl-pagegroup" }, bgBtn); // text: font ▾ | - size + | B I U A | align ▾ spacing ▾ const font = add(fontPicker({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]), cls: "sl-font", preview: false, docFonts: () => [...(s.task?.fonts_used || []), ...s.doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family)], open: (btn, get, set, docFonts) => fontMenu(btn, { get, set, docFonts: () => docFonts }) })); const size = add(numberField({ label: "", title: "Font size", digits: 2, min: 1, max: 2000, cls: "sl-size", get: tget("font_size"), set: (v) => m.set({ font_size: v }, ["text"]) })); const step = (d) => m.set((e) => ({ font_size: Math.max(1, Math.round(e.font_size + d)) }), ["text"]); const sizeGroup = h("div", { class: "sl-sizegroup" }, iconButton("minus", "Decrease font size", () => step(-1)), size, iconButton("plus", "Increase font size", () => step(1))); const tog = (prop, ic, t) => add(toggle(ic, t, () => m.common(prop, ["text"]) === true, (v) => m.set({ [prop]: v }, ["text"]))); const bold = tog("bold", "bold", "Bold"), italic = tog("italic", "italic", "Italic"), underline = tog("underline", "underline", "Underline"); const colorBtn = h("button", { class: "icon-btn sl-textcolor", type: "button", title: "Text color", "aria-label": "Text color" }); colorBtn.innerHTML = `A`; colorBtn.addEventListener("click", () => colorMenu(colorBtn, { get: tget("color"), set: (c) => m.set({ color: c }, ["text"]) })); const alignBtn = h("button", { class: "icon-btn sl-align sl-drop", type: "button", title: "Align", "aria-label": "Align" }); alignBtn.addEventListener("click", () => alignMenu(alignBtn, m)); const spacingBtn = iconButton("line-spacing", "Line & paragraph spacing", () => spacingMenu(spacingBtn, m), { cls: "sl-spacing" }); const textGroup = h("div", { class: "sl-group" }, font, h("span", { class: "tb-sep" }), sizeGroup, h("span", { class: "tb-sep" }), bold, italic, underline, colorBtn, h("span", { class: "tb-sep" }), alignBtn, spacingBtn); // shapes: fill colour, border colour, border weight const fillBtn = iconButton("paint-bucket", "Fill color", () => colorMenu(fillBtn, { get: () => m.common("fill", ["shape"]), set: (c) => m.set({ fill: c }, ["shape"]) }), { cls: "sl-fill" }); const borderBtn = iconButton("pencil-line", "Border color", () => colorMenu(borderBtn, { get: () => m.common("stroke", ["shape"]), set: (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 1 }), ["shape"]) }), { cls: "sl-border" }); const weightBtn = iconButton("line-weight", "Border weight", () => menu(weightBtn, [1, 2, 3, 4, 8, 12, 16, 24].map((w) => ({ label: `${w}px`, run: () => m.set((e) => ({ stroke_width: w, stroke: e.stroke || "#000000" }), ["shape"]) }))), { cls: "sl-weight" }); const shapeGroup = h("div", { class: "sl-group" }, fillBtn, borderBtn, weightBtn); const toolbar = h("div", { class: "sl-toolbar", role: "toolbar", "aria-label": "Toolbar" }, menuSearch(app, () => Object.values(C).filter((c) => c.label && !c.hidden?.())), undo, redo, zoomBtn, h("span", { class: "tb-sep" }), ...tools, imageBtn, shapeBtn, h("span", { class: "tb-sep" }), pageGroup, shapeGroup, textGroup, h("span", { class: "grow" }), formatBtn); const strip = thumbStrip(app, "sl-filmstrip", "Slides"); const stageHost = h("div", { class: "sl-stage stage-host" }); const center = h("div", { class: "sl-center" }, h("div", { class: "sl-rulers" }, h("div", { class: "sl-ruler-corner" }), h("div", { class: "sl-ruler sl-ruler-h" }), h("div", { class: "sl-ruler sl-ruler-v" }), stageHost)); root.append(header, toolbar, h("div", { class: "sl-body" }, strip.el, center, pane.el)); return { stageHost, panels: [fo, images, strip], refresh() { title.textContent = s.task?.title || "Untitled presentation"; const els = m.els(); pageGroup.hidden = els.length > 0; textGroup.hidden = !textOnly(); shapeGroup.hidden = !shapeOnly(); for (const c of controls) c.set?.(); colorBtn.querySelector(".bar").style.background = tget("color")() || "#000"; const al = m.common("align", ["text"]) || "left"; alignBtn.innerHTML = `${icon(`align-${al}`, 16)}${icon("chevron-down", 10)}`; zoomBtn.innerHTML = `${isFit() ? "Fit" : `${Math.round(app.stage.zoom * 100)}%`}${icon("chevron-down", 10)}`; [...tools, undo, redo, submit].forEach((b) => b.refresh?.()); formatBtn.classList.toggle("on", pane.current() === "format"); strip.refresh(); }, reveal(which) { if (which === "assets") pane.open("images"); else if (which === "position") pane.open("format"); }, }; } // ================================================================ PowerPoint-like function powerpoint(app, root) { // PowerPoint for the web (checked in the browser): a white header with the file name and Search, the // tab row (File, Home, Insert, Design, View, and Shape Format / Picture while something is selected), // the simplified one-line ribbon, the thumbnail pane, the Format pane on demand, the status bar. const s = app.store; const C = app.commands; const m = selectionModel(app); const fmt = formatPane(app, m); const selection = layersPanel(app, { thumbs: false }); const pane = taskPane(app, "pp-pane", { format: { title: fmt.title, panel: fmt }, selection: { title: "Selection", panel: selection } }); const textOnly = () => { const e = m.els(); return e.length > 0 && e.every((x) => x.kind === "text"); }; const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined); const btn = (label, ic, attrs, onclick, { iconOnly = false, caret = false } = {}) => { const b = h("button", { class: `pp-btn ${iconOnly ? "pp-iconly" : ""}`, type: "button", title: label, "aria-label": label, ...attrs }, h("span", { class: "pp-ic", html: icon(ic, 16) }), ...(iconOnly ? [] : [h("span", { class: "pp-lab" }, label)]), ...(caret ? [h("span", { class: "pp-caret", html: icon("chevron-down", 12) })] : [])); b.addEventListener("click", onclick); return b; }; const cmdBtn = (label, ic, id, opts) => { const b = btn(label, ic, { dataset: { command: id } }, () => C[id].run(), opts); b.refresh = () => { if (C[id].enabled) b.disabled = !C[id].enabled(); }; return b; }; const group = (name, ...kids) => h("div", { class: `pp-group ${name === "Clipboard" ? "pp-optional" : ""}`, "aria-label": name }, ...kids); const controls = []; const add = (c) => { controls.push(c); return c; }; // Home: Undo | Clipboard | Font | Paragraph | Drawing | Editing const undo = commandButton(C.undo, { cls: "pp-btn pp-iconly" }); const redo = commandButton(C.redo, { cls: "pp-btn pp-iconly" }); const font = add(fontCombo({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]), docFonts: () => [...(s.task?.fonts_used || []), ...s.doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family)] })); const size = sizeCombo({ get: tget("font_size"), set: (v) => m.set({ font_size: v }, ["text"]) }); add(size.field); const grow = btn("Increase Font Size", "arrow-up", {}, size.grow, { iconOnly: true }); const shrink = btn("Decrease Font Size", "arrow-down", {}, size.shrink, { iconOnly: true }); grow.innerHTML = `A^`; shrink.innerHTML = `Aˇ`; const tog = (prop, ic, t) => add(toggle(ic, t, () => m.common(prop, ["text"]) === true, (v) => m.set({ [prop]: v }, ["text"]))); const styleBtns = [tog("bold", "bold", "Bold"), tog("italic", "italic", "Italic"), tog("underline", "underline", "Underline")]; let lastColor = "#c00000"; const colorBtn = h("button", { class: "icon-btn pp-textcolor", type: "button", title: "Font Color", "aria-label": "Font Color" }); colorBtn.innerHTML = `A`; colorBtn.addEventListener("click", () => m.set({ color: lastColor }, ["text"])); const colorDrop = h("button", { class: "pp-split-caret pp-textcolor-drop", type: "button", title: "Font Color options", "aria-label": "Font Color options", html: icon("chevron-down", 12) }); colorDrop.addEventListener("click", () => colorGallery(colorDrop, { get: tget("color"), automatic: "Automatic", set: (c) => { lastColor = c || "#000000"; m.set({ color: lastColor }, ["text"]); } })); const moreFont = btn("More Font Options", "more-horizontal", {}, () => menu(moreFont, [ { label: "Character Spacing", items: [["Very Tight", -3], ["Tight", -1.5], ["Normal", 0], ["Loose", 3], ["Very Loose", 6]].map(([l, v]) => ({ label: l, run: () => m.set({ letter_spacing: v }, ["text"]) })).concat(["-", { label: "More Spacing...", run: () => fontDialog(app, m, "spacing") }]) }, "-", { label: "Font...", run: () => fontDialog(app, m, "font") }]), { iconOnly: true }); moreFont.classList.add("pp-morefont"); const alignBtn = h("button", { class: "pp-btn pp-align", type: "button", title: "Align", "aria-label": "Align" }); alignBtn.addEventListener("click", () => menu(alignBtn, [["left", "Align Left"], ["center", "Center"], ["right", "Align Right"]].map(([v, label]) => ({ label, icon: `align-${v}`, run: () => m.set({ align: v }, ["text"]) })), { cls: "pp-alignmenu" })); const lineBtn = btn("Line Spacing", "line-spacing", {}, () => lineSpacingMenu(lineBtn, app, m), { iconOnly: true, caret: true }); lineBtn.classList.add("pp-linespacing"); const textBtns = [grow, shrink, ...styleBtns, colorBtn, colorDrop, moreFont, alignBtn, lineBtn]; const shapes = btn("Shapes", "shapes", {}, () => menu(shapes, [{ header: "Basic Shapes" }, { cmd: C.toolText, label: "Text Box" }, { cmd: C.toolRect }, { cmd: C.toolEllipse }]), { caret: true }); const shapeOnly = () => { const e = m.els(); return e.length > 0 && e.every((x) => x.kind === "shape"); }; const fillBtn = btn("Shape Fill", "paint-bucket", {}, () => colorGallery(fillBtn, { get: () => m.common("fill", ["shape"]), none: "No Fill", set: (c) => m.set({ fill: c }, ["shape"]) }), { iconOnly: true, caret: true }); const outlineBtn = btn("Shape Outline", "pencil-line", {}, () => colorGallery(outlineBtn, { get: () => m.common("stroke", ["shape"]), none: "No Outline", set: (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 1 }), ["shape"]) }), { iconOnly: true, caret: true }); const arrangeItems = () => [{ header: "Order Objects" }, { cmd: C.bringFront }, { cmd: C.sendBack }, { cmd: C.bringForward }, { cmd: C.sendBackward }, "-", { label: "Align", items: [{ cmd: C.alignLeft }, { cmd: C.alignCenter }, { cmd: C.alignRight }, "-", { cmd: C.alignTop }, { cmd: C.alignMiddle }, { cmd: C.alignBottom }] }, { label: "Rotate", items: [{ label: "Rotate Right 90°", cmd: C.rotateCw }, { label: "Rotate Left 90°", cmd: C.rotateCcw }, { label: "Flip Vertical", cmd: C.flipV }, { label: "Flip Horizontal", cmd: C.flipH }] }, "-", { label: "Selection Pane...", run: () => pane.open("selection") }]; const arrange = btn("Arrange", "bring-to-front", {}, () => menu(arrange, arrangeItems()), { caret: true }); const select = btn("Select", "mouse-pointer-2", { dataset: { open: "selection" } }, () => menu(select, [{ cmd: C.selectAll }, { label: "Selection Pane...", run: () => pane.open("selection") }]), { caret: true }); select.classList.add("pp-select"); // Insert > Pictures opens PowerPoint's "Insert Picture From" menu; This Device... is the computer's file window. const picturesBtn = btn("Pictures", "image", { dataset: { open: "pictures" } }, () => menu(picturesBtn, [{ header: "Insert Picture From" }, { label: "This Device...", cmd: C.placeImage }]), { caret: true }); const formatBg = btn("Format Background", "paint-bucket", { dataset: { open: "background" } }, () => { s.select([]); pane.open("format"); }); const formatPaneBtn = btn("Format Pane", "sliders-horizontal", { dataset: { open: "format" } }, () => pane.open(pane.current() === "format" ? null : "format")); const ctxH = numberField({ label: "", title: "Shape Height", name: "ctx_height", digits: 2, min: 1, unit: "px", iconName: "arrow-down", get: () => (m.els().every((e) => e.kind !== "text") ? m.common("height") : undefined), set: (v) => m.set({ height: v }), cls: "pp-ctxnum" }); const ctxW = numberField({ label: "", title: "Shape Width", name: "ctx_width", digits: 2, min: 1, unit: "px", iconName: "arrow-right", get: () => m.common("width"), set: (v) => m.set({ width: v }), cls: "pp-ctxnum" }); const ribbons = { home: h("div", { class: "pp-ribbon-body" }, group("Undo", undo, redo), group("Clipboard", cmdBtn("Paste", "clipboard-paste", "paste", { iconOnly: true }), cmdBtn("Cut", "scissors", "cut", { iconOnly: true }), cmdBtn("Copy", "copy", "copy", { iconOnly: true }), cmdBtn("Delete", "trash-2", "delete", { iconOnly: true })), group("Font", font, size.field, grow, shrink, ...styleBtns, h("span", { class: "pp-split" }, colorBtn, colorDrop), moreFont), group("Paragraph", alignBtn, lineBtn), group("Drawing", shapes, fillBtn, outlineBtn, arrange), group("Editing", select)), insert: h("div", { class: "pp-ribbon-body" }, group("Images", picturesBtn), group("Text", cmdBtn("Text Box", "type", "toolText")), group("Shapes", btn("Shapes", "shapes", {}, (e) => menu(e.currentTarget, [{ cmd: C.toolRect }, { cmd: C.toolEllipse }]), { caret: true }))), design: h("div", { class: "pp-ribbon-body" }, group("Customize", formatBg)), view: h("div", { class: "pp-ribbon-body" }, group("Zoom", cmdBtn("Zoom In", "zoom-in", "zoomIn"), cmdBtn("Zoom Out", "zoom-out", "zoomOut"), cmdBtn("Fit to Window", "maximize", "zoomFit"))), format: h("div", { class: "pp-ribbon-body" }, group("Shape Styles", btn("Shape Fill", "paint-bucket", {}, (e) => colorGallery(e.currentTarget, { get: () => m.common("fill", ["shape"]), none: "No Fill", set: (c) => m.set({ fill: c }, ["shape"]) }), { caret: true })), group("Arrange", btn("Arrange", "bring-to-front", {}, (e) => menu(e.currentTarget, arrangeItems()), { caret: true }), btn("Rotate", "rotate-cw", {}, (e) => menu(e.currentTarget, [{ label: "Rotate Right 90°", cmd: C.rotateCw }, { label: "Rotate Left 90°", cmd: C.rotateCcw }, { label: "Flip Vertical", cmd: C.flipV }, { label: "Flip Horizontal", cmd: C.flipH }]), { caret: true })), group("Size", ctxH, ctxW), group("Format", formatPaneBtn)), }; const shapeStyles = ribbons.format.querySelector('[aria-label="Shape Styles"]'); const ribbonHost = h("div", { class: "pp-ribbon", role: "toolbar", "aria-label": "Ribbon" }); const tabBar = h("div", { class: "pp-tabs", role: "tablist" }); const ctxTab = h("button", { class: "pp-tab pp-ctxtab", type: "button", role: "tab", dataset: { tab: "format" } }, "Shape Format"); let currentTab = "home"; const setTab = (id) => { if (id === "file") { menu(tabBar.querySelector('[data-tab="file"]'), [...(app.showCode ? [{ cmd: C.html }, "-"] : []), { label: "Export", items: [{ label: "Download as PNG", cmd: C.submit }] }, "-", { cmd: C.shortcuts }]); return; } currentTab = id; ribbonHost.textContent = ""; ribbonHost.append(ribbons[id]); tabBar.querySelectorAll(".pp-tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === id)); ribbonHost.querySelectorAll("[data-command]").forEach((b) => b.refresh?.()); }; for (const [id, label] of [["file", "File"], ["home", "Home"], ["insert", "Insert"], ["design", "Design"], ["view", "View"]]) { const b = h("button", { class: "pp-tab", type: "button", role: "tab", dataset: { tab: id } }, label); b.addEventListener("click", () => setTab(id)); tabBar.append(b); } ctxTab.addEventListener("click", () => setTab("format")); const submit = submitButton(app, "pp-share"); tabBar.append(ctxTab, h("span", { class: "grow" }), submit); const fileName = h("span", { class: "pp-filename" }, "Presentation1"); const titlebar = h("header", { class: "pp-titlebar" }, h("span", { class: "brand-mark mark-present", html: icon("rectangle-horizontal", 14) }), fileName, h("span", { class: "pp-saved", title: "Saved to OneDrive", html: icon("cloud-check", 15) }), h("span", { class: "grow" }), commandSearch(app, () => Object.values(C).filter((c) => c.label && !c.hidden?.())), h("span", { class: "grow" }), avatar(app)); const strip = thumbStrip(app, "pp-thumbs", "Slides"); const stageHost = h("div", { class: "pp-stage stage-host" }); const zoomRange = h("input", { type: "range", min: "10", max: "400", value: "100", class: "pp-zoom-range", "aria-label": "Zoom" }); zoomRange.addEventListener("input", () => app.stage.setZoom(zoomRange.value / 100)); app.on("zoom", (z) => { zoomRange.value = String(Math.round(z * 100)); }); const statusbar = h("footer", { class: "pp-status" }, h("span", {}, "Slide 1 of 1"), h("span", {}, "English (U.S.)"), h("span", { class: "grow" }), iconButton("minus", "Zoom Out", () => C.zoomOut.run(), { cls: "pp-sbtn" }), zoomRange, iconButton("plus", "Zoom In", () => C.zoomIn.run(), { cls: "pp-sbtn" }), zoomLabel(app), iconButton("maximize", "Fit slide to current window", () => C.zoomFit.run(), { cls: "pp-sbtn" })); root.append(titlebar, tabBar, ribbonHost, h("div", { class: "pp-body" }, strip.el, h("div", { class: "pp-center" }, stageHost), pane.el), statusbar); setTab("home"); const refreshables = [undo, redo, submit]; return { stageHost, panels: [fmt, selection, strip], refresh() { fileName.textContent = s.task?.title ? s.task.title.slice(0, 80) : "Presentation1"; const els = m.els(), t = textOnly(); // The contextual tab: Shape Format for shapes and text boxes, Picture for pictures. ctxTab.hidden = !els.length; ctxTab.textContent = els.length && els.every((e) => e.kind === "image") ? "Picture" : "Shape Format"; shapeStyles.hidden = !shapeOnly(); if (!els.length && currentTab === "format") setTab("home"); for (const c of controls) c.set?.(); textBtns.forEach((b) => { b.disabled = !t; }); fillBtn.disabled = outlineBtn.disabled = !shapeOnly(); colorBtn.querySelector(".bar").style.background = lastColor; const al = m.common("align", ["text"]) || "left"; alignBtn.innerHTML = `${icon(`align-${al}`, 16)}${icon("chevron-down", 12)}`; ctxH.set(); ctxW.set(); for (const b of ribbonHost.querySelectorAll("[data-command]")) b.refresh?.(); refreshables.forEach((b) => b.refresh?.()); ribbonHost.querySelectorAll("[data-open]").forEach((b) => b.classList.toggle("on", pane.current() === b.dataset.open)); pane.retitle(); strip.refresh(); }, reveal(which) { if (which === "assets") app.openFiles(); else if (which === "position") pane.open("format"); }, currentTab: () => currentTab, }; } // ================================================================ Inkscape-like function inkscape(app, root) { // Inkscape 1.3 / 1.4's window (per the Inkscape Beginners' Guide): menu bar, the tool controls bar // under it, the toolbox on the left, rulers round the canvas, docked dialogs as closable tabs, the // commands bar down the right edge, the palette, and the status bar (fill / stroke, O:, layer, // message, cursor X / Y, Z:). Geometry lives in the Selector's controls, text styling in the Text // tool's controls and Text and Font, colour in Fill and Stroke, the page colour in Document Properties. const s = app.store; const C = app.commands; const m = selectionModel(app); const layers = layersPanel(app, { thumbs: false }); const fs = fillAndStroke(app, m); const tf = textAndFont(app, m); const tr = transformDialog(app, m); const dp = documentProperties(app); const ex = exportDialog(app); const dialogs = { fill: { title: "Fill and Stroke", icon: "paint-bucket", panel: fs }, text: { title: "Text and Font", icon: "type", panel: tf }, layers: { title: "Layers and Objects", icon: "layers", panel: layers }, transform: { title: "Transform", icon: "move", panel: tr }, document: { title: "Document Properties", icon: "file", panel: dp }, export: { title: "Export", icon: "download", panel: ex }, }; const docked = dock(dialogs, { onChange: () => requestAnimationFrame(() => app.stage?.fit?.()) }); const openDialog = (id, sub) => { docked.show(id); if (sub) dialogs[id].panel.setTab?.(sub); }; // Dialogs have their own commands (and Inkscape's shortcuts), so menus, the commands bar and keys agree. const dlg = (id, label, key, ic) => ({ id: `dlg_${id}`, label, key, icon: ic, run: () => openDialog(id) }); const D = { fill: dlg("fill", "Fill and Stroke…", "Mod+Shift+F", "paint-bucket"), text: dlg("text", "Text and Font…", "Mod+Shift+T", "type"), layers: dlg("layers", "Layers and Objects…", "Mod+Shift+L", "layers"), transform: dlg("transform", "Transform…", "Mod+Shift+M", "move"), document: dlg("document", "Document Properties…", "Mod+Shift+D", "file"), export: dlg("export", "Export…", "Mod+Shift+E", "download"), }; for (const c of Object.values(D)) C[c.id] = c; const menus = { File: [{ label: "Import…", cmd: C.placeImage }, ...(app.showCode ? [{ cmd: C.html }] : []), "-", { cmd: D.document }, "-", { cmd: D.export }], Edit: [{ cmd: C.undo }, { cmd: C.redo }, "-", { cmd: C.cut }, { cmd: C.copy }, { cmd: C.paste }, "-", { cmd: C.duplicate }, { cmd: C.delete }, "-", { label: "Select All", cmd: C.selectAll }, { label: "Deselect", cmd: C.deselect }], View: [{ header: "Zoom" }, { cmd: C.zoomIn }, { cmd: C.zoomOut }, { label: "Zoom 1:1", cmd: C.zoom100 }, { label: "Zoom Selection", cmd: C.zoomSelection }, { label: "Zoom Page", cmd: C.zoomFit }], Layer: [{ cmd: D.layers }], Object: [{ cmd: D.fill }, { cmd: D.transform }, "-", { label: "Raise to Top", cmd: C.bringFront }, { label: "Raise", cmd: C.bringForward }, { label: "Lower", cmd: C.sendBackward }, { label: "Lower to Bottom", cmd: C.sendBack }, "-", { label: "Rotate 90° CW", cmd: C.rotateCw }, { label: "Rotate 90° CCW", cmd: C.rotateCcw }, { label: "Flip Horizontal", cmd: C.flipH }, { label: "Flip Vertical", cmd: C.flipV }, "-", { header: "Align" }, { cmd: C.alignLeft }, { cmd: C.alignCenter }, { cmd: C.alignRight }, { cmd: C.alignTop }, { cmd: C.alignMiddle }, { cmd: C.alignBottom }, "-", { label: "Lock / Unlock", cmd: C.lock }, { label: "Hide / Unhide", cmd: C.hide }], Text: [{ cmd: D.text }], Help: [{ label: "Keys and Mouse Reference", cmd: C.shortcuts }], }; const menubar = h("div", { class: "ik-menubar", role: "menubar" }, h("span", { class: "brand-mark mark-vector", html: icon("pen-tool", 13) })); for (const [name, items] of Object.entries(menus)) { const b = h("button", { class: "ik-menu", type: "button", role: "menuitem" }, name); b.addEventListener("click", () => menu(b, items)); menubar.append(b); } // Tool controls bar. Selector: select all / deselect, rotate 90, flip, z-order, then X Y W (lock) H in px. const cmdBtn = (cmd, title) => { const b = commandButton(cmd, { cls: "ik-cmd" }); if (title) b.title = title; return b; }; let lockRatio = false; const geom = (prop, label) => spin({ label, title: label, name: prop, digits: 3, cls: "ik-num", min: prop === "width" || prop === "height" ? 1 : -1e5, max: 1e5, get: () => { const e = m.els(); if (!e.length) return undefined; if (prop === "height" && !e.every((x) => x.kind !== "text")) return undefined; return m.common(prop); }, set: (v) => m.set((e) => { if (lockRatio && (prop === "width" || prop === "height") && e.kind !== "text") { const r = e.width / e.height; return prop === "width" ? { width: v, height: v / r } : { height: v, width: v * r }; } return { [prop]: v }; }) }); const gx = geom("x", "X"), gy = geom("y", "Y"), gw = geom("width", "W"), gh = geom("height", "H"); const lock = toggle("lock", "When locked, change width and height by the same proportion", () => lockRatio, (v) => { lockRatio = v; lock.set(); }, { cls: "ik-cmd ik-lockratio" }); const selOpts = h("div", { class: "ik-optgroup ik-selopts inspector" }, cmdBtn(C.selectAll, "Select all objects"), cmdBtn(C.deselect, "Deselect any selected objects"), h("span", { class: "tb-sep" }), cmdBtn(C.rotateCcw, "Rotate 90° counter-clockwise"), cmdBtn(C.rotateCw, "Rotate 90° clockwise"), cmdBtn(C.flipH, "Flip horizontally"), cmdBtn(C.flipV, "Flip vertically"), h("span", { class: "tb-sep" }), cmdBtn(C.bringFront, "Raise selection to top"), cmdBtn(C.bringForward, "Raise selection one step"), cmdBtn(C.sendBackward, "Lower selection one step"), cmdBtn(C.sendBack, "Lower selection to bottom"), h("span", { class: "tb-sep" }), gx, gy, gw, lock, gh, h("span", { class: "ik-unit" }, "px")); // Text tool: font family, style, size (px), line height (lines), alignment, letter spacing. const tc = textControls(app, "ik", { steppers: false, colorButton: false, toggles: false }); const FACES = [["Regular", "regular"], ["Bold", "bold"], ["Italic", "italic"], ["Bold Italic", "bolditalic"]]; const styleSel = h("button", { class: "ik-style ik-drop", type: "button", "aria-label": "Font style" }); styleSel.addEventListener("click", () => { const fam = m.common("font_family", ["text"]); menu(styleSel, FACES.filter(([, f]) => f === "regular" || !fam || hasStyle(fam, f)).map(([label]) => ({ label, run: () => m.set({ bold: label.includes("Bold"), italic: label.includes("Italic") }, ["text"]) })), { cls: "ik-style-menu" }); }); const lineH = spin({ label: "", title: "Spacing between baselines", name: "line_height", iconName: "baseline", digits: 3, step: 0.1, min: 0.1, max: 10, get: () => (tc.textOnly() ? m.common("line_height", ["text"]) : undefined), set: (v) => m.set({ line_height: v }, ["text"]) }); const letterS = spin({ label: "", title: "Spacing between letters (px)", name: "letter_spacing", iconName: "letter-spacing", digits: 2, step: 0.1, min: -100, max: 500, get: () => (tc.textOnly() ? m.common("letter_spacing", ["text"]) : undefined), set: (v) => m.set({ letter_spacing: v }, ["text"]) }); const alignDd = alignDropdown(m); const textOpts = h("div", { class: "ik-optgroup ik-textopts" }, tc.font, styleSel, tc.size, h("span", { class: "ik-unit" }, "px"), lineH, h("span", { class: "ik-unit" }, "lines"), alignDd, h("span", { class: "tb-sep" }), letterS); const emptyOpts = h("div", { class: "ik-optgroup ik-muted" }, cmdBtn(C.selectAll, "Select all objects"), h("span", { class: "tb-sep" }), "No objects selected"); const controls = h("div", { class: "ik-controls" }, selOpts, textOpts, emptyOpts); const toolBtns = []; const live = (id, tool, title) => { const b = commandButton(C[id], { cls: "ik-tool" }); b.title = title; b.setAttribute("aria-label", title); b.refresh = () => b.classList.toggle("on", s.tool === tool); toolBtns.push(b); return b; }; const toolbox = h("div", { class: "ik-tools", role: "toolbar", "aria-label": "Tools" }, live("toolMove", "move", "Selector (S)"), iconButton("zoom-in", "Zoom (Z)", () => C.zoomIn.run(), { cls: "ik-tool" }), h("span", { class: "ik-tsep" }), live("toolRect", "rect", "Rectangle (R)"), live("toolEllipse", "ellipse", "Ellipse / Arc (E)"), h("span", { class: "ik-tsep" }), live("toolText", "text", "Text (T)")); const stageHost = h("div", { class: "ik-stage stage-host" }); const rulerTop = h("div", { class: "ik-ruler ik-ruler-h" }); const rulerLeft = h("div", { class: "ik-ruler ik-ruler-v" }); const canvasWrap = h("div", { class: "ik-canvas" }, h("div", { class: "ik-ruler-corner" }), rulerTop, rulerLeft, stageHost); // Commands bar, down the right edge: import / export, undo / redo, clipboard, zoom, duplicate, the dialogs. const importBtn = iconButton("file-down", "Import a bitmap or SVG image into this document (Ctrl+I)", () => C.placeImage.run(), { cls: "ik-cmd" }); importBtn.dataset.open = "files"; const cbtn = (cmd, title) => { const b = commandButton(cmd, { cls: "ik-cmd" }); b.title = title; return b; }; const commands = h("div", { class: "ik-commands", role: "toolbar", "aria-label": "Commands" }, importBtn, cbtn(D.export, "Export this document (Shift+Ctrl+E)"), h("span", { class: "ik-csep" }), cbtn(C.undo, "Undo"), cbtn(C.redo, "Redo"), h("span", { class: "ik-csep" }), cbtn(C.copy, "Copy"), cbtn(C.cut, "Cut"), cbtn(C.paste, "Paste"), h("span", { class: "ik-csep" }), cbtn(C.zoomSelection, "Zoom Selection"), cbtn(C.zoomFit, "Zoom Page"), cbtn(C.zoom100, "Zoom 1:1"), h("span", { class: "ik-csep" }), cbtn(C.duplicate, "Duplicate"), cbtn(C.delete, "Delete"), h("span", { class: "ik-csep" }), cbtn(D.fill, "Fill and Stroke (Shift+Ctrl+F)"), cbtn(D.text, "Text and Font (Shift+Ctrl+T)"), cbtn(D.layers, "Layers and Objects (Shift+Ctrl+L)"), cbtn(D.transform, "Transform (Shift+Ctrl+M)"), cbtn(D.document, "Document Properties (Shift+Ctrl+D)")); // Palette: Inkscape's default palette. Click fills the selection, Shift+click strokes it (shapes); // X is no paint; with nothing selected a colour becomes new objects' colour. const palette = paletteBar({ onPick: (c, stroke) => { const els = m.els(); if (!els.length) { app.setFg(c); return; } if (stroke) { if (els.every((e) => e.kind === "shape")) m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 4 }), ["shape"]); return; } if (els.every((e) => e.kind === "text")) m.set({ color: c }, ["text"]); else if (els.every((e) => e.kind === "shape")) m.set({ fill: c }, ["shape"]); }, onNone: (stroke) => { const els = m.els(); if (els.length && els.every((e) => e.kind === "shape")) m.set(stroke ? { stroke: null, stroke_width: 0 } : { fill: "none" }, ["shape"]); }, }); // Status bar: Fill / Stroke (click opens Fill and Stroke), O: opacity, layer, message, cursor X / Y, Z: zoom. const fillSw = h("span", { class: "ik-ind-sw" }), strokeSw = h("span", { class: "ik-ind-sw" }); const fillTx = h("span", { class: "ik-ind-tx" }), strokeTx = h("span", { class: "ik-ind-tx" }); const indicator = h("button", { class: "ik-indicator", type: "button", title: "Fill and stroke of the selection; click to edit", "aria-label": "Fill and stroke" }, h("span", { class: "ik-ind-row" }, h("span", { class: "ik-ind-k" }, "Fill:"), fillSw, fillTx), h("span", { class: "ik-ind-row" }, h("span", { class: "ik-ind-k" }, "Stroke:"), strokeSw, strokeTx)); indicator.addEventListener("click", () => openDialog("fill", "fill")); const opac = spin({ label: "O:", title: "Opacity (%)", name: "opacity", digits: 0, min: 0, max: 100, step: 5, cls: "ik-opac", get: () => { const v = m.common("opacity"); return v === null || v === undefined ? v : v * 100; }, set: (v) => m.set({ opacity: v / 100 }) }); const status = h("span", { class: "ik-msg" }); const cursor = h("span", { class: "ik-cursor" }, h("span", {}, "X: –"), h("span", {}, "Y: –")); const statusbar = h("footer", { class: "ik-status" }, indicator, opac, h("span", { class: "ik-layer" }, "Layer 1"), status, h("span", { class: "grow" }), cursor, zoomSpin(app)); root.append(menubar, controls, h("div", { class: "ik-body" }, toolbox, canvasWrap, docked.el, commands), palette, statusbar); stageHost.addEventListener("pointermove", (e) => { const [x, y] = app.stage.toCanvas(e.clientX, e.clientY); cursor.firstChild.textContent = `X: ${fmt(x, 2)}`; cursor.lastChild.textContent = `Y: ${fmt(y, 2)}`; }); openDialog("layers"); openDialog("fill"); const geo = [gx, gy, gw, gh, lineH, letterS, opac]; // Inkscape asks how to import every bitmap before it lands on the canvas. Only what this task can // honour is offered: images are always embedded at their file resolution, so those rows are read-only; // "Don't ask again" works and skips the question for the rest of the session. let askAgain = true; const row = (label, value) => [h("span", { class: "ik-import-label" }, label), h("span", { class: "ik-import-value" }, value)]; const askImport = (assets) => new Promise((resolve) => { if (!askAgain) { resolve(true); return; } const a = assets[0], kind = a && (a.kind === "photo" || a.kind === "background") ? "jpeg" : "png"; const never = h("input", { type: "checkbox", name: "ik-dont-ask" }); let ok = false; modal(`${kind} bitmap image import`, h("div", { class: "ik-import-grid" }, ...row("Image Import Type:", "Embed"), ...row("Image DPI:", "From file"), ...row("Image rendering mode:", "None (auto)"), h("span", {}), h("label", {}, never, "Don't ask again")), { cls: "ik-import", actions: [{ label: "Cancel" }, { label: "OK", primary: true, run: () => { ok = true; if (never.checked) askAgain = false; } }], onClose: () => resolve(ok), }); }); return { stageHost, panels: [layers, docked], placeAssets: async (assets, point) => { if (await askImport(assets)) app.placeAt(assets, point); }, fileOptions: () => ({ title: "Select file to import" }), refresh() { const els = m.els(); const textSel = tc.textOnly(); const tool = s.tool; // Inkscape: the Text tool shows text controls; the Selector shows X / Y / W / H for whatever is selected. textOpts.hidden = tool !== "text"; selOpts.hidden = tool === "text" || !els.length; emptyOpts.hidden = !(selOpts.hidden && textOpts.hidden); geo.forEach((f) => f.set()); lock.set(); alignDd.set(); tc.refresh(); const b = m.common("bold", ["text"]), it = m.common("italic", ["text"]); const fam = textSel ? m.common("font_family", ["text"]) : null; const want = !textSel ? "" : b === null || it === null ? "Mixed" : b && it ? "Bold Italic" : b ? "Bold" : it ? "Italic" : fam ? "Regular" : ""; styleSel.innerHTML = `${want}${icon("chevron-down", 10)}`; styleSel.disabled = !textSel; toolBtns.forEach((x) => x.refresh()); commands.querySelectorAll("[data-command]").forEach((x) => x.refresh?.()); controls.querySelectorAll("[data-command]").forEach((x) => x.refresh?.()); const one = els.length === 1 ? els[0] : null; const fillOf = (e) => (e.kind === "text" ? e.color : e.kind === "shape" ? e.fill : null); const f = one ? fillOf(one) : els.length ? null : app.fgColor; const st = one && one.kind === "shape" && one.stroke && one.stroke_width ? one.stroke : null; fillSw.style.background = f && f !== "none" ? f : "transparent"; fillTx.textContent = !els.length ? "" : f && f !== "none" ? "" : one ? "None" : "Different"; strokeSw.style.background = st || "transparent"; strokeTx.textContent = !els.length ? "" : st ? "" : "None"; indicator.classList.toggle("none", !els.length); status.textContent = !els.length ? "No objects selected. Click, Shift+click, Alt+scroll mouse on top of objects, or drag around objects to select." : els.length === 1 ? `${one.kind === "text" ? "Text" : one.kind === "image" ? "Image" : "Rectangle"} in layer Layer 1. Click selection to toggle scale/rotation handles.` : `${els.length} objects selected in layer Layer 1.`; }, reveal(which) { if (which === "assets") app.openFiles(); else if (which === "position") openDialog("transform"); }, }; } export const SKINS = { figma: { name: "figma", build: figma, frameLabel: "Frame 1", fitPadding: 56, rotateHandle: false, alwaysShowSize: true, floatingToolbar: false, fill: "#d9d9d9" }, canva: { name: "canva", build: canva, fitPadding: 48, rotateHandle: true, rotateIcon: icon("rotate-cw", 14), alwaysShowSize: false, floatingToolbar: true, fill: "#c1c1c1" }, photoshop: { name: "photoshop", build: photoshop, fitPadding: 36, rotateHandle: false, alwaysShowSize: false, floatingToolbar: false, fill: "#5b8def" }, slides: { name: "slides", build: slides, fitPadding: 32, rotateHandle: true, rotateIcon: "", alwaysShowSize: false, floatingToolbar: false, fill: "#9aa0a6" }, powerpoint: { name: "powerpoint", build: powerpoint, fitPadding: 32, rotateHandle: true, rotateIcon: icon("rotate-cw", 12), alwaysShowSize: false, floatingToolbar: false, fill: "#4472c4" }, inkscape: { name: "inkscape", build: inkscape, fitPadding: 40, rotateHandle: false, alwaysShowSize: false, floatingToolbar: false, fill: "#c3c3c3" }, };