Browse Source

Improve Slides shape draw UX and hit testing

Shape insertion now matches PowerPoint-style flow: choosing a shape arms draw mode, dragging defines its bounds, Shift preserves catalog proportions, and click-to-place drops the default size. The update also refactors shape defaults/props setup, auto-scrolls active thumbnails during keyboard navigation, and adjusts line pointer-events so only the stroke is hittable (allowing clicks through the bounding box). UI polish includes presentation icon alignment tweaks, thumbnail scroll margin, and simplifying the image tools bar by removing the redundant "Edit image" label.
Toby Chui 13 giờ trước cách đây
mục cha
commit
d5c6747856

+ 6 - 0
src/web/Office/README.md

@@ -607,6 +607,12 @@ document did not give it one), `evenOdd` (the path has holes in it), and
 `detail` (markings such as the divider bars of a predefined process, drawn
 over the outline rather than filled).
 
+Picking a shape arms the canvas (`insertShape` -> `pendingDraw = "shape"`), as
+PowerPoint does: drag out its box (Shift keeps the catalogue's proportions,
+`defaultSize`), or click to drop it at that size. Lines, likewise, are hit by
+their stroke only - the line's box lets clicks through (`slides.css`,
+`.sl-type-line`), so a click beside a diagonal reaches what is under it.
+
 The picker itself is `showShapePicker()`: the categories down the left, the
 shapes of the one in hand as icons on the right. The icons come from
 `SlidesShapes.icon()` — the same geometry again — so a picker entry cannot

+ 19 - 12
src/web/Office/slides/slides.css

@@ -66,6 +66,8 @@
 /* focusing a thumbnail selects it, so the active border is already the
    focus indicator - the browser's own outline would only double it up */
 .sl-thumb:focus { outline: none; }
+/* keyboard walking scrolls the rail (updateRailActive): leave a gap */
+.sl-thumb { scroll-margin: 12px 0; }
 .sl-thumb.active .sl-thumb-view {
     border-color: var(--of-accent);
     box-shadow: 0 0 0 1px var(--of-accent);
@@ -200,6 +202,12 @@
 }
 
 .sl-obj { position: absolute; box-sizing: border-box; }
+/* A line is hit by its stroke only: the box around it lets clicks through,
+   and lineSvg's wide transparent polyline (the svg's first child) is the
+   target - so a click beside a diagonal line reaches what is under it. */
+#slSlideLayer .sl-obj.sl-type-line,
+#slSlideLayer .sl-obj.sl-type-line svg * { pointer-events: none; }
+#slSlideLayer .sl-obj.sl-type-line svg > polyline:first-child { pointer-events: stroke; }
 .sl-obj > svg { display: block; width: 100%; height: 100%; overflow: visible; }
 .sl-obj img { display: block; width: 100%; height: 100%; pointer-events: none; }
 
