/* ArozOS Office - Slides picture tools ==================================== Everything that acts on a selected picture, kept out of slides.js so that file stays about the document and the canvas: - the floating picture bar, the same shape as the text-edit bar but anchored to a selected image: crop, reset, format options - the shape grid: the crop shapes drawn as icons rather than listed as names, shared by the bar and the toolbar's crop button - the format panel: a docked side panel for size, position, rotation, alignment, re-colour and the brightness / contrast / transparency adjustments slides.js owns the document, so this module never touches the model directly - it asks through the host object passed to init(): SlidesImageTools.init({ getImage: fn -> the lone selected image object, or null, objEl: fn(id) -> its DOM element, commit: fn, // model changed, redraw + undo startCrop: fn(id), endCrop: fn(apply), isCropping: fn -> bool, resetImage: fn(obj), setMask: fn(obj, kind), shapeKinds: [{kind,label}], slideSize: [w,h], relayout: fn }); and the host drives it with sync() / hide() / reposition(). */ var SlidesImageTools = (function () { "use strict"; var host = null; var $bar = null; var $panel = null; var barObjId = null; /* Re-colour presets. Each is a CSS filter chain, and the swatches in the panel show the picture itself through that filter, so what is previewed is exactly what is applied. The tint recipe (grayscale, then sepia as a colour base, then rotate the hue) is the standard way to wash an arbitrary photo into one hue with plain CSS. */ var RECOLORS = [ { key: "", label: "No recolour", filter: "" }, { key: "gray", label: "Greyscale", filter: "grayscale(1)" }, { key: "sepia", label: "Sepia", filter: "sepia(1)" }, { key: "washout", label: "Washed out", filter: "grayscale(0.4) brightness(1.35) contrast(0.7)" }, { key: "blue-light", label: "Light blue", filter: tint(210, 0.55, 1.25) }, { key: "blue-dark", label: "Dark blue", filter: tint(215, 1.1, 0.7) }, { key: "teal", label: "Teal", filter: tint(175, 1.0, 0.95) }, { key: "green", label: "Green", filter: tint(105, 0.95, 0.95) }, { key: "lime", label: "Lime", filter: tint(75, 1.3, 1.15) }, { key: "yellow", label: "Yellow", filter: tint(45, 1.4, 1.15) }, { key: "orange", label: "Orange", filter: tint(15, 1.3, 1.0) }, { key: "red", label: "Red", filter: tint(345, 1.2, 0.9) }, { key: "purple", label: "Purple", filter: tint(265, 0.9, 0.9) }, { key: "pink", label: "Pink", filter: tint(315, 0.9, 1.15) }, { key: "black-white", label: "Black and white", filter: "grayscale(1) contrast(2.6) brightness(1.05)" }, { key: "negative", label: "Negative", filter: "invert(1)" } ]; // hue is where the wash lands, sat how strong it is, bright how light function tint(hue, sat, bright) { return "grayscale(1) sepia(1) hue-rotate(" + (hue - 40) + "deg) " + "saturate(" + sat + ") brightness(" + bright + ")"; } function recolorByKey(key) { for (var i = 0; i < RECOLORS.length; i++) { if (RECOLORS[i].key === (key || "")) return RECOLORS[i]; } return RECOLORS[0]; } /* imageFilter is the single place that turns a picture's re-colour and adjustments into CSS, so the canvas, the thumbnails, present mode and the panel's own swatches all agree. Exported for slides.js to use while rendering. */ function imageFilter(p) { var parts = []; var rc = recolorByKey(p.recolor); if (rc.filter) parts.push(rc.filter); var b = Number(p.bright) || 0; var c = Number(p.contrast) || 0; if (b) parts.push("brightness(" + (1 + clamp(b, -0.9, 2)) + ")"); if (c) parts.push("contrast(" + (1 + clamp(c, -0.9, 2)) + ")"); return parts.join(" "); } function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } function esc(t) { return OfficeApp.escapeHtml(t); } function num(v, d) { var n = Number(v); return isFinite(n) ? n : d; } /* ================= shape grid ================= */ // shapeIcon draws one crop shape as a small outline. The catalogue draws // it, from the very same geometry the canvas uses, so the icon cannot // drift from the mask it stands for. function shapeIcon(kind, size) { return SlidesShapes.icon(kind || "rect", size || 22); } /* showShapeMenu opens the crop-shape picker under an element. The shapes are icons in a grid rather than a list of names, because the outline is the thing being chosen. */ function showShapeMenu(anchorEl) { closeShapeMenu(); if (!host) return; var o = host.getImage(); if (!o) return; var $m = $('
'); var $none = $('') .append($(shapeIcon("rect"))) .append($("").text("No shape (rectangle)")); if (!o.props.mask) $none.addClass("active"); $none.on("click", function () { closeShapeMenu(); host.setMask(o, ""); }); $m.append($none); var $grid = $(''); host.shapeKinds.forEach(function (s) { if (s.kind === "rect") return; var $b = $('') .attr("title", s.label) .html(shapeIcon(s.kind)); if (o.props.mask === s.kind) $b.addClass("active"); $b.on("click", function () { closeShapeMenu(); host.setMask(o, s.kind); }); $grid.append($b); }); $m.append($grid); $("body").append($m); var r = anchorEl.getBoundingClientRect(); var w = $m.outerWidth(), h = $m.outerHeight(); var x = Math.min(r.left, window.innerWidth - w - 6); var y = r.bottom + 4; if (y + h > window.innerHeight - 6) y = Math.max(6, r.top - h - 4); $m.css({ left: Math.max(6, x) + "px", top: y + "px" }); setTimeout(function () { $(document).on("mousedown.slshapegrid", function (e) { if (!$m[0].contains(e.target)) closeShapeMenu(); }); }, 0); } function closeShapeMenu() { $(".sl-shapegrid").remove(); $(document).off("mousedown.slshapegrid"); } /* ================= floating picture bar ================= */ function barBtn(icon, title, fn) { var $b = $(''); $b.on("mousedown", function (e) { e.preventDefault(); }); $b.on("click", fn); return $b; } function buildBar() { $bar = $(''); var $row = $(''); $bar.append($row); $row.append($('') .append('') .append(document.createTextNode("Edit image"))); $row.append(''); // crop and the crop shapes are one control: the button crops, the // caret beside it picks the shape to crop to var $crop = barBtn("crop", "Crop image", function () { var o = host.getImage(); if (!o) return; if (host.isCropping()) host.endCrop(true); else host.startCrop(o.id); }); $crop.addClass("sl-imagebar-crop"); $row.append($crop); var $caret = barBtn("caret down", "Crop to shape", function (e) { showShapeMenu(e.currentTarget); }); $caret.addClass("sl-imagebar-caret"); $row.append($caret); $row.append(barBtn("history", "Reset image (clear crop, shape and colour)", function () { var o = host.getImage(); if (o) host.resetImage(o); })); $row.append(''); var $fmt = barBtn("sliders horizontal", "Format options", function () { togglePanel(); }); $fmt.addClass("sl-imagebar-fmt"); $row.append($fmt); $("body").append($bar); } function syncBarState() { if (!$bar) return; $bar.find(".sl-imagebar-crop").toggleClass("active", !!host.isCropping()); $bar.find(".sl-imagebar-fmt").toggleClass("active", !!$panel); } function reposition() { if (!$bar || !host) return; var o = host.getImage(); var el = o ? host.objEl(o.id) : null; if (!el) { hideBar(); return; } var r = el.getBoundingClientRect(); var w = $bar.outerWidth(), h = $bar.outerHeight(); var x = r.left + (r.width - w) / 2; // below the picture by default, like the reference: the space above // is usually where the slide's own content is var y = r.bottom + 8; if (y + h > window.innerHeight - 4) y = Math.max(4, r.top - h - 8); if (x + w > window.innerWidth - 4) x = window.innerWidth - w - 4; if (x < 4) x = 4; $bar.css({ left: x + "px", top: y + "px" }); } function hideBar() { if ($bar) { $bar.remove(); $bar = null; } barObjId = null; closeShapeMenu(); } /* sync is called whenever the selection or the document changes: it shows the bar for a lone picture and takes it away otherwise */ function sync() { if (!host) return; var o = host.getImage(); if (!o) { hideBar(); if ($panel) renderPanel(); return; } if (!$bar) buildBar(); barObjId = o.id; syncBarState(); reposition(); if ($panel) renderPanel(); } /* ================= format panel ================= */ function togglePanel() { if (!host) return; if ($panel) closePanel(); else openPanel(); } function openPanel() { if ($panel) return; $panel = $(''); $("#slMain").after($panel); renderPanel(); syncBarState(); if (host.relayout) host.relayout(); } function closePanel() { if (!$panel) return; $panel.remove(); $panel = null; syncBarState(); if (host.relayout) host.relayout(); } function section(title, key, $body) { var open = OfficeApp.getSetting("fmtSection." + key, true); var $s = $('').toggleClass("closed", !open); var $h = $('