/** * SlideShortcuts companion script for Google Slides — the selection engine. * * WHY THIS EXISTS: a Chrome extension cannot see which shapes are selected * (Slides paints its canvas; selection never touches the DOM), and the * Slides REST API has no concept of the live editor selection either. Apps * Script running as the user is the ONLY surface Google exposes selection * on. The extension keeps owning the keyboard (add-ons cannot bind keys); * this script owns the geometry. The extension invokes these functions by * driving the "SlideShortcuts" menu this script creates — same label-based * menu engine it already uses for Arrange/Format. * * Semantics are ported 1:1 from the PowerPoint product's modMacros.bas * (reference shape = FIRST selected, exact sticky spec, "..." dots, etc.). * All lengths in points, same unit as the VBA source. * * INSTALL: this is an EDITOR ADD-ON, not a per-deck script — see SETUP.md. * A "test deployment" from the Apps Script editor injects it into the deck * it opens (Execute → that tab's URL carries an addon_dry_run token; the * same deck opened normally has no add-on — verified live 2026-09-14). The * Marketplace listing is what installs it for every deck, for customers. * It still works pasted into a single deck as a bound script; name that * bound project "SlideShortcuts" too — the project name IS the submenu label. * * Its menu lives under Extensions ▸ SlideShortcuts ▸ … (createAddonMenu — * add-ons may not create top-level menus). The extension drives that path * first and falls back to the old top-level "SlideShortcuts" menu for any * deck still carrying the pre-0.7.1 bound script. Keep the project / * listing named exactly "SlideShortcuts": that name IS the submenu label. */ /* eslint-disable no-unused-vars */ // onOpen runs with e.authMode === NONE before the user has authorized the // add-on: only menu construction is allowed then, which is all this does. // The first item the user picks triggers Google's consent prompt. function onOpen(e) { SlidesApp.getUi() .createAddonMenu() .addItem("Make Same Size", "ssMakeSameSize") .addItem("Make Same Width", "ssMakeSameWidth") .addItem("Make Same Height", "ssMakeSameHeight") .addSeparator() .addItem("Copy Position", "ssCopyPosition") .addItem("Paste Position", "ssPastePosition") .addItem("Swap Objects", "ssSwapObjects") .addItem("Select Similar", "ssSelectSimilar") .addSeparator() .addItem("Group Rows", "ssGroupRows") .addItem("Group Columns", "ssGroupColumns") .addSeparator() .addItem("Convert to Dots", "ssConvertToDots") .addItem("Split/Join Textboxes", "ssSplitJoinTextboxes") .addItem("Shape to Text Box", "ssShapeToTextBox") .addSeparator() .addItem("Cycle Fill", "ssCycleFill") .addItem("Cycle Accent Color", "ssCycleAccentColor") .addSeparator() .addItem("Insert Yellow Sticky", "ssInsertYellowSticky") .addItem("Insert Source Footer", "ssInsertSourceFooter") .addItem("Insert Legend", "ssInsertLegend") .addSeparator() .addItem("Toggle Draft Stamp", "ssToggleDraftStamp") .addItem("Remove Speaker Notes", "ssRemoveSpeakerNotes") .addToUi(); } // Marketplace installs call onInstall, not onOpen, for the deck that was open // at install time — build the menu there too or it stays missing until reload. function onInstall(e) { onOpen(e); } // --- selection plumbing ------------------------------------------------------ /** Selected page elements, or [] — mirrors VBA SelShapes() returning Nothing. */ function sel_() { var selection = SlidesApp.getActivePresentation().getSelection(); if (!selection) return []; if (selection.getSelectionType() !== SlidesApp.SelectionType.PAGE_ELEMENT) return []; var range = selection.getPageElementRange(); return range ? range.getPageElements() : []; } function currentPage_() { var selection = SlidesApp.getActivePresentation().getSelection(); var page = selection && selection.getCurrentPage(); if (page) return page; var slides = SlidesApp.getActivePresentation().getSlides(); return slides.length ? slides[0] : null; } // --- Sizing (PPT: reference = FIRST selected; needs 2+) ---------------------- function ssMakeSameSize() { var els = sel_(); if (els.length < 2) return; var w = els[0].getWidth(), h = els[0].getHeight(); for (var i = 1; i < els.length; i++) { els[i].setWidth(w); els[i].setHeight(h); } } function ssMakeSameWidth() { var els = sel_(); if (els.length < 2) return; var w = els[0].getWidth(); for (var i = 1; i < els.length; i++) els[i].setWidth(w); } function ssMakeSameHeight() { var els = sel_(); if (els.length < 2) return; var h = els[0].getHeight(); for (var i = 1; i < els.length; i++) els[i].setHeight(h); } // --- Position clipboard (PPT: copy from first; paste ALL FOUR to every) ------ function ssCopyPosition() { var els = sel_(); if (!els.length) return; var e = els[0]; PropertiesService.getUserProperties().setProperty("ssPos", JSON.stringify({ l: e.getLeft(), t: e.getTop(), w: e.getWidth(), h: e.getHeight(), })); } function ssPastePosition() { var raw = PropertiesService.getUserProperties().getProperty("ssPos"); if (!raw) return; var p = JSON.parse(raw); sel_().forEach(function (e) { e.setLeft(p.l); e.setTop(p.t); e.setWidth(p.w); e.setHeight(p.h); }); } // --- Swap (PPT: exactly 2; swap left/top only) ------------------------------- function ssSwapObjects() { var els = sel_(); if (els.length !== 2) return; var l1 = els[0].getLeft(), t1 = els[0].getTop(); var l2 = els[1].getLeft(), t2 = els[1].getTop(); els[0].setLeft(l2); els[0].setTop(t2); els[1].setLeft(l1); els[1].setTop(t1); } // --- Select Similar (v1: match element type + solid fill of the first) ------- // The PPT version intersects many traits across all selected shapes; v1 here // matches the first shape's type and fill color. NOTE: additive select() is // the one Apps Script capability the research could not confirm — if // select(false) throws, we fall back to selecting the first match only. function ssSelectSimilar() { var els = sel_(); if (!els.length) return; var page = currentPage_(); if (!page) return; var ref = els[0]; var refType = ref.getPageElementType(); var refFill = solidFillHex_(ref); var matches = page.getPageElements().filter(function (e) { if (e.getPageElementType() !== refType) return false; return solidFillHex_(e) === refFill; }); if (!matches.length) return; try { matches[0].select(true); for (var i = 1; i < matches.length; i++) matches[i].select(false); } catch (err) { matches[0].select(); } } function solidFillHex_(el) { try { if (el.getPageElementType() !== SlidesApp.PageElementType.SHAPE) return "n/a"; var fill = el.asShape().getFill(); var solid = fill.getSolidFill(); if (!solid) return "none"; return solid.getColor().asRgbColor().asHexString(); } catch (e) { return "unknown"; } } // --- Group Rows / Columns (cluster along an axis, group each cluster) -------- // Two shapes share a row when their vertical ranges overlap by at least half // of the smaller shape's height (columns: same test on X). function ssGroupRows() { groupClusters_(true); } function ssGroupColumns() { groupClusters_(false); } function groupClusters_(byRow) { var els = sel_(); if (els.length < 2) return; var page = currentPage_(); if (!page) return; var items = els.map(function (e) { return { el: e, a: byRow ? e.getTop() : e.getLeft(), len: byRow ? e.getHeight() : e.getWidth() }; }).sort(function (x, y) { return x.a - y.a; }); var clusters = []; items.forEach(function (it) { var last = clusters[clusters.length - 1]; if (last) { var overlap = Math.min(last.end, it.a + it.len) - Math.max(last.start, it.a); var minLen = Math.min(it.len, last.minLen); if (overlap >= minLen / 2) { last.members.push(it.el); last.end = Math.max(last.end, it.a + it.len); last.start = Math.min(last.start, it.a); last.minLen = minLen; return; } } clusters.push({ members: [it.el], start: it.a, end: it.a + it.len, minLen: it.len }); }); clusters.forEach(function (c) { if (c.members.length >= 2) { try { page.group(c.members); } catch (e) { /* mixed un-groupables; skip */ } } }); } // --- Convert to Dots (PPT: literally sets text to "...") --------------------- function ssConvertToDots() { sel_().forEach(function (e) { try { if (e.getPageElementType() === SlidesApp.PageElementType.SHAPE) { e.asShape().getText().setText("..."); } } catch (err) { /* no text frame; skip */ } }); } // --- Split / Join textboxes -------------------------------------------------- // 1 text shape with 2+ paragraphs -> one textbox per paragraph, stacked from // the original's top. 2+ text shapes -> join into the top-left-most (texts // newline-joined), remove the rest. function ssSplitJoinTextboxes() { var els = sel_().filter(function (e) { try { return e.getPageElementType() === SlidesApp.PageElementType.SHAPE && e.asShape().getText().asString().trim() !== ""; } catch (err) { return false; } }); var page = currentPage_(); if (!page || !els.length) return; if (els.length === 1) { var shape = els[0].asShape(); var paras = shape.getText().asString().split("\n").filter(function (p) { return p.trim() !== ""; }); if (paras.length < 2) return; var x = shape.getLeft(), y = shape.getTop(), w = shape.getWidth(); var lineH = 19.44, gap = 3.6; // PPT default textbox height + tight gap paras.forEach(function (p, i) { var box = page.insertTextBox(p, x, y + i * (lineH + gap), w, lineH); box.getText().getTextStyle().setFontSize(12); }); shape.remove(); } else { els.sort(function (a, b) { return (a.getTop() - b.getTop()) || (a.getLeft() - b.getLeft()); }); var target = els[0].asShape(); var joined = els.map(function (e) { return e.asShape().getText().asString().replace(/\n+$/, ""); }).join("\n"); target.getText().setText(joined); for (var i = 1; i < els.length; i++) els[i].remove(); } } // --- Shape to Text Box ------------------------------------------------------- // Recreate each selected shape as a plain textbox with the same geometry and // text, then remove the original (PPT strips fill/border the same way). function ssShapeToTextBox() { var page = currentPage_(); if (!page) return; sel_().forEach(function (e) { try { if (e.getPageElementType() !== SlidesApp.PageElementType.SHAPE) return; var s = e.asShape(); var text = s.getText().asString().replace(/\n+$/, ""); var box = page.insertTextBox(text || " ", s.getLeft(), s.getTop(), s.getWidth(), s.getHeight()); box.getText().getTextStyle().setFontSize(12); s.remove(); } catch (err) { /* skip non-convertibles */ } }); } // --- Inserts (exact PPT specs from modMacros.bas) ---------------------------- function ssInsertYellowSticky() { var pres = SlidesApp.getActivePresentation(); var page = currentPage_(); if (!page) return; var W = 149.76, H = 36, MARGIN = 14.4, GAP = 7.2; // 2.08" x 0.5", 0.2", 0.1" var x = pres.getPageWidth() - W - MARGIN, y = MARGIN; // Stack below the lowest existing sticky (matched by the yellow fill), // aligned to its left edge — exactly like the PPT macro. var lowest = -1; page.getPageElements().forEach(function (e) { if (solidFillHex_(e).toLowerCase() === "#ffff66") { var bottom = e.getTop() + e.getHeight(); if (bottom > lowest) { lowest = bottom; x = e.getLeft(); } } }); if (lowest > 0) y = lowest + GAP; var sticky = page.insertShape(SlidesApp.ShapeType.RECTANGLE, x, y, W, H); sticky.getFill().setSolidFill("#FFFF66"); sticky.getBorder().setTransparent(); var text = sticky.getText(); text.setText(" "); text.getTextStyle().setFontSize(16).setForegroundColor("#333333"); sticky.setContentAlignment(SlidesApp.ContentAlignment.TOP); sticky.select(); } function ssInsertSourceFooter() { var pres = SlidesApp.getActivePresentation(); var page = currentPage_(); if (!page) return; var box = page.insertTextBox("Source: ", 14.4, pres.getPageHeight() - 28, 300, 16); box.getText().getTextStyle().setFontSize(8).setForegroundColor("#595959"); box.select(); } function ssInsertLegend() { var page = currentPage_(); if (!page) return; var colors = ["#00338D", "#008EC2", "#A6A6A6"]; var labels = ["Series 1", "Series 2", "Series 3"]; var startX = 50, startY = 50, members = []; for (var i = 0; i < 3; i++) { var sw = page.insertShape(SlidesApp.ShapeType.RECTANGLE, startX, startY + i * 25, 15, 15); sw.getFill().setSolidFill(colors[i]); sw.getBorder().setTransparent(); members.push(sw); var tb = page.insertTextBox(labels[i], startX + 20, startY + i * 25 - 2, 80, 20); tb.getText().getTextStyle().setFontSize(10).setForegroundColor("#333333"); members.push(tb); } try { page.group(members).select(); } catch (e) { /* group unavailable; leave loose */ } } // --- Theme-accent cycles (PPT ⌃T toggle fill / ⌥⇧A cycle accent colors) ----- // Ported from formatting.applescript. Both walk the selection (groups // flattened to their shapes) and keep their cycle position per user. // Cycle Fill: 1..6 = theme accent N as the fill, 7 = no fill. // Cycle Accent Color: 1..6 = theme accent N — shapes without text get it as // the fill (only if they already have a visible fill); text shapes with a // fill get it as the fill plus a contrast font color (black on light // accents, white on dark); fill-less text shapes get it on the font. The // position only advances when something changed, so a no-op press doesn't // skip a color. var ACCENTS_ = [ SlidesApp.ThemeColorType.ACCENT1, SlidesApp.ThemeColorType.ACCENT2, SlidesApp.ThemeColorType.ACCENT3, SlidesApp.ThemeColorType.ACCENT4, SlidesApp.ThemeColorType.ACCENT5, SlidesApp.ThemeColorType.ACCENT6, ]; /** Shapes in the selection, groups flattened. */ function shapesIn_(els) { var out = []; els.forEach(function (e) { var t = e.getPageElementType(); if (t === SlidesApp.PageElementType.SHAPE) out.push(e.asShape()); else if (t === SlidesApp.PageElementType.GROUP) out = out.concat(shapesIn_(e.asGroup().getChildren())); }); return out; } function hasVisibleFill_(shape) { try { return shape.getFill().getType() !== SlidesApp.FillType.NONE; } catch (e) { return false; } } function hasText_(shape) { try { return shape.getText().asString().trim().length > 0; } catch (e) { return false; } } /** Next 1..n position for a named cycle; commit() stores it. */ function nextCycle_(key, n) { var props = PropertiesService.getUserProperties(); var idx = (parseInt(props.getProperty(key) || "0", 10) % n) + 1; return { idx: idx, commit: function () { props.setProperty(key, String(idx)); } }; } function ssCycleFill() { var shapes = shapesIn_(sel_()); if (!shapes.length) return; var c = nextCycle_("ssFillIdx", 7); shapes.forEach(function (s) { if (c.idx === 7) s.getFill().setTransparent(); else s.getFill().setSolidFill(ACCENTS_[c.idx - 1]); }); c.commit(); } function ssCycleAccentColor() { var shapes = shapesIn_(sel_()); var page = currentPage_(); if (!shapes.length || !page) return; var c = nextCycle_("ssAccentIdx", 6); var accent = ACCENTS_[c.idx - 1]; var contrast = "#000000"; try { var rgb = page.getColorScheme().getConcreteColor(accent).asRgbColor(); var lum = 0.299 * rgb.getRed() + 0.587 * rgb.getGreen() + 0.114 * rgb.getBlue(); contrast = lum >= 128 ? "#000000" : "#FFFFFF"; } catch (e) { /* scheme unreadable: keep black */ } var changed = 0; shapes.forEach(function (s) { var filled = hasVisibleFill_(s); if (!hasText_(s)) { if (filled) { s.getFill().setSolidFill(accent); changed++; } return; } if (filled) { s.getFill().setSolidFill(accent); s.getText().getTextStyle().setForegroundColor(contrast); } else { s.getText().getTextStyle().setForegroundColor(accent); } changed++; }); if (changed) c.commit(); } // ============================================================================= // Deck hygiene (0.7.8) // ============================================================================= // Every stamp we create carries this in its alt-text description, so the // toggle can find its own stamps again without touching the user's shapes. var SS_DRAFT_TAG = "slideshortcuts:draft-stamp"; /** * Toggle a DRAFT stamp across every slide in the deck. * * Idempotent by construction: if any tagged stamp exists we remove them all, * otherwise we add one per slide. That makes a double-press a clean undo and * makes it impossible to stack duplicate stamps by pressing twice. */ function ssToggleDraftStamp() { var pres = SlidesApp.getActivePresentation(); var slides = pres.getSlides(); var existing = []; for (var i = 0; i < slides.length; i++) { var els = slides[i].getPageElements(); for (var j = 0; j < els.length; j++) { if (els[j].getDescription() === SS_DRAFT_TAG) existing.push(els[j]); } } if (existing.length) { for (var k = 0; k < existing.length; k++) existing[k].remove(); return; } // Centre a wide, rotated, light-grey word on each slide, behind the content. var w = pres.getPageWidth(), h = pres.getPageHeight(); var boxW = w * 0.8, boxH = h * 0.3; for (var s2 = 0; s2 < slides.length; s2++) { var box = slides[s2].insertTextBox("DRAFT", (w - boxW) / 2, (h - boxH) / 2, boxW, boxH); box.setDescription(SS_DRAFT_TAG); box.setRotation(-20); var txt = box.getText(); txt.getTextStyle() .setFontSize(Math.round(h / 5)) .setBold(true) .setForegroundColor("#D8D2C6"); txt.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER); box.sendToBack(); // never obscure real content } } /** * Clear the speaker notes on every slide. * * NOTE: this removes NOTES only, not Drive comments. Comments on a Slides file * live in Drive, not in the Slides API, so deleting them would require a Drive * OAuth scope on top of presentations.currentonly - a consent-screen change and * a re-review. Deliberately out of scope here; tracked on the roadmap. */ function ssRemoveSpeakerNotes() { var slides = SlidesApp.getActivePresentation().getSlides(); for (var i = 0; i < slides.length; i++) { var shape = slides[i].getNotesPage().getSpeakerNotesShape(); if (shape) shape.getText().setText(""); } }