@@ -437,7 +445,16 @@ body.dark .sl-h.sl-h-adj { background: #fbbc04; }
 .sl-present-btn { padding: 0 12px 0 10px; border-radius: 15px 0 0 15px; }
 .sl-present-more { padding: 0 9px 0 7px; border-radius: 0 15px 15px 0; border-left: 1px solid rgba(255, 255, 255, 0.35); }
 .sl-present-btn:hover, .sl-present-more:hover { filter: brightness(1.08); }
-.sl-present-btn i.icon, .sl-present-more i.icon { margin: 0 !important; font-size: 12px !important; }
+.sl-present-btn i.icon, .sl-present-more i.icon {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: auto;
+    height: auto;
+    margin: 0 !important;
+    font-size: 12px !important;
+    line-height: 1 !important;
+}
 body.of-narrow .sl-present-btn span { display: none; }
 body.of-narrow .sl-present-btn { padding: 0 9px; }
 
@@ -845,17 +862,7 @@ body.of-narrow .sl-ov-card, body.of-narrow .sl-ov-add { width: 100%; }
 /* ============ Picture tools (slides_image.js) ============ */
 
 /* the floating picture bar reuses the text bar's chrome so the two feel
-   like one family; only the label and the split crop control differ */
-.sl-imagebar-label {
-    display: inline-flex;
-    align-items: center;
-    gap: 4px;
-    padding: 0 6px 0 2px;
-    font-size: 12px;
-    color: var(--of-fg-soft);
-    white-space: nowrap;
-}
-.sl-imagebar-label i.icon { margin: 0 !important; font-size: 12px !important; }
+   like one family; only the split crop control differs */
 .sl-imagebar .of-te-btn.active { background: var(--of-accent-soft); color: var(--of-accent); }
 /* crop + caret read as one split button */
 .sl-imagebar-crop { border-radius: 4px 0 0 4px !important; }

+ 75 - 5
src/web/Office/slides/slides.js

@@ -1115,6 +1115,9 @@ var SlidesApp = (function () {
         $("#slThumbs .sl-thumb").each(function (i) {
             $(this).toggleClass("active", i === cur);
         });
+        // walking the deck with the keys: the rail scrolls with it
+        var act = $("#slThumbs .sl-thumb").eq(cur)[0];
+        if (act) act.scrollIntoView({ block: "nearest", inline: "nearest" });
         if (overview) {
             $("#slOverview .sl-ov-card").each(function (i) {
                 $(this).toggleClass("active", i === cur);
@@ -1728,11 +1731,13 @@ var SlidesApp = (function () {
         }, 0);
     }
 
-    function insertShape(kind) {
+    // a brace or a bracket only reads as itself tall and narrow, so the
+    // catalogue gets to say what box its shapes want
+    function shapeDefaultSize(kind) {
+        return SlidesShapes.defaultSize(kind) || [200, 160];
+    }
+    function shapeProps(kind) {
         var th = themeOf();
-        // a brace or a bracket only reads as itself tall and narrow, so the
-        // catalogue gets to say what box its shapes want
-        var size = SlidesShapes.defaultSize(kind) || [200, 160];
         var props = {
             kind: kind, fill: /^#[0-9a-fA-F]{6}$/.test(th.accent) ? th.accent : "#e07b1f",
             stroke: "#333333", strokeW: 0, text: "", fontSize: 18
@@ -1740,11 +1745,22 @@ var SlidesApp = (function () {
         // a speech bubble starts with its tip where PowerPoint puts one,
         // and a yellow handle to move it
         if (SlidesShapes.adjustable(kind) === "tip") props.adj = SlidesShapes.tipDefaults(kind);
-        addObj("shape", props, { x: 480 - size[0] / 2, y: 270 - size[1] / 2, w: size[0], h: size[1] });
+        return props;
+    }
+    /* Picking a shape arms the canvas, as PowerPoint does: drag out the box
+       it should fill (Shift keeps the shape's own proportions), or click to
+       drop one at its default size there. */
+    var pendingShape = null;
+    function insertShape(kind) {
+        armDraw("shape");
+        pendingShape = kind;
+        OfficeApp.setStatus("Drag on the slide to draw the " + SlidesShapes.label(kind).toLowerCase() +
+            " (Shift keeps its proportions), or click to place it - Esc to cancel", "info", 0);
     }
     function armDraw(kind) {
         endEdit(true);
         pendingDraw = kind;
+        pendingShape = null;
         canvasEl.classList.add("sl-drawmode");
         OfficeApp.setStatus("Drag on the slide to draw a " + (kind === "arrow" ? "arrow" : "line") +
             " - Esc to cancel", "info", 0);
@@ -1752,6 +1768,7 @@ var SlidesApp = (function () {
     }
     function disarmDraw() {
         pendingDraw = null;
+        pendingShape = null;
         canvasEl.classList.remove("sl-drawmode");
         OfficeApp.setStatus("");
         syncDrawButtons();
@@ -2495,6 +2512,19 @@ var SlidesApp = (function () {
             endEdit(true);
         }
 
+        // armed shape: drag out its box
+        if (pendingDraw === "shape" && pendingShape) {
+            var sslide = curSlide();
+            var sobj = {
+                id: genId(), type: "shape", x: pt.x, y: pt.y, w: 0, h: 0, rot: 0,
+                z: sslide.objects.length + 1, props: shapeProps(pendingShape)
+            };
+            sslide.objects.push(sobj);
+            renderEditorSlide();
+            drag = { mode: "drawshape", id: sobj.id, start: pt, moved: false, kind: pendingShape };
+            try { canvasEl.setPointerCapture(e.pointerId); } catch (err) { }
+            return;
+        }
         // armed line/arrow drawing
         if (pendingDraw) {
             var th = themeOf();
@@ -2755,6 +2785,27 @@ var SlidesApp = (function () {
                 renderOverlay();
                 break;
             }
+            case "drawshape": {
+                o = objById(drag.id);
+                if (!o) return;
+                var sx0 = drag.start.x, sy0 = drag.start.y, ex = pt.x, ey = pt.y;
+                if (snapGrid) {
+                    ex = Math.round(ex / GRID) * GRID;
+                    ey = Math.round(ey / GRID) * GRID;
+                }
+                var bw = Math.abs(ex - sx0), bh = Math.abs(ey - sy0);
+                if (e.shiftKey && bw > 0 && bh > 0) {
+                    var ds = shapeDefaultSize(drag.kind);
+                    var ar = ds[0] / ds[1];
+                    if (bw / bh > ar) bw = bh * ar; else bh = bw / ar;
+                }
+                o.x = ex < sx0 ? sx0 - bw : sx0;
+                o.y = ey < sy0 ? sy0 - bh : sy0;
+                o.w = bw;
+                o.h = bh;
+                updateObjEl(o);
+                break;
+            }
             case "draw": {
                 o = objById(drag.id);
                 if (!o) return;
@@ -2826,6 +2877,25 @@ var SlidesApp = (function () {
             marqueeEl.style.display = "none";
             return;
         }
+        if (d.mode === "drawshape") {
+            var so2 = objById(d.id);
+            disarmDraw();
+            if (!so2) return;
+            if (so2.w < 6 && so2.h < 6) {
+                // a click: the shape's own size, centred where it was clicked
+                var dsz = shapeDefaultSize(d.kind);
+                so2.w = dsz[0];
+                so2.h = dsz[1];
+                so2.x = clamp(d.start.x - dsz[0] / 2, 0, SLIDE_W - dsz[0]);
+                so2.y = clamp(d.start.y - dsz[1] / 2, 0, SLIDE_H - dsz[1]);
+            } else {
+                so2.w = Math.max(6, so2.w);
+                so2.h = Math.max(6, so2.h);
+            }
+            setSel([so2.id]);
+            commit();
+            return;
+        }
         if (d.mode === "draw") {
             var o = objById(d.id);
             disarmDraw();

+ 0 - 5
src/web/Office/slides/slides_image.js

@@ -160,11 +160,6 @@ var SlidesImageTools = (function () {
         var $row = $('<div class="of-te-row"></div>');
         $bar.append($row);
 
-        $row.append($('<span class="sl-imagebar-label"></span>')
-            .append('<i class="image outline icon"></i>')
-            .append(document.createTextNode("Edit image")));
-        $row.append('<div class="of-te-sep"></div>');
-
         // 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 () {