visio-solid-viewer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  import { VISIO_RASTER_IMAGE_LIMITS, VISIO_VISIBILITY_LIMITS, getVisioPageLayers, inspectVisioRasterImage, parseVsdx, resolveVisioPageVisibility, validateVisioForeignVector } from "ooxml-core/visio";
2
+ import { registerControls } from "ooxml-ui/controls";
3
+ import { getIcon, registerIcon } from "ooxml-ui/icons";
2
4
  import { escape, ssr, ssrAttribute, ssrHydrationKey, ssrStyle } from "solid-js/web";
3
5
  import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
6
  export * from "visio-core";
@@ -2136,7 +2138,7 @@ function selection(target) {
2136
2138
  }
2137
2139
  /** Own every DOM listener and abort them together when the surface is disposed. */
2138
2140
  function wireViewerInputs(controls, controller, fit) {
2139
- const { viewport, commandRoot, pageSelect, searchInput } = controls;
2141
+ const { viewport, commandRoot, zoomSlider, searchInput } = controls;
2140
2142
  const events = new ((viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
2141
2143
  const options = { signal: events.signal };
2142
2144
  searchInput.addEventListener("input", () => controller.setSearchQuery(searchInput.value), options);
@@ -2152,13 +2154,9 @@ function wireViewerInputs(controls, controller, fit) {
2152
2154
  controller.setSearchQuery("");
2153
2155
  }
2154
2156
  }, options);
2155
- pageSelect.addEventListener("change", () => controller.setPage(Number(pageSelect.value)), options);
2157
+ zoomSlider.addEventListener("input", () => controller.setZoom(zoomSlider.value / 100), options);
2156
2158
  commandRoot.addEventListener("click", (event) => {
2157
2159
  const action = event.target?.closest?.("button")?.dataset.action;
2158
- if (action === "in") controller.setZoom(controller.state.zoom * 1.25);
2159
- if (action === "out") controller.setZoom(controller.state.zoom / 1.25);
2160
- if (action === "fit") fit();
2161
- if (action === "actual") controller.setZoom(1);
2162
2160
  if (action === "search-next") controller.nextSearchResult();
2163
2161
  if (action === "search-previous") controller.previousSearchResult();
2164
2162
  }, options);
@@ -2197,7 +2195,7 @@ function wireViewerInputs(controls, controller, fit) {
2197
2195
  }
2198
2196
  if (event.key === "0") {
2199
2197
  event.preventDefault();
2200
- fit();
2198
+ fit("page");
2201
2199
  }
2202
2200
  }, options);
2203
2201
  return () => events.abort();
@@ -2301,13 +2299,7 @@ button:disabled { opacity:.4; cursor:default; }
2301
2299
  button:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible,[tabindex]:focus-visible { outline:2px solid var(--vv-focus,var(--_vv-accent)); outline-offset:2px; }
2302
2300
  button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-accent-soft); }
2303
2301
  .toolbar { flex:none; min-width:0; border-bottom:1px solid var(--_vv-border); background:var(--_vv-surface); }
2304
- .ribbon-primary { height:32px; display:flex; align-items:center; gap:6px; padding:2px 8px; }
2305
- .page-picker { display:flex; min-width:0; align-items:center; gap:8px; color:var(--_vv-muted); font-size:11px; }
2306
- .page-picker select { width:190px; max-width:42vw; height:26px; min-height:26px; padding:2px 6px; font-size:11px; color:var(--_vv-ink); }
2307
- .page-stepper { display:flex; align-items:center; gap:2px; }
2308
- .page-stepper button { width:26px; height:26px; min-height:26px; padding:0; font-size:18px; line-height:22px; background:transparent; border-color:transparent; }
2309
2302
  .spacer { flex:1; }
2310
- .local-label { color:var(--_vv-muted); font-size:10px; white-space:nowrap; }
2311
2303
  .ribbon-tabs { height:35px; display:flex; align-items:stretch; padding:0 4px; border-bottom:1px solid var(--_vv-border); }
2312
2304
  .ribbon-tabs button { position:relative; min-height:34px; padding:7px 14px; border:0; border-radius:0; background:transparent; color:var(--_vv-muted); font-size:12px; font-weight:500; }
2313
2305
  .ribbon-tabs button[aria-selected="true"] { color:var(--_vv-accent); }
@@ -2317,15 +2309,6 @@ button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-acce
2317
2309
  .group-label { font-size:10px; line-height:12px; color:var(--_vv-muted); text-align:center; }
2318
2310
  .ribbon-actions { display:flex; align-items:stretch; gap:2px; flex:1; }
2319
2311
  .ribbon-command { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:2px; min-width:70px; padding:2px 6px; border-color:transparent; background:transparent; font-size:11px; white-space:nowrap; }
2320
- .command-icon { display:grid; place-items:center; width:26px; height:26px; color:var(--_vv-accent); }
2321
- .command-icon::before { content:""; width:24px; height:24px; background:currentColor; mask:var(--command-symbol) center / contain no-repeat; }
2322
- [data-icon="selection"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m5%203%2013%209-6%201-3%206z%22%2F%3E%3C%2Fsvg%3E"); }
2323
- [data-icon="edit"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%205h16M12%205v14M8%2019h8%22%2F%3E%3C%2Fsvg%3E"); }
2324
- [data-icon="layers"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m3%208%209-5%209%205-9%205zM3%2012l9%205%209-5M3%2016l9%205%209-5%22%2F%3E%3C%2Fsvg%3E"); }
2325
- [data-icon="pages"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%225%22%20y%3D%223%22%20width%3D%2214%22%20height%3D%2218%22%20rx%3D%221%22%2F%3E%3Cpath%20d%3D%22M8%208h8M8%2012h8M8%2016h5%22%2F%3E%3C%2Fsvg%3E"); }
2326
- [data-icon="inspector"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%224%22%20width%3D%2218%22%20height%3D%2216%22%20rx%3D%221%22%2F%3E%3Cpath%20d%3D%22M14%204v16M17%208h1M17%2012h1M17%2016h1%22%2F%3E%3C%2Fsvg%3E"); }
2327
- [data-icon="notes"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%204h16v12H9l-5%204z%22%2F%3E%3Cpath%20d%3D%22M8%208h8M8%2012h5%22%2F%3E%3C%2Fsvg%3E"); }
2328
- .ribbon-command[data-chrome="edit"] .command-icon { font-family:ui-serif,Georgia,serif; text-decoration:underline; text-decoration-thickness:1px; text-underline-offset:3px; }
2329
2312
  .ribbon-hint { align-self:center; flex:none; margin:0; padding:0 16px; font-size:11px; line-height:18px; color:var(--_vv-muted); }
2330
2313
  .search-group { width:330px; }
2331
2314
  .search-controls { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
@@ -2397,11 +2380,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2397
2380
  .status { flex:none; display:flex; align-items:center; justify-content:space-between; gap:12px; min-width:0; min-height:29px; padding:0 8px 0 12px; border-top:1px solid var(--_vv-border); color:var(--_vv-muted); background:var(--_vv-surface); font-size:10px; }
2398
2381
  .status-message { display:flex; gap:16px; min-width:0; }
2399
2382
  .status-message span { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
2400
- .zoom-controls { display:flex; flex:none; align-items:center; gap:3px; }
2401
- .zoom-controls button { min-height:24px; height:24px; padding:1px 6px; border-color:transparent; background:transparent; font-size:10px; }
2402
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:24px; padding:0; font-size:17px; line-height:20px; }
2403
- .zoom-divider { width:1px; height:14px; background:var(--_vv-border); margin:0 3px; }
2404
- .zoom { min-width:40px; font-size:10px; text-align:center; font-variant-numeric:tabular-nums; }
2405
2383
  .empty { max-width:360px; margin:60px auto; padding:16px; text-align:center; color:var(--_vv-muted); font-size:12px; }
2406
2384
  .empty strong { display:block; color:var(--_vv-ink); font-size:18px; font-weight:500; margin-bottom:8px; }
2407
2385
  @media(max-width:1100px) { .ribbon-hint { display:none; } [data-diagnostics] { display:none; } }
@@ -2409,11 +2387,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2409
2387
  button,select,input,textarea { min-height:44px; }
2410
2388
  button { min-width:44px; }
2411
2389
  summary { min-height:44px; line-height:44px; }
2412
- .ribbon-primary { height:52px; padding:4px 8px; }
2413
- .page-picker { flex:1; }
2414
- .page-picker select { width:100%; max-width:none; min-height:44px; height:44px; font-size:12px; }
2415
- .page-stepper button { width:44px; min-height:44px; height:44px; }
2416
- .local-label,.ribbon-primary>.spacer { display:none; }
2417
2390
  .ribbon-tabs { height:44px; }
2418
2391
  .ribbon-tabs button { min-height:44px; padding:8px 16px; }
2419
2392
  .ribbon-content { min-height:86px; }
@@ -2425,7 +2398,7 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2425
2398
  .search-controls button { min-width:44px; font-size:0; padding:3px; }
2426
2399
  .search-controls [data-action="search-previous"]::before { content:"‹"; font-size:22px; }
2427
2400
  .search-controls [data-action="search-next"]::before { content:"›"; font-size:22px; }
2428
- #home-panel>.ribbon-group:not(.search-group) { display:none; }
2401
+ #home-panel>.ribbon-group:not(.search-group):not(.undo-group) { display:none; }
2429
2402
  .ribbon-command { min-width:90px; }
2430
2403
  .workspace { display:block; overflow:auto; }
2431
2404
  .page-rail { width:100%; max-height:190px; border-right:0; border-bottom:1px solid var(--_vv-border); }
@@ -2442,29 +2415,413 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2442
2415
  .notes-strip button { min-height:44px; }
2443
2416
  .status { flex-wrap:wrap; gap:0; padding:4px 8px; }
2444
2417
  .status-message { width:100%; min-height:18px; }
2445
- .zoom-controls { justify-content:flex-end; width:100%; }
2446
- .zoom-controls button { min-height:44px; height:44px; min-width:44px; font-size:11px; }
2447
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; font-size:20px; }
2448
- .zoom { font-size:11px; }
2449
2418
  }
2450
2419
  @media(pointer:coarse) {
2451
2420
  button,select,input,textarea { min-height:44px; }
2452
2421
  button { min-width:44px; }
2453
2422
  summary,.layer-controls label { min-height:44px; line-height:44px; }
2454
- .ribbon-primary { min-height:52px; height:52px; }
2455
- .page-picker select,.page-stepper button { min-height:44px; height:44px; }
2456
- .page-stepper button { min-width:44px; }
2457
2423
  .ribbon-tabs { min-height:44px; height:44px; }
2458
2424
  .ribbon-tabs button { min-height:44px; }
2459
2425
  .ribbon-content { min-height:96px; }
2460
2426
  .search-group { width:380px; max-width:100%; }
2461
- .zoom-controls button,.notes-strip button { min-height:44px; height:44px; }
2462
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; }
2427
+ .notes-strip button { min-height:44px; height:44px; }
2463
2428
  .layer-controls input { min-height:20px; }
2464
2429
  }
2430
+ /* Suite ribbon: tabs lead directly into Visio command groups. */
2431
+ .ribbon-tabs { height:40px; }
2432
+ .ribbon-tabs button { min-height:39px; padding:8px 16px; }
2433
+ .ribbon-content { min-height:104px; padding:4px 4px; }
2434
+ .search-group { order:2; width:320px; }
2435
+ .ribbon-hint { order:3; padding:10px 18px; }
2436
+ .ribbon-command { min-width:82px; }
2437
+ .pane-close { display:none; }
2438
+ @media(max-width:760px) {
2439
+ .ribbon-tabs { height:44px; }
2440
+ .ribbon-tabs button { min-height:44px; }
2441
+ .ribbon-content { min-height:54px; padding:4px 0; }
2442
+ .search-group { flex:0 0 100%; width:100%; min-height:54px; }
2443
+ .search-feedback,.group-label { display:none; }
2444
+ .ribbon-content { overflow-x:auto; scroll-snap-type:x mandatory; }
2445
+ .workspace { display:flex; position:relative; overflow:hidden; }
2446
+ .viewport { flex:1; width:100%; height:100%; min-height:0; padding:16px 4px; }
2447
+ .inspector-pane { position:absolute; inset:0 0 0 auto; width:min(320px,100%); z-index:4; overflow:auto; border-left:1px solid var(--_vv-border); box-shadow:-8px 0 24px #0002; }
2448
+ .pane-close { display:block; width:44px; height:44px; padding:0; font-size:22px; background:transparent; border:0; }
2449
+ .inspector-kind { margin-left:auto; }
2450
+ .page-rail { position:absolute; inset:0 auto 0 0; width:180px; max-height:none; z-index:3; border-right:1px solid var(--_vv-border); background:var(--_vv-secondary); }
2451
+ .page-list { flex-direction:column; }
2452
+ .page-list li { flex:none; }
2453
+ .notes-strip { display:none; }
2454
+ .status { min-height:48px; padding:2px 8px; }
2455
+ .status-message { display:none; }
2456
+ }
2457
+ .toolbar { position:relative; }
2458
+ .ribbon-tools { display:flex; flex:none; order:0; }
2459
+ .tools-content { display:flex; align-items:stretch; }
2460
+ .ribbon-tools>summary { display:none; }
2461
+ .ribbon-command { font-size:12px; min-width:88px; }
2462
+ .ribbon-hint { font-size:12px; line-height:20px; flex:1; }
2463
+ .group-label { font-size:11px; line-height:14px; }
2464
+ .inspector-card,.edit-controls,.layer-controls { font-size:12px; line-height:18px; }
2465
+ .selection-hint,.document-card dl { font-size:12px; }
2466
+ .pane-heading { font-size:12px; }
2467
+ .inspector-pane .pane-heading { position:sticky; top:0; z-index:1; background:var(--_vv-secondary); flex:none; }
2468
+ .status .notes-strip { border:0; padding:0 8px; background:transparent; min-height:28px; }
2469
+ .status .notes-strip>span { display:none; }
2470
+ .status-message { flex:1; }
2471
+ @media(max-width:760px) {
2472
+ #home-panel { overflow:visible; min-height:54px; padding:4px; }
2473
+ .ribbon-tools { display:block; flex:0 0 84px; width:84px; order:1; }
2474
+ .ribbon-tools>summary { display:flex; min-height:44px; align-items:center; justify-content:center; gap:6px; padding:0 10px; border:1px solid var(--_vv-border); background:var(--_vv-surface); border-radius:4px; font-size:12px; cursor:pointer; list-style:none; }
2475
+ .ribbon-tools>summary::-webkit-details-marker { display:none; }
2476
+ .ribbon-tools .tools-content { display:flex; position:absolute; top:100%; left:4px; right:4px; z-index:6; flex-direction:column; padding:12px; gap:12px; border:1px solid var(--_vv-border); border-radius:4px; background:var(--_vv-surface); box-shadow:0 12px 28px #0003; max-height:60vh; overflow:auto; }
2477
+ .ribbon-tools:not([open])>.tools-content { display:none; }
2478
+ .ribbon-tools .ribbon-group { display:flex; width:100%; min-height:64px; border:0; padding:0; }
2479
+ .ribbon-tools .ribbon-actions { justify-content:space-between; }
2480
+ .ribbon-tools .ribbon-command { min-width:88px; font-size:12px; }
2481
+ .ribbon-tools .search-group { order:1; flex:auto; }
2482
+ .ribbon-tools .search-feedback { display:block; }
2483
+ .status { flex-wrap:nowrap; gap:4px; min-height:48px; }
2484
+ .status .notes-strip { display:none; }
2485
+ }
2486
+ @media(pointer:coarse) { .ribbon-tabs {height:45px;min-height:45px;} .ribbon-tabs button {min-height:44px;height:44px;} }
2465
2487
  @media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; } }
2466
2488
  `;
2467
2489
  //#endregion
2490
+ //#region src/styles/index.ts
2491
+ /**
2492
+ * Viewer stylesheets as text for the shadow root. New styles are CSS files here; the legacy
2493
+ * `styles.ts` string moves into this directory when it is next changed.
2494
+ */
2495
+ var canvasAndRibbonStyles = [":host{--office-foreground:var(--_vv-ink);--office-muted-foreground:var(--_vv-muted);--office-background:var(--_vv-surface);--office-surface:var(--_vv-accent-soft);--office-border:var(--_vv-border);--office-accent:var(--_vv-accent);--office-accent-foreground:var(--vv-accent-ink,#fff);--office-ring:var(--vv-focus,var(--_vv-accent));--office-font:\"Segoe UI\", Arial, sans-serif}.ribbon-row{background:0 0;border:0;flex-wrap:nowrap;align-items:stretch;gap:0;padding:0;display:flex}.ribbon-row office-ui-ribbon-group{min-width:0}.ribbon-row office-ui-button{--office-target-size:28px}.ribbon-row office-ui-button[variant=stacked]::part(button){min-width:64px;min-height:62px;font-size:12px}office-ui-button[disabled]::part(button){opacity:.45}.page-tabs{flex:none}office-ui-status-bar.status{background:var(--_vv-surface);min-height:29px;color:var(--_vv-muted);gap:16px;padding:0 8px 0 12px;font-size:11px}office-ui-status-item[value=\"\"]{display:none}office-ui-status-item[data-page-status]{color:var(--_vv-ink)}.status-message{flex:1;min-width:0}.viewport[data-tool=rectangle],.viewport[data-tool=rectangle] .paper [data-shape-id]{cursor:crosshair}.viewport[data-tool=rectangle]{touch-action:none}.draw-preview{fill:var(--_vv-accent-soft);stroke:var(--_vv-accent);stroke-width:1.5px;stroke-dasharray:4 3;pointer-events:none}.viewport[data-grid=true] .paper{--_vv-grid-minor:#00000012;--_vv-grid-major:#00000029;background-image:linear-gradient(to right, var(--_vv-grid-major) 1px, transparent 1px), linear-gradient(to top, var(--_vv-grid-major) 1px, transparent 1px), linear-gradient(to right, var(--_vv-grid-minor) 1px, transparent 1px), linear-gradient(to top, var(--_vv-grid-minor) 1px, transparent 1px);background-size:var(--vv-inch,96px) var(--vv-inch,96px), var(--vv-inch,96px) var(--vv-inch,96px), calc(var(--vv-inch,96px) / 4) calc(var(--vv-inch,96px) / 4), calc(var(--vv-inch,96px) / 4) calc(var(--vv-inch,96px) / 4);background-position:0 100%}:host(:fullscreen){width:100vw;height:100vh}@media (width<=760px){.ribbon-row{flex:1;min-width:0}.undo-group{flex:none}.ribbon-row office-ui-button[variant=stacked]::part(button){min-width:52px;min-height:44px}.ribbon-tools .tools-content office-ui-ribbon-group{width:100%}.search-group{scroll-snap-align:start}office-ui-status-bar.status{gap:4px;min-height:48px}office-ui-status-bar .notes-strip,office-ui-status-bar .status-message,office-ui-status-item[data-shape-status],office-ui-status-item[data-page-status]{display:none}.zoom-controls{--office-zoom-slider-width:0;flex:1;min-width:0}}", ".paper [data-shape-id]:focus,.paper [data-shape-id]:focus-visible{outline:none}.paper [data-shape-id]:focus-visible>[data-geometry]{stroke-dasharray:.08 .05;stroke:var(--vv-focus,var(--_vv-accent))!important;stroke-width:.04px!important}"].join("\n");
2496
+ //#endregion
2497
+ //#region src/ribbon-action.ts
2498
+ /** Event name shared by every ribbon control; detail is the typed action. */
2499
+ var RIBBON_ACTION_EVENT = "ribbon-action";
2500
+ /** Internal, non-composed event: it stays inside the viewer's shadow tree. */
2501
+ function emitRibbonAction(target, action) {
2502
+ target.dispatchEvent(new CustomEvent(RIBBON_ACTION_EVENT, {
2503
+ detail: action,
2504
+ bubbles: true
2505
+ }));
2506
+ }
2507
+ //#endregion
2508
+ //#region src/ribbon-parts.ts
2509
+ /**
2510
+ * A large stacked ribbon command. `id` is a stable name for state sync and tests; the typed
2511
+ * `action` is what runs. Labels, icons and shortcuts are static application strings.
2512
+ */
2513
+ function tool(doc, id, label, icon, action, options = {}) {
2514
+ const el = doc.createElement("office-ui-button");
2515
+ el.setAttribute("variant", "stacked");
2516
+ el.setAttribute("command", id);
2517
+ el.setAttribute("icon", icon);
2518
+ el.setAttribute("label", label);
2519
+ el.setAttribute("title", options.keys ? `${label} (${options.keys[1]})` : label);
2520
+ if (options.keys) el.setAttribute("keyshortcuts", options.keys[0]);
2521
+ if (options.pressed !== void 0) el.setAttribute("pressed", String(options.pressed));
2522
+ if (options.controls) el.setAttribute("aria-controls", options.controls);
2523
+ el.addEventListener("office-command", (event) => {
2524
+ event.stopPropagation();
2525
+ emitRibbonAction(el, action);
2526
+ });
2527
+ return el;
2528
+ }
2529
+ /** A labelled ribbon group (the shared `office-ui-ribbon-group`). */
2530
+ function group(doc, label, children, className = "") {
2531
+ const el = doc.createElement("office-ui-ribbon-group");
2532
+ el.setAttribute("label", label);
2533
+ if (className) el.className = className;
2534
+ el.append(...children);
2535
+ return el;
2536
+ }
2537
+ /** A keyboard-navigable row of groups (the shared `office-ui-toolbar`). */
2538
+ function commandRow(doc, label, children) {
2539
+ const el = doc.createElement("office-ui-toolbar");
2540
+ el.className = "ribbon-row";
2541
+ el.setAttribute("aria-label", label);
2542
+ el.append(...children);
2543
+ return el;
2544
+ }
2545
+ /** Muted two-line guidance at the end of a ribbon panel. */
2546
+ function hint(doc, lines) {
2547
+ const el = doc.createElement("p");
2548
+ el.className = "ribbon-hint";
2549
+ lines.forEach((line, index) => {
2550
+ if (index) el.append(doc.createElement("br"));
2551
+ el.append(line);
2552
+ });
2553
+ return el;
2554
+ }
2555
+ //#endregion
2556
+ //#region src/viewer-search.ts
2557
+ /** Builds the Find group: a search field, previous/next result buttons and a live status. */
2558
+ function createSearchGroup(doc) {
2559
+ const group = doc.createElement("div");
2560
+ group.className = "ribbon-group search-group";
2561
+ group.setAttribute("role", "group");
2562
+ group.setAttribute("aria-label", "Find");
2563
+ const controls = doc.createElement("div");
2564
+ controls.className = "search-controls";
2565
+ controls.setAttribute("role", "search");
2566
+ controls.setAttribute("aria-label", "Diagram text search");
2567
+ const label = doc.createElement("label");
2568
+ label.append("Find ");
2569
+ const input = doc.createElement("input");
2570
+ input.type = "search";
2571
+ input.setAttribute("aria-label", "Search diagram text");
2572
+ input.placeholder = "Text across pages";
2573
+ input.maxLength = TEXT_SEARCH_LIMITS.queryCharacters;
2574
+ input.autocomplete = "off";
2575
+ input.spellcheck = false;
2576
+ input.setAttribute("aria-describedby", "search-status");
2577
+ label.append(input);
2578
+ const step = (action, name, text) => {
2579
+ const button = doc.createElement("button");
2580
+ button.type = "button";
2581
+ button.dataset.action = action;
2582
+ button.setAttribute("aria-label", name);
2583
+ button.textContent = text;
2584
+ return button;
2585
+ };
2586
+ const status = doc.createElement("span");
2587
+ status.id = "search-status";
2588
+ status.setAttribute("role", "status");
2589
+ status.setAttribute("aria-live", "polite");
2590
+ status.setAttribute("aria-atomic", "true");
2591
+ controls.append(label, step("search-previous", "Previous matching shape", "Previous"), step("search-next", "Next matching shape", "Next"), status);
2592
+ const caption = doc.createElement("span");
2593
+ caption.className = "group-label";
2594
+ caption.textContent = "Find in drawing";
2595
+ group.append(controls, caption);
2596
+ return group;
2597
+ }
2598
+ function searchControls(root) {
2599
+ return {
2600
+ input: root.querySelector("input[type=\"search\"]"),
2601
+ status: root.querySelector("#search-status"),
2602
+ previous: root.querySelector("[data-action=\"search-previous\"]"),
2603
+ next: root.querySelector("[data-action=\"search-next\"]")
2604
+ };
2605
+ }
2606
+ function renderSearchControls(controls, state) {
2607
+ if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2608
+ controls.input.disabled = !state.document?.pages.length;
2609
+ controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2610
+ controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2611
+ const result = state.search.results[state.search.activeIndex];
2612
+ controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2613
+ }
2614
+ //#endregion
2615
+ //#region src/ribbon-home.ts
2616
+ /**
2617
+ * Visio's Home tab, limited to commands this build can honestly perform: Undo, Tools and
2618
+ * Editing. Clipboard, Font, Paragraph, Shape Styles and Arrange wait for core edit commands.
2619
+ * The compact (phone) layout folds every group but Undo into the Tools disclosure.
2620
+ */
2621
+ function buildHomePanel(doc, panel) {
2622
+ const tools = doc.createElement("details");
2623
+ tools.className = "ribbon-tools";
2624
+ tools.open = true;
2625
+ const summary = doc.createElement("summary");
2626
+ const caret = doc.createElement("span");
2627
+ caret.setAttribute("aria-hidden", "true");
2628
+ caret.textContent = "⌄";
2629
+ summary.append("Tools ", caret);
2630
+ const content = doc.createElement("div");
2631
+ content.className = "tools-content";
2632
+ content.append(group(doc, "Tools", [
2633
+ tool(doc, "pointer", "Pointer Tool", "pointer", {
2634
+ type: "tool",
2635
+ tool: "pointer"
2636
+ }, {
2637
+ keys: ["Control+1", "Ctrl+1"],
2638
+ pressed: true
2639
+ }),
2640
+ tool(doc, "rectangle", "Rectangle", "rectangle", {
2641
+ type: "tool",
2642
+ tool: "rectangle"
2643
+ }, {
2644
+ keys: ["Control+8", "Ctrl+8"],
2645
+ pressed: false
2646
+ }),
2647
+ tool(doc, "edit", "Edit text", "pencil", {
2648
+ type: "reveal",
2649
+ panel: "edit"
2650
+ }, { keys: ["F2", "F2"] })
2651
+ ]), createSearchGroup(doc), group(doc, "Editing", [
2652
+ tool(doc, "delete", "Delete", "trash", { type: "delete" }, { keys: ["Delete", "Del"] }),
2653
+ tool(doc, "layers", "Layers", "visioLayers", {
2654
+ type: "reveal",
2655
+ panel: "layers"
2656
+ }),
2657
+ tool(doc, "selection", "Shape details", "visioShapeData", {
2658
+ type: "reveal",
2659
+ panel: "selection"
2660
+ })
2661
+ ]));
2662
+ tools.append(summary, content);
2663
+ panel.append(commandRow(doc, "Home commands", [group(doc, "Undo", [tool(doc, "undo", "Undo", "undo", {
2664
+ type: "history",
2665
+ key: "undo"
2666
+ }, { keys: ["Control+Z", "Ctrl+Z"] }), tool(doc, "redo", "Redo", "redo", {
2667
+ type: "history",
2668
+ key: "redo"
2669
+ }, { keys: ["Control+Y", "Ctrl+Y"] })], "undo-group"), tools]), hint(doc, ["Rectangle: drag on the page to draw.", "Editing is experimental. Keep your original file."]));
2670
+ }
2671
+ //#endregion
2672
+ //#region src/ribbon-view.ts
2673
+ /** Visio's View tab: Views, Show (grid and task panes) and Zoom. */
2674
+ function buildViewPanel(doc, panel) {
2675
+ panel.append(commandRow(doc, "View commands", [
2676
+ group(doc, "Views", [tool(doc, "fullscreen", "Full Screen", "fullscreen", { type: "fullscreen" }, {
2677
+ keys: ["F5", "F5"],
2678
+ pressed: false
2679
+ })]),
2680
+ group(doc, "Show", [
2681
+ tool(doc, "grid", "Grid", "grid", { type: "grid" }, { pressed: false }),
2682
+ tool(doc, "pages", "Pages pane", "visioPagesPane", {
2683
+ type: "pane",
2684
+ pane: "pages"
2685
+ }, {
2686
+ pressed: true,
2687
+ controls: "page-rail"
2688
+ }),
2689
+ tool(doc, "inspector", "Inspector pane", "visioInspectorPane", {
2690
+ type: "pane",
2691
+ pane: "inspector"
2692
+ }, {
2693
+ pressed: true,
2694
+ controls: "inspector-pane"
2695
+ }),
2696
+ tool(doc, "notes", "Review notes", "message", {
2697
+ type: "reveal",
2698
+ panel: "notes"
2699
+ })
2700
+ ]),
2701
+ group(doc, "Zoom", [
2702
+ tool(doc, "zoom-fit", "Fit to Window", "fitPage", {
2703
+ type: "zoom",
2704
+ mode: "fit"
2705
+ }, { keys: ["Control+Shift+W", "Ctrl+Shift+W"] }),
2706
+ tool(doc, "page-width", "Page Width", "pageWidth", {
2707
+ type: "zoom",
2708
+ mode: "width"
2709
+ }),
2710
+ tool(doc, "actual-size", "100%", "search", {
2711
+ type: "zoom",
2712
+ mode: "actual"
2713
+ })
2714
+ ])
2715
+ ]), hint(doc, ["Ctrl+wheel or + / − to zoom.", "Ctrl+Page Up / Page Down changes page."]));
2716
+ }
2717
+ //#endregion
2718
+ //#region src/ribbon.ts
2719
+ var TABS = [[
2720
+ "home",
2721
+ "Home",
2722
+ buildHomePanel
2723
+ ], [
2724
+ "view",
2725
+ "View",
2726
+ buildViewPanel
2727
+ ]];
2728
+ /**
2729
+ * The Visio ribbon: a tab list and one panel per tab. Tab switching and keyboard movement
2730
+ * belong to the chrome; commands emit `ribbon-action` events for the router.
2731
+ */
2732
+ function createRibbon(doc) {
2733
+ const toolbar = doc.createElement("div");
2734
+ toolbar.className = "toolbar";
2735
+ toolbar.setAttribute("role", "group");
2736
+ toolbar.setAttribute("aria-label", "Diagram controls");
2737
+ const tabs = doc.createElement("div");
2738
+ tabs.className = "ribbon-tabs";
2739
+ tabs.setAttribute("role", "tablist");
2740
+ tabs.setAttribute("aria-label", "Ribbon");
2741
+ toolbar.append(tabs);
2742
+ for (const [key, name, build] of TABS) {
2743
+ const selected = key === "home";
2744
+ const tab = doc.createElement("button");
2745
+ tab.type = "button";
2746
+ tab.id = `${key}-tab`;
2747
+ tab.dataset.tab = key;
2748
+ tab.textContent = name;
2749
+ tab.setAttribute("role", "tab");
2750
+ tab.setAttribute("aria-selected", String(selected));
2751
+ tab.setAttribute("aria-controls", `${key}-panel`);
2752
+ if (!selected) tab.tabIndex = -1;
2753
+ const panel = doc.createElement("div");
2754
+ panel.className = "ribbon-content";
2755
+ panel.id = `${key}-panel`;
2756
+ panel.setAttribute("role", "tabpanel");
2757
+ panel.setAttribute("aria-labelledby", tab.id);
2758
+ panel.hidden = !selected;
2759
+ build(doc, panel);
2760
+ tabs.append(tab);
2761
+ toolbar.append(panel);
2762
+ }
2763
+ return toolbar;
2764
+ }
2765
+ //#endregion
2766
+ //#region src/status-bar.ts
2767
+ /**
2768
+ * Visio's status bar on the shared `office-ui-status-bar`: page and shape metrics, a live
2769
+ * message region, compatibility notes, the host footer slot and the shared zoom slider.
2770
+ */
2771
+ function createStatusBar(doc) {
2772
+ const bar = doc.createElement("office-ui-status-bar");
2773
+ bar.className = "status";
2774
+ bar.setAttribute("label", "Status bar");
2775
+ const item = (name) => {
2776
+ const el = doc.createElement("office-ui-status-item");
2777
+ el.setAttribute(name, "");
2778
+ el.setAttribute("label", "");
2779
+ el.setAttribute("value", "");
2780
+ return el;
2781
+ };
2782
+ const message = doc.createElement("div");
2783
+ message.className = "status-message";
2784
+ message.setAttribute("role", "status");
2785
+ const text = doc.createElement("span");
2786
+ text.dataset.status = "";
2787
+ const diagnostics = doc.createElement("span");
2788
+ diagnostics.dataset.diagnostics = "";
2789
+ message.append(text, diagnostics);
2790
+ const notes = doc.createElement("div");
2791
+ notes.className = "notes-strip";
2792
+ const notesButton = doc.createElement("button");
2793
+ notesButton.type = "button";
2794
+ notesButton.dataset.chrome = "notes";
2795
+ notesButton.setAttribute("aria-controls", "inspector-pane");
2796
+ notesButton.setAttribute("aria-expanded", "false");
2797
+ const count = doc.createElement("span");
2798
+ count.className = "notes-count";
2799
+ count.dataset.noteCount = "";
2800
+ notesButton.append("Notes", count);
2801
+ const local = doc.createElement("span");
2802
+ local.textContent = "Files stay in your browser";
2803
+ notes.append(notesButton, local);
2804
+ const footer = doc.createElement("slot");
2805
+ footer.name = "workspace-footer";
2806
+ const zoom = doc.createElement("office-ui-zoom-slider");
2807
+ zoom.slot = "end";
2808
+ zoom.className = "zoom-controls";
2809
+ zoom.setAttribute("label", "Canvas zoom");
2810
+ zoom.setAttribute("fit", "Fit page to current window");
2811
+ zoom.setAttribute("value", "100");
2812
+ bar.append(item("data-page-status"), item("data-shape-status"), message, notes, footer, zoom);
2813
+ return bar;
2814
+ }
2815
+ /** Visio's page tabs under the drawing, on the shared document tab strip. */
2816
+ function createPageTabs(doc) {
2817
+ const strip = doc.createElement("office-ui-tab-strip");
2818
+ strip.className = "page-tabs";
2819
+ strip.setAttribute("label", "Pages");
2820
+ strip.setAttribute("previous-label", "Previous page");
2821
+ strip.setAttribute("next-label", "Next page");
2822
+ return strip;
2823
+ }
2824
+ //#endregion
2468
2825
  //#region src/edit-error.ts
2469
2826
  /** Core error codes remain visible for a refused transaction; document content is plain text. */
2470
2827
  function editErrorMessage(error) {
@@ -2752,61 +3109,17 @@ var ViewerEditControls = class {
2752
3109
  }
2753
3110
  };
2754
3111
  //#endregion
2755
- //#region src/viewer-search.ts
2756
- var searchTemplate = `<div class="search-controls" role="search" aria-label="Diagram text search"><label>Find <input type="search" aria-label="Search diagram text" placeholder="Text across pages" maxlength="${TEXT_SEARCH_LIMITS.queryCharacters}" autocomplete="off" spellcheck="false" aria-describedby="search-status"></label><button type="button" data-action="search-previous" aria-label="Previous matching shape">Previous</button><button type="button" data-action="search-next" aria-label="Next matching shape">Next</button><span id="search-status" role="status" aria-live="polite" aria-atomic="true"></span></div>`;
2757
- function searchControls(root) {
2758
- return {
2759
- input: root.querySelector("input[type=\"search\"]"),
2760
- status: root.querySelector("#search-status"),
2761
- previous: root.querySelector("[data-action=\"search-previous\"]"),
2762
- next: root.querySelector("[data-action=\"search-next\"]")
2763
- };
2764
- }
2765
- function renderSearchControls(controls, state) {
2766
- if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2767
- controls.input.disabled = !state.document?.pages.length;
2768
- controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2769
- controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2770
- const result = state.search.results[state.search.activeIndex];
2771
- controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2772
- }
2773
- //#endregion
2774
3112
  //#region src/viewer-chrome.ts
2775
- /** Only static application markup belongs here. All document labels are inserted as text. */
2776
- var viewerChromeTemplate = `
2777
- <div class="toolbar" role="group" aria-label="Diagram controls">
2778
- <div class="ribbon-primary">
2779
- <label class="page-picker">Page <select aria-label="Page"></select></label>
2780
- <div class="page-stepper"><button type="button" data-chrome="previous-page" aria-label="Previous page">‹</button><button type="button" data-chrome="next-page" aria-label="Next page">›</button></div>
2781
- <span class="spacer"></span><span class="local-label">Local workspace</span>
2782
- </div>
2783
- <div class="ribbon-tabs" role="tablist" aria-label="Ribbon">
2784
- <button type="button" id="home-tab" role="tab" aria-selected="true" aria-controls="home-panel" data-tab="home">Home</button>
2785
- <button type="button" id="view-tab" role="tab" aria-selected="false" aria-controls="view-panel" tabindex="-1" data-tab="view">View</button>
2786
- </div>
2787
- <div class="ribbon-content" id="home-panel" role="tabpanel" aria-labelledby="home-tab">
2788
- <div class="ribbon-group search-group">${searchTemplate}<span class="group-label">Find in drawing</span></div>
2789
- <div class="ribbon-group"><div class="ribbon-actions">
2790
- <button class="ribbon-command" type="button" data-chrome="selection"><span class="command-icon" data-icon="selection" aria-hidden="true"></span><span>Shape details</span></button>
2791
- <button class="ribbon-command" type="button" data-chrome="edit"><span class="command-icon" data-icon="edit" aria-hidden="true"></span><span>Edit text</span></button>
2792
- <button class="ribbon-command" type="button" data-chrome="layers"><span class="command-icon" data-icon="layers" aria-hidden="true"></span><span>Layers</span></button>
2793
- </div><span class="group-label">Inspect and edit</span></div>
2794
- <p class="ribbon-hint">Select a shape to inspect its details.<br>Text editing is experimental.</p>
2795
- </div>
2796
- <div class="ribbon-content" id="view-panel" role="tabpanel" aria-labelledby="view-tab" hidden>
2797
- <div class="ribbon-group"><div class="ribbon-actions">
2798
- <button class="ribbon-command" type="button" data-chrome="pages" aria-pressed="true" aria-controls="page-rail"><span class="command-icon" data-icon="pages" aria-hidden="true"></span><span>Pages pane</span></button>
2799
- <button class="ribbon-command" type="button" data-chrome="inspector" aria-pressed="true" aria-controls="inspector-pane"><span class="command-icon" data-icon="inspector" aria-hidden="true"></span><span>Inspector pane</span></button>
2800
- <button class="ribbon-command" type="button" data-chrome="notes"><span class="command-icon" data-icon="notes" aria-hidden="true"></span><span>Review notes</span></button>
2801
- </div><span class="group-label">Workspace panes</span></div>
2802
- <p class="ribbon-hint">Canvas shortcuts: + / − to zoom, 0 to fit.<br>Arrow keys move between focused shapes.</p>
2803
- </div>
2804
- </div>
2805
- <div class="workspace">
3113
+ /**
3114
+ * Static workspace markup (legacy; migrate to builders when next changed). The ribbon, page
3115
+ * tabs and status bar are built by `createRibbon`, `createPageTabs` and `createStatusBar`.
3116
+ * All document labels are inserted as text.
3117
+ */
3118
+ var viewerChromeTemplate = `<div class="workspace">
2806
3119
  <nav id="page-rail" class="page-rail" aria-label="Diagram pages"><div class="pane-heading"><span>Pages</span><span data-page-count>0</span></div><ol class="page-list"></ol><p class="page-empty">Open a drawing to see its pages.</p></nav>
2807
3120
  <div class="viewport" tabindex="0" role="region" aria-label="Diagram canvas"></div>
2808
3121
  <aside id="inspector-pane" class="inspector-pane" aria-label="Drawing inspector">
2809
- <div class="pane-heading"><span>Inspector</span><span class="inspector-kind">Drawing</span></div>
3122
+ <div class="pane-heading"><span>Inspector</span><span class="inspector-kind">Drawing</span><button class="pane-close" type="button" data-chrome="inspector" aria-label="Close inspector">×</button></div>
2810
3123
  <div class="inspector-body">
2811
3124
  <section class="inspector-card document-card" aria-label="Current page"><h2>Current page</h2><p class="current-page-name" data-page-name>No diagram open</p><dl><dt>Size</dt><dd data-page-size>No page</dd><dt>Shapes</dt><dd data-page-shapes>No shapes</dd></dl></section>
2812
3125
  <p class="selection-hint">Select a shape on the canvas to see its data and links.</p>
@@ -2815,24 +3128,28 @@ var viewerChromeTemplate = `
2815
3128
  <details class="notes inspector-card"><summary>Compatibility notes</summary><ul></ul></details>
2816
3129
  </div>
2817
3130
  </aside>
2818
- </div>
2819
- <div class="notes-strip"><button type="button" data-chrome="notes" aria-controls="inspector-pane" aria-expanded="false">Notes<span class="notes-count" data-note-count></span></button><span>Files stay in your browser</span></div>
2820
- <div class="status"><div class="status-message" role="status"><span data-status></span><span data-diagnostics></span></div><div class="zoom-controls" role="group" aria-label="Canvas zoom"><button type="button" data-action="fit">Fit page</button><button type="button" data-action="actual">100%</button><span class="zoom-divider"></span><button type="button" data-action="out" aria-label="Zoom out">−</button><output class="zoom" aria-label="Zoom level">100%</output><button type="button" data-action="in" aria-label="Zoom in">+</button></div></div>`;
3131
+ </div>`;
2821
3132
  /** Presentation state stays local; page navigation uses the same controller as every binding. */
2822
3133
  var ViewerChrome = class {
2823
3134
  #root;
2824
3135
  #controller;
2825
3136
  #document;
2826
3137
  #pageList;
3138
+ #pageTabs;
3139
+ #tools;
2827
3140
  #rail;
2828
3141
  #inspector;
2829
3142
  #notes;
2830
3143
  #responsive;
3144
+ #compact;
2831
3145
  #pagesManuallyToggled = false;
3146
+ #inspectorManuallyToggled = false;
2832
3147
  constructor(root, controller) {
2833
3148
  this.#root = root;
2834
3149
  this.#controller = controller;
2835
3150
  this.#pageList = root.querySelector(".page-list");
3151
+ this.#pageTabs = root.querySelector("office-ui-tab-strip");
3152
+ this.#tools = root.querySelector(".ribbon-tools");
2836
3153
  this.#rail = root.querySelector(".page-rail");
2837
3154
  this.#inspector = root.querySelector(".inspector-pane");
2838
3155
  this.#notes = root.querySelector(".notes");
@@ -2841,26 +3158,45 @@ var ViewerChrome = class {
2841
3158
  const view = this.#root.ownerDocument.defaultView;
2842
3159
  const events = new ((view?.AbortController) ?? AbortController)(), options = { signal: events.signal };
2843
3160
  this.#responsive = view?.matchMedia?.("(max-width: 980px)");
3161
+ const compact = view?.matchMedia?.("(max-width: 760px)");
3162
+ this.#compact = compact;
3163
+ const tools = this.#tools;
3164
+ const toolLayout = () => {
3165
+ tools.open = !compact?.matches;
3166
+ };
3167
+ toolLayout();
3168
+ compact?.addEventListener("change", toolLayout, options);
3169
+ this.#root.addEventListener("keydown", (event) => {
3170
+ if (event.key === "Escape" && compact?.matches && tools.open) {
3171
+ event.preventDefault();
3172
+ event.stopPropagation();
3173
+ tools.open = false;
3174
+ tools.querySelector("summary").focus();
3175
+ }
3176
+ }, {
3177
+ ...options,
3178
+ capture: true
3179
+ });
2844
3180
  const responsive = () => {
2845
3181
  if (!this.#pagesManuallyToggled) this.#setPane("pages", !this.#responsive?.matches);
3182
+ if (!this.#inspectorManuallyToggled) this.#setPane("inspector", !compact?.matches);
2846
3183
  };
2847
3184
  responsive();
2848
3185
  this.#responsive?.addEventListener("change", responsive, options);
3186
+ compact?.addEventListener("change", responsive, options);
2849
3187
  this.#root.addEventListener("click", (event) => {
2850
3188
  const button = event.target?.closest?.("button");
2851
3189
  if (!button || button.disabled) return;
2852
3190
  const tab = button.dataset.tab;
2853
- if (tab === "home" || tab === "view") this.#showTab(tab);
3191
+ if (tab === "home" || tab === "view") this.showTab(tab);
2854
3192
  if (button.dataset.pageIndex !== void 0) this.#controller.setPage(Number(button.dataset.pageIndex));
2855
3193
  const action = button.dataset.chrome;
2856
- if (action === "previous-page") this.#controller.setPage(this.#controller.state.pageIndex - 1);
2857
- if (action === "next-page") this.#controller.setPage(this.#controller.state.pageIndex + 1);
2858
- if (action === "pages") {
2859
- this.#pagesManuallyToggled = true;
2860
- this.#setPane("pages", this.#rail.hidden);
2861
- }
2862
- if (action === "inspector") this.#setPane("inspector", this.#inspector.hidden);
2863
- if (action === "notes" || action === "selection" || action === "edit" || action === "layers") this.#reveal(action);
3194
+ if (action === "inspector") this.togglePane("inspector");
3195
+ if (action === "notes") this.reveal("notes");
3196
+ }, options);
3197
+ this.#root.addEventListener("office-tab-select", (event) => {
3198
+ const id = Number(event.detail.id);
3199
+ if (Number.isSafeInteger(id)) this.#controller.setPage(id);
2864
3200
  }, options);
2865
3201
  this.#root.addEventListener("keydown", (event) => {
2866
3202
  const key = event;
@@ -2877,11 +3213,11 @@ var ViewerChrome = class {
2877
3213
  if (key.key === "ArrowUp" || key.key === "ArrowDown") return;
2878
3214
  key.preventDefault();
2879
3215
  const tab = key.key === "Home" ? "home" : key.key === "End" ? "view" : button.dataset.tab === "home" ? "view" : "home";
2880
- this.#showTab(tab);
3216
+ this.showTab(tab);
2881
3217
  this.#root.querySelector(`[data-tab="${tab}"]`).focus();
2882
3218
  } else if (button.dataset.pageIndex !== void 0) {
2883
3219
  key.preventDefault();
2884
- const pages = [...this.#pageList.querySelectorAll("button")];
3220
+ const pages = [...this.#pageList.querySelectorAll("[data-page-index]")];
2885
3221
  const current = Number(button.dataset.pageIndex);
2886
3222
  const next = key.key === "Home" ? 0 : key.key === "End" ? pages.length - 1 : Math.max(0, Math.min(pages.length - 1, current + (["ArrowRight", "ArrowDown"].includes(key.key) ? 1 : -1)));
2887
3223
  this.#controller.setPage(next);
@@ -2891,7 +3227,7 @@ var ViewerChrome = class {
2891
3227
  this.#notes.addEventListener("toggle", () => this.#syncNotes(), options);
2892
3228
  return () => events.abort();
2893
3229
  }
2894
- #showTab(tab) {
3230
+ showTab(tab) {
2895
3231
  for (const button of this.#root.querySelectorAll("[data-tab]")) {
2896
3232
  const selected = button.dataset.tab === tab;
2897
3233
  button.setAttribute("aria-selected", String(selected));
@@ -2899,21 +3235,41 @@ var ViewerChrome = class {
2899
3235
  }
2900
3236
  for (const panel of this.#root.querySelectorAll("[role=\"tabpanel\"]")) panel.hidden = panel.id !== `${tab}-panel`;
2901
3237
  }
3238
+ /** Show or hide a task pane; compact layouts close the Tools menu afterwards. */
3239
+ togglePane(pane) {
3240
+ if (this.#compact?.matches) this.#tools.open = false;
3241
+ if (pane === "pages") this.#pagesManuallyToggled = true;
3242
+ else this.#inspectorManuallyToggled = true;
3243
+ this.#setPane(pane, (pane === "pages" ? this.#rail : this.#inspector).hidden);
3244
+ }
3245
+ #command(name) {
3246
+ return this.#root.querySelector(`office-ui-button[command="${name}"]`);
3247
+ }
2902
3248
  #setPane(pane, visible) {
3249
+ if (visible && this.#compact?.matches) {
3250
+ const other = pane === "pages" ? "inspector" : "pages";
3251
+ (other === "pages" ? this.#rail : this.#inspector).hidden = true;
3252
+ this.#command(other).setAttribute("pressed", "false");
3253
+ }
2903
3254
  (pane === "pages" ? this.#rail : this.#inspector).hidden = !visible;
2904
- this.#root.querySelector(`[data-chrome="${pane}"]`).setAttribute("aria-pressed", String(visible));
3255
+ this.#command(pane).setAttribute("pressed", String(visible));
2905
3256
  this.#syncNotes();
2906
3257
  }
2907
3258
  #syncNotes() {
2908
3259
  this.#root.querySelector(".notes-strip button").setAttribute("aria-expanded", String(!this.#inspector.hidden && this.#notes.open));
2909
3260
  }
2910
- #reveal(kind) {
3261
+ /** Open an inspector disclosure; text editing may focus the text field directly (F2). */
3262
+ reveal(kind, focusText = false) {
3263
+ if (this.#compact?.matches) this.#tools.open = false;
3264
+ this.#inspectorManuallyToggled = true;
2911
3265
  this.#setPane("inspector", true);
2912
3266
  const selector = kind === "notes" ? ".notes" : kind === "edit" ? ".edit-controls" : kind === "layers" ? ".layer-controls" : ".shape-inspector";
2913
3267
  const panel = this.#root.querySelector(selector);
2914
3268
  if (panel.hidden) return;
2915
3269
  panel.open = true;
2916
- panel.querySelector("summary").focus();
3270
+ const text = panel.querySelector("textarea");
3271
+ if (focusText && text && !text.disabled) text.focus();
3272
+ else panel.querySelector("summary").focus();
2917
3273
  panel.scrollIntoView?.({
2918
3274
  block: "nearest",
2919
3275
  inline: "nearest"
@@ -2954,14 +3310,21 @@ var ViewerChrome = class {
2954
3310
  fragment.append(item);
2955
3311
  }
2956
3312
  this.#pageList.replaceChildren(fragment);
3313
+ this.#pageTabs.tabs = (state.document?.pages ?? []).map((candidate, index) => ({
3314
+ id: String(index),
3315
+ label: candidate.name,
3316
+ title: candidate.isBackground ? `${candidate.name} (background page)` : candidate.name
3317
+ }));
2957
3318
  if (restoreFocus !== void 0) [...this.#pageList.querySelectorAll("button")].find((button) => button.dataset.pageIndex === restoreFocus)?.focus();
2958
3319
  }
3320
+ this.#pageTabs.selected = page ? String(state.pageIndex) : "";
2959
3321
  for (const button of this.#pageList.querySelectorAll("button")) {
2960
3322
  const selected = Number(button.dataset.pageIndex) === state.pageIndex;
2961
3323
  if (selected) button.setAttribute("aria-current", "page");
2962
3324
  else button.removeAttribute("aria-current");
2963
3325
  button.tabIndex = selected ? 0 : -1;
2964
3326
  }
3327
+ this.#root.querySelector("[data-page-status]").setAttribute("value", page ? `Page ${state.pageIndex + 1} of ${state.document.pages.length}` : "");
2965
3328
  const count = state.document?.pages.length ?? 0;
2966
3329
  this.#root.querySelector("[data-page-count]").textContent = String(count);
2967
3330
  this.#root.querySelector(".page-empty").hidden = count > 0;
@@ -2969,17 +3332,405 @@ var ViewerChrome = class {
2969
3332
  this.#root.querySelector("[data-page-size]").textContent = page ? `${page.width} × ${page.height} in` : "No page";
2970
3333
  this.#root.querySelector("[data-page-shapes]").textContent = page ? `${page.shapes.length} top-level` : "No shapes";
2971
3334
  this.#root.querySelector(".selection-hint").hidden = !!state.selectedShape;
2972
- this.#root.querySelector("[data-chrome=\"previous-page\"]").disabled = !page || state.pageIndex === 0;
2973
- this.#root.querySelector("[data-chrome=\"next-page\"]").disabled = !page || state.pageIndex >= count - 1;
2974
- this.#root.querySelector("[data-chrome=\"selection\"]").disabled = !state.selectedShape;
2975
- this.#root.querySelector("[data-chrome=\"edit\"]").disabled = !page;
2976
- this.#root.querySelector("[data-chrome=\"layers\"]").disabled = this.#root.querySelector(".layer-controls").hidden;
2977
- for (const button of this.#root.querySelectorAll("[data-chrome=\"notes\"]")) button.disabled = noteCount === 0;
3335
+ this.#command("selection").disabled = !state.selectedShape;
3336
+ this.#command("edit").disabled = !page;
3337
+ this.#command("layers").disabled = this.#root.querySelector(".layer-controls").hidden;
3338
+ this.#command("notes").disabled = noteCount === 0;
3339
+ this.#root.querySelector(".notes-strip button").disabled = noteCount === 0;
2978
3340
  this.#root.querySelector("[data-note-count]").textContent = noteCount ? ` · ${noteCount}` : "";
2979
3341
  this.#syncNotes();
2980
3342
  }
2981
3343
  };
2982
3344
  //#endregion
3345
+ //#region src/ribbon-router.ts
3346
+ /** Routes a ribbon, status-bar or shortcut action to the controller that owns it. */
3347
+ function routeRibbonAction(targets, action) {
3348
+ const { controller } = targets;
3349
+ const page = controller.state.document?.pages[controller.state.pageIndex];
3350
+ switch (action.type) {
3351
+ case "history": return targets.history(action.key);
3352
+ case "delete": return targets.deleteSelection();
3353
+ case "tool": return targets.setTool(action.tool);
3354
+ case "grid": return targets.toggleGrid();
3355
+ case "fullscreen": return targets.toggleFullscreen();
3356
+ case "pane": return targets.togglePane(action.pane);
3357
+ case "reveal": return targets.reveal(action.panel, action.focusText ?? false);
3358
+ case "search": return targets.focusSearch();
3359
+ case "page": return controller.setPage(controller.state.pageIndex + action.step);
3360
+ case "zoom":
3361
+ if (!page) return;
3362
+ if (action.mode === "actual") return controller.setZoom(1);
3363
+ return targets.fit(action.mode === "fit" ? "page" : "width");
3364
+ }
3365
+ }
3366
+ //#endregion
3367
+ //#region src/viewer-draw-tool.ts
3368
+ /** Visio snaps new geometry to ruler subdivisions; 1/16 inch matches its default fine grid. */
3369
+ var SNAP = 1 / 16;
3370
+ var MIN_SIZE = SNAP;
3371
+ var snap = (value) => Math.round(value / SNAP) * SNAP;
3372
+ /** Next free numeric shape ID on the page tree. Core still rejects any collision. */
3373
+ function nextShapeId(page) {
3374
+ let max = 0;
3375
+ const pending = [...page.shapes];
3376
+ while (pending.length) {
3377
+ const shape = pending.pop();
3378
+ if (/^\d{1,9}$/.test(shape.id)) max = Math.max(max, Number(shape.id));
3379
+ pending.push(...shape.children);
3380
+ }
3381
+ return String(max + 1);
3382
+ }
3383
+ /** Page inches with a top-left origin (SVG user space) from a client point. */
3384
+ function pagePoint(svg, page, event) {
3385
+ const matrix = svg.getScreenCTM?.();
3386
+ if (!matrix) return void 0;
3387
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
3388
+ return {
3389
+ x: snap(Math.max(0, Math.min(page.width, point.x))),
3390
+ y: snap(Math.max(0, Math.min(page.height, point.y)))
3391
+ };
3392
+ }
3393
+ /** Drag-to-draw rectangle tool. The preview is viewer-only; the shape is created by core. */
3394
+ var RectangleDrawTool = class {
3395
+ viewport;
3396
+ controller;
3397
+ options;
3398
+ #drag;
3399
+ #request = 0;
3400
+ constructor(viewport, controller, options) {
3401
+ this.viewport = viewport;
3402
+ this.controller = controller;
3403
+ this.options = options;
3404
+ }
3405
+ get drawing() {
3406
+ return !!this.#drag;
3407
+ }
3408
+ wire() {
3409
+ const events = new ((this.viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
3410
+ const options = { signal: events.signal };
3411
+ const viewport = this.viewport;
3412
+ viewport.addEventListener("click", (event) => {
3413
+ if (this.options.active()) event.stopImmediatePropagation();
3414
+ }, {
3415
+ ...options,
3416
+ capture: true
3417
+ });
3418
+ viewport.addEventListener("pointerdown", (event) => this.#start(event), options);
3419
+ viewport.addEventListener("pointermove", (event) => this.#move(event), options);
3420
+ viewport.addEventListener("pointerup", (event) => void this.#finish(event), options);
3421
+ viewport.addEventListener("pointercancel", () => this.#cancel(), options);
3422
+ viewport.addEventListener("keydown", (event) => {
3423
+ if (event.key === "Escape" && this.#drag) {
3424
+ event.preventDefault();
3425
+ this.#cancel();
3426
+ }
3427
+ }, options);
3428
+ return () => {
3429
+ ++this.#request;
3430
+ this.#cancel();
3431
+ events.abort();
3432
+ };
3433
+ }
3434
+ #start(event) {
3435
+ if (!this.options.active() || event.button !== 0 || this.#drag) return;
3436
+ const state = this.controller.state;
3437
+ const page = state.document?.pages[state.pageIndex];
3438
+ const svg = this.viewport.querySelector("svg.paper");
3439
+ if (!page || !svg || !state.edit.sourceAvailable || state.edit.busy) return;
3440
+ const start = pagePoint(svg, page, event);
3441
+ if (!start) return;
3442
+ event.preventDefault();
3443
+ const rect = this.viewport.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "rect");
3444
+ rect.classList.add("draw-preview");
3445
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
3446
+ svg.append(rect);
3447
+ this.#drag = {
3448
+ pointer: event.pointerId,
3449
+ svg,
3450
+ page,
3451
+ ...start,
3452
+ rect
3453
+ };
3454
+ this.viewport.setPointerCapture?.(event.pointerId);
3455
+ this.#update(start);
3456
+ }
3457
+ #move(event) {
3458
+ if (!this.#drag || event.pointerId !== this.#drag.pointer) return;
3459
+ const point = pagePoint(this.#drag.svg, this.#drag.page, event);
3460
+ if (point) this.#update(point);
3461
+ }
3462
+ #update(point) {
3463
+ const { x, y, rect } = this.#drag;
3464
+ rect.setAttribute("x", String(Math.min(x, point.x)));
3465
+ rect.setAttribute("y", String(Math.min(y, point.y)));
3466
+ rect.setAttribute("width", String(Math.abs(point.x - x)));
3467
+ rect.setAttribute("height", String(Math.abs(point.y - y)));
3468
+ }
3469
+ #cancel() {
3470
+ this.#drag?.rect.remove();
3471
+ this.#drag = void 0;
3472
+ }
3473
+ async #finish(event) {
3474
+ const drag = this.#drag;
3475
+ if (!drag || event.pointerId !== drag.pointer) return;
3476
+ const end = pagePoint(drag.svg, drag.page, event) ?? drag;
3477
+ this.#cancel();
3478
+ const width = Math.abs(end.x - drag.x), height = Math.abs(end.y - drag.y);
3479
+ if (width < MIN_SIZE || height < MIN_SIZE) {
3480
+ this.options.announce("Drag on the page to draw a rectangle.");
3481
+ return;
3482
+ }
3483
+ const state = this.controller.state;
3484
+ if (state.document?.pages[state.pageIndex] !== drag.page) return;
3485
+ const shapeId = nextShapeId(drag.page);
3486
+ const pageId = drag.page.id;
3487
+ const request = ++this.#request;
3488
+ try {
3489
+ await this.controller.applyEdits([{
3490
+ type: "create-rectangle",
3491
+ pageId,
3492
+ shapeId,
3493
+ x: (drag.x + end.x) / 2,
3494
+ y: drag.page.height - (drag.y + end.y) / 2,
3495
+ width,
3496
+ height
3497
+ }]);
3498
+ if (request !== this.#request) return;
3499
+ const created = this.controller.state.document?.pages.find((page) => page.id === pageId)?.shapes.find((shape) => shape.id === shapeId);
3500
+ if (created) this.controller.selectShape({
3501
+ id: shapeId,
3502
+ name: created.name,
3503
+ pageId
3504
+ });
3505
+ this.options.announce(`Rectangle ${shapeId} added (${+width.toFixed(4)} × ${+height.toFixed(4)} in).`);
3506
+ } catch (error) {
3507
+ if (request === this.#request && !isEditCancellation(error)) this.options.announce(editErrorMessage(error));
3508
+ }
3509
+ }
3510
+ };
3511
+ //#endregion
3512
+ //#region src/viewer-commands.ts
3513
+ var editable = (target) => target instanceof Element && !!target.closest("input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])");
3514
+ /**
3515
+ * Visio command state (tool, grid, pending edits) and keyboard shortcuts. Ribbon controls emit
3516
+ * typed `ribbon-action` events and shortcuts build the same actions; both go through
3517
+ * `routeRibbonAction`. Document mutation stays in the controller and core.
3518
+ */
3519
+ var ViewerCommands = class {
3520
+ host;
3521
+ #tool = "pointer";
3522
+ #grid = false;
3523
+ #pending = 0;
3524
+ #draw;
3525
+ #targets;
3526
+ constructor(host) {
3527
+ this.host = host;
3528
+ this.#draw = new RectangleDrawTool(host.viewport, host.controller, {
3529
+ active: () => this.#tool === "rectangle",
3530
+ announce: host.announce
3531
+ });
3532
+ this.#targets = {
3533
+ controller: host.controller,
3534
+ history: (key) => this.#history(key),
3535
+ deleteSelection: () => this.#delete(),
3536
+ setTool: (tool) => this.setTool(tool),
3537
+ toggleGrid: () => {
3538
+ this.#grid = !this.#grid;
3539
+ this.render(host.controller.state);
3540
+ },
3541
+ toggleFullscreen: () => this.#toggleFullscreen(),
3542
+ togglePane: host.togglePane,
3543
+ reveal: host.reveal,
3544
+ fit: host.fit,
3545
+ focusSearch: host.focusSearch
3546
+ };
3547
+ }
3548
+ get tool() {
3549
+ return this.#tool;
3550
+ }
3551
+ /** Run one typed action, exactly as a ribbon control or shortcut would. */
3552
+ run(action) {
3553
+ routeRibbonAction(this.#targets, action);
3554
+ }
3555
+ wire() {
3556
+ const { root, viewport } = this.host;
3557
+ const doc = root.ownerDocument;
3558
+ const events = new ((doc.defaultView?.AbortController) ?? AbortController)();
3559
+ const options = { signal: events.signal };
3560
+ root.addEventListener(RIBBON_ACTION_EVENT, (event) => this.run(event.detail), options);
3561
+ root.addEventListener("office-command", (event) => {
3562
+ if (event.detail?.command === "zoom-fit") this.run({
3563
+ type: "zoom",
3564
+ mode: "fit"
3565
+ });
3566
+ }, options);
3567
+ root.addEventListener("keydown", (event) => this.#shortcut(event), options);
3568
+ viewport.addEventListener("wheel", (event) => {
3569
+ if (!event.ctrlKey && !event.metaKey) return;
3570
+ event.preventDefault();
3571
+ const zoom = this.host.controller.state.zoom;
3572
+ this.host.controller.setZoom(event.deltaY < 0 ? zoom * 1.1 : zoom / 1.1);
3573
+ }, {
3574
+ ...options,
3575
+ passive: false
3576
+ });
3577
+ viewport.addEventListener("pointerdown", () => {
3578
+ if (!viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3579
+ }, options);
3580
+ viewport.addEventListener("dblclick", (event) => {
3581
+ if (this.#tool !== "pointer") return;
3582
+ if (event.target?.closest?.("[data-shape-id]")) this.run({
3583
+ type: "reveal",
3584
+ panel: "edit",
3585
+ focusText: true
3586
+ });
3587
+ }, options);
3588
+ doc.addEventListener("fullscreenchange", () => this.render(this.host.controller.state), options);
3589
+ const disposeDraw = this.#draw.wire();
3590
+ return () => {
3591
+ ++this.#pending;
3592
+ events.abort();
3593
+ disposeDraw();
3594
+ };
3595
+ }
3596
+ setTool(tool) {
3597
+ if (tool === "rectangle" && !this.#canEdit(this.host.controller.state)) return;
3598
+ this.#tool = tool;
3599
+ this.render(this.host.controller.state);
3600
+ }
3601
+ #canEdit(state) {
3602
+ return state.edit.sourceAvailable && !state.loading && !state.edit.busy;
3603
+ }
3604
+ #history(key) {
3605
+ const { controller } = this.host;
3606
+ const { edit, loading } = controller.state;
3607
+ if ((key === "undo" ? edit.canUndo : edit.canRedo) && !edit.busy && !loading) this.#edit(() => controller[key]());
3608
+ }
3609
+ #delete() {
3610
+ const state = this.host.controller.state;
3611
+ const shape = state.selectedShape;
3612
+ const pageId = shape?.pageId ?? state.document?.pages[state.pageIndex]?.id;
3613
+ if (!shape || pageId === void 0 || !this.#canEdit(state)) return;
3614
+ this.#edit(() => this.host.controller.applyEdits([{
3615
+ type: "delete-shape",
3616
+ pageId,
3617
+ shapeId: shape.id
3618
+ }]), `Deleted ${shape.name || `shape ${shape.id}`}.`);
3619
+ }
3620
+ async #edit(action, success) {
3621
+ const request = ++this.#pending;
3622
+ const { root, viewport } = this.host;
3623
+ const canvasFocused = viewport.contains(root.activeElement);
3624
+ try {
3625
+ await action();
3626
+ if (canvasFocused && !viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3627
+ if (success && request === this.#pending) this.host.announce(success);
3628
+ } catch (error) {
3629
+ if (request === this.#pending && !isEditCancellation(error) && !this.host.controller.state.edit.error) this.host.announce(editErrorMessage(error));
3630
+ }
3631
+ }
3632
+ #toggleFullscreen() {
3633
+ const host = this.host.root.host;
3634
+ const doc = this.host.root.ownerDocument;
3635
+ (doc.fullscreenElement === host ? doc.exitFullscreen?.() : host.requestFullscreen?.())?.catch?.(() => this.host.announce("Full screen is not available in this browser frame."));
3636
+ }
3637
+ /** Visio shortcuts as typed actions. Text fields keep native undo, deletion and editing keys. */
3638
+ #shortcutAction(event) {
3639
+ const state = this.host.controller.state;
3640
+ const control = event.ctrlKey || event.metaKey;
3641
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
3642
+ if (control && key === "f") return { type: "search" };
3643
+ if (control && event.shiftKey && key === "w") return {
3644
+ type: "zoom",
3645
+ mode: "fit"
3646
+ };
3647
+ if (control && (key === "PageDown" || key === "PageUp")) return {
3648
+ type: "page",
3649
+ step: key === "PageDown" ? 1 : -1
3650
+ };
3651
+ if (key === "F5" && !control) return { type: "fullscreen" };
3652
+ if (editable(event.target)) return void 0;
3653
+ if (control && !event.shiftKey && key === "z") return {
3654
+ type: "history",
3655
+ key: "undo"
3656
+ };
3657
+ if (control && (key === "y" || event.shiftKey && key === "z")) return {
3658
+ type: "history",
3659
+ key: "redo"
3660
+ };
3661
+ if (control && key === "1") return {
3662
+ type: "tool",
3663
+ tool: "pointer"
3664
+ };
3665
+ if (control && key === "8") return {
3666
+ type: "tool",
3667
+ tool: "rectangle"
3668
+ };
3669
+ if (!control && key === "Delete" && state.selectedShape) return { type: "delete" };
3670
+ if (!control && key === "F2" && state.document) return {
3671
+ type: "reveal",
3672
+ panel: "edit",
3673
+ focusText: true
3674
+ };
3675
+ if (key === "Escape" && this.#tool !== "pointer" && !this.#draw.drawing) return {
3676
+ type: "tool",
3677
+ tool: "pointer"
3678
+ };
3679
+ }
3680
+ #shortcut(event) {
3681
+ if (event.defaultPrevented || event.isComposing || event.altKey) return;
3682
+ const action = this.#shortcutAction(event);
3683
+ if (!action) return;
3684
+ event.preventDefault();
3685
+ this.run(action);
3686
+ }
3687
+ render(state) {
3688
+ const { root, viewport } = this.host;
3689
+ const button = (name) => root.querySelector(`office-ui-button[command="${name}"]`);
3690
+ const editing = this.#canEdit(state);
3691
+ if (this.#tool === "rectangle" && !state.edit.sourceAvailable) this.#tool = "pointer";
3692
+ const page = state.document?.pages[state.pageIndex];
3693
+ button("undo").disabled = !state.edit.canUndo || state.edit.busy || state.loading;
3694
+ button("redo").disabled = !state.edit.canRedo || state.edit.busy || state.loading;
3695
+ button("delete").disabled = !editing || !state.selectedShape;
3696
+ button("pointer").setAttribute("pressed", String(this.#tool === "pointer"));
3697
+ const rectangle = button("rectangle");
3698
+ rectangle.setAttribute("pressed", String(this.#tool === "rectangle"));
3699
+ rectangle.disabled = !editing || !page;
3700
+ rectangle.title = state.edit.sourceAvailable ? "Rectangle (Ctrl+8)" : "Rectangle (Ctrl+8): open a .vsdx file to draw. Model-only documents are read only.";
3701
+ button("grid").setAttribute("pressed", String(this.#grid));
3702
+ button("grid").disabled = !page;
3703
+ for (const name of [
3704
+ "zoom-fit",
3705
+ "page-width",
3706
+ "actual-size"
3707
+ ]) button(name).disabled = !page;
3708
+ const fullscreen = button("fullscreen");
3709
+ const host = root.host;
3710
+ fullscreen.disabled = typeof host.requestFullscreen !== "function";
3711
+ fullscreen.setAttribute("pressed", String(root.ownerDocument.fullscreenElement === host));
3712
+ viewport.dataset.tool = this.#tool;
3713
+ viewport.dataset.grid = String(this.#grid);
3714
+ }
3715
+ };
3716
+ //#endregion
3717
+ //#region src/office-ui.ts
3718
+ /**
3719
+ * Visio-only glyphs (20x20 path data). Neutral Office glyphs come from ooxml-ui; add a glyph
3720
+ * there instead when another Office product could use it.
3721
+ */
3722
+ var VISIO_ICONS = {
3723
+ visioLayers: "m2.5 7 7.5-4 7.5 4-7.5 4ZM2.5 10.5l7.5 4 7.5-4M2.5 14l7.5 4 7.5-4",
3724
+ visioShapeData: "M3 3.5h14v13H3ZM3 7.5h14M8 7.5v9M10.5 11h4M10.5 14h4",
3725
+ visioPagesPane: "M2.5 3h15v14h-15ZM7 3v14M3.8 6h1.9M3.8 9h1.9M3.8 12h1.9",
3726
+ visioInspectorPane: "M2.5 3h15v14h-15ZM13 3v14M14.5 6h1.5M14.5 9h1.5M14.5 12h1.5"
3727
+ };
3728
+ /** Define the shared Office controls the viewer chrome uses. Idempotent; browser only. */
3729
+ function registerViewerControls(registry) {
3730
+ for (const [name, path] of Object.entries(VISIO_ICONS)) if (!getIcon(name)) registerIcon(name, path);
3731
+ registerControls(registry);
3732
+ }
3733
+ //#endregion
2983
3734
  //#region src/export-svg.ts
2984
3735
  var MAX_SVG_EXPORT_BYTES = 16777216;
2985
3736
  var APPROXIMATION = "Static SVG export is a rendered approximation, not an editable VSDX round-trip or a guarantee of Microsoft Visio fidelity. Fonts are not embedded.";
@@ -3590,8 +4341,10 @@ var VisioViewerElement = class extends BaseElement {
3590
4341
  #root;
3591
4342
  #disposeInputs;
3592
4343
  #viewport;
3593
- #pageSelect;
3594
- #zoomLabel;
4344
+ #zoomSlider;
4345
+ #shapeStatus;
4346
+ #announcement;
4347
+ #commands;
3595
4348
  #status;
3596
4349
  #diagnostics;
3597
4350
  #toolbar;
@@ -3624,10 +4377,14 @@ var VisioViewerElement = class extends BaseElement {
3624
4377
  constructor() {
3625
4378
  super();
3626
4379
  this.#root = this.attachShadow({ mode: "open" });
3627
- this.#root.innerHTML = `<style>${viewerStyles}</style>${viewerChromeTemplate}`;
4380
+ registerViewerControls();
4381
+ this.#root.innerHTML = `<style>${viewerStyles}${canvasAndRibbonStyles}</style>${viewerChromeTemplate}`;
4382
+ const workspace = this.#root.querySelector(".workspace");
4383
+ workspace.before(createRibbon(document));
4384
+ workspace.after(createPageTabs(document), createStatusBar(document));
3628
4385
  this.#viewport = this.#root.querySelector(".viewport");
3629
- this.#pageSelect = this.#root.querySelector("select");
3630
- this.#zoomLabel = this.#root.querySelector("output");
4386
+ this.#zoomSlider = this.#root.querySelector("office-ui-zoom-slider");
4387
+ this.#shapeStatus = this.#root.querySelector("[data-shape-status]");
3631
4388
  this.#status = this.#root.querySelector("[data-status]");
3632
4389
  this.#diagnostics = this.#root.querySelector("[data-diagnostics]");
3633
4390
  this.#toolbar = this.#root.querySelector(".toolbar");
@@ -3638,6 +4395,23 @@ var VisioViewerElement = class extends BaseElement {
3638
4395
  this.#notesPanel = this.#root.querySelector(".notes");
3639
4396
  this.#inspector = this.#root.querySelector(".shape-inspector");
3640
4397
  this.#chrome = new ViewerChrome(this.#root, this.controller);
4398
+ this.#commands = new ViewerCommands({
4399
+ root: this.#root,
4400
+ viewport: this.#viewport,
4401
+ controller: this.controller,
4402
+ fit: (mode) => this.#fit(mode),
4403
+ togglePane: (pane) => this.#chrome.togglePane(pane),
4404
+ reveal: (panel, focusText) => this.#chrome.reveal(panel, focusText),
4405
+ focusSearch: () => {
4406
+ this.#chrome.showTab("home");
4407
+ this.#search.input.focus();
4408
+ this.#search.input.select();
4409
+ },
4410
+ announce: (message) => {
4411
+ this.#announcement = message;
4412
+ this.#status.textContent = message;
4413
+ }
4414
+ });
3641
4415
  this.#disposeInputs = this.#wireInputs();
3642
4416
  this.#unsubscribe = this.controller.subscribe((state) => this.#render(state));
3643
4417
  this.#eventUnsubscribe = this.controller.onEvent((name, detail) => {
@@ -3711,13 +4485,24 @@ var VisioViewerElement = class extends BaseElement {
3711
4485
  }
3712
4486
  fit() {
3713
4487
  this.#assertAlive();
4488
+ this.#fit("page");
4489
+ }
4490
+ /** Visio Fit to Window shows the whole page; Page Width fills the canvas width. */
4491
+ #fit(mode) {
3714
4492
  const page = this.document?.pages[this.pageIndex];
3715
4493
  if (!page) return;
3716
4494
  const style = this.ownerDocument.defaultView?.getComputedStyle(this.#viewport);
3717
4495
  const padding = (value) => Number.parseFloat(value ?? "") || 0;
3718
4496
  const width = Math.max(1, this.#viewport.clientWidth - padding(style?.paddingLeft) - padding(style?.paddingRight));
3719
4497
  const height = Math.max(1, this.#viewport.clientHeight - padding(style?.paddingTop) - padding(style?.paddingBottom));
3720
- this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4498
+ if (mode === "page") {
4499
+ this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4500
+ return;
4501
+ }
4502
+ const scrollbar = this.#viewport.offsetWidth - this.#viewport.clientWidth;
4503
+ const fitted = width / (page.width * 96);
4504
+ const reserve = page.height * 96 * fitted > height && scrollbar <= 0 ? 17 : 0;
4505
+ this.zoom = Math.max(1, width - reserve - 1) / (page.width * 96);
3721
4506
  }
3722
4507
  setLayerVisibility(pageId, layerId, visible) {
3723
4508
  this.#assertAlive();
@@ -3787,16 +4572,18 @@ var VisioViewerElement = class extends BaseElement {
3787
4572
  }
3788
4573
  #wireInputs() {
3789
4574
  const disposeChrome = this.#chrome.wire();
4575
+ const disposeCommands = this.#commands.wire();
3790
4576
  const disposeEdit = this.#edit.wire();
3791
4577
  const disposeLayers = wireLayerControls(this.#layers, this.controller);
3792
4578
  const disposeInputs = wireViewerInputs({
3793
4579
  viewport: this.#viewport,
3794
4580
  commandRoot: this.#root,
3795
- pageSelect: this.#pageSelect,
4581
+ zoomSlider: this.#zoomSlider,
3796
4582
  searchInput: this.#search.input
3797
- }, this.controller, () => this.fit());
4583
+ }, this.controller, (mode) => this.#fit(mode));
3798
4584
  return () => {
3799
4585
  disposeChrome();
4586
+ disposeCommands();
3800
4587
  disposeInputs();
3801
4588
  disposeLayers();
3802
4589
  disposeEdit();
@@ -3815,14 +4602,7 @@ var VisioViewerElement = class extends BaseElement {
3815
4602
  this.#document = state.document;
3816
4603
  this.#renderedPage = state.pageIndex;
3817
4604
  this.#renderedLayerOverrides = state.layerVisibilityOverrides;
3818
- this.#pageSelect.replaceChildren();
3819
- for (const [index, candidate] of (state.document?.pages ?? []).entries()) {
3820
- const option = document.createElement("option");
3821
- option.value = String(index);
3822
- option.textContent = candidate.name + (candidate.isBackground ? " (background)" : "");
3823
- this.#pageSelect.append(option);
3824
- }
3825
- this.#pageSelect.value = String(state.pageIndex);
4605
+ this.#announcement = void 0;
3826
4606
  this.#renderWarnings = [];
3827
4607
  if (state.document && page) {
3828
4608
  const result = renderPage(state.document, page, { layerVisibilityOverrides: state.layerVisibilityOverrides });
@@ -3849,6 +4629,7 @@ var VisioViewerElement = class extends BaseElement {
3849
4629
  if (svg && page) {
3850
4630
  svg.style.width = `${page.width * 96 * state.zoom}px`;
3851
4631
  svg.style.height = `${page.height * 96 * state.zoom}px`;
4632
+ svg.style.setProperty("--vv-inch", `${96 * state.zoom}px`);
3852
4633
  }
3853
4634
  const searchResult = state.search.results[state.search.activeIndex];
3854
4635
  const selection = state.selectedShape;
@@ -3876,16 +4657,20 @@ var VisioViewerElement = class extends BaseElement {
3876
4657
  this.#renderedSelection = selection ? { ...selection } : null;
3877
4658
  }
3878
4659
  if (!searchResult) this.#revealedSearchResult = void 0;
3879
- this.#zoomLabel.value = `${Math.round(state.zoom * 100)}%`;
3880
- this.#pageSelect.disabled = !page;
4660
+ this.#zoomSlider.value = Math.round(state.zoom * 100);
4661
+ this.#zoomSlider.disabled = !page;
4662
+ const inspected = selectedShape(state.document, state.selectedShape, state.pageIndex);
4663
+ const inches = (value) => `${+value.toFixed(3)} in`;
4664
+ this.#shapeStatus.setAttribute("value", inspected ? `Width: ${inches(inspected.width)} Height: ${inches(inspected.height)}` : "");
3881
4665
  for (const button of this.#root.querySelectorAll("[data-action]")) button.disabled = !page;
3882
4666
  renderSearchControls(this.#search, state);
3883
4667
  this.#edit.render(state);
3884
4668
  if (changed) renderLayerControls(this.#layers, state);
3885
4669
  else this.#layers.querySelector("[data-layer-reset=\"all\"]").disabled = state.layerVisibilityOverrides.length === 0;
3886
4670
  this.#chrome.render(state, this.#notes.children.length);
4671
+ this.#commands.render(state);
3887
4672
  this.#viewport.setAttribute("aria-busy", String(state.loading || state.edit.busy));
3888
- this.#status.textContent = state.loading ? "Opening diagram…" : state.error?.message ?? (page ? `${page.name} · ${page.shapes.length} top-level shapes` : "No diagram open");
4673
+ this.#status.textContent = state.loading ? "Opening diagram…" : state.edit.busy ? "Updating diagram…" : state.error?.message ?? (state.edit.error ? `Edit rejected: ${editErrorMessage(state.edit.error)}` : void 0) ?? this.#announcement ?? (page ? `${page.name} · ${page.shapes.length} top-level shapes` : "No diagram open");
3889
4674
  const warnings = (state.document?.diagnostics.length ?? 0) + this.#renderWarnings.length;
3890
4675
  this.#diagnostics.textContent = warnings ? `${this.#notes.children.length} compatibility notes` : "Local-only viewing";
3891
4676
  }
@@ -4095,6 +4880,23 @@ function viewerHandle(current) {
4095
4880
  }
4096
4881
  };
4097
4882
  }
4883
+ function viewerStateSource(current) {
4884
+ const controller = () => {
4885
+ try {
4886
+ return current()?.controller;
4887
+ } catch {
4888
+ return;
4889
+ }
4890
+ };
4891
+ return {
4892
+ subscribe(onChange) {
4893
+ return controller()?.subscribe(() => onChange()) ?? (() => {});
4894
+ },
4895
+ getSnapshot() {
4896
+ return controller()?.state ?? null;
4897
+ }
4898
+ };
4899
+ }
4098
4900
  //#endregion
4099
4901
  //#region packages/bindings/src/solid.tsx
4100
4902
  var _tmpl$ = [
@@ -4138,5 +4940,19 @@ function VisioViewer(props) {
4138
4940
  });
4139
4941
  return ssr(_tmpl$, ssrHydrationKey() + ssrAttribute("class", escape(props.class, true), false), ssrStyle(props.style));
4140
4942
  }
4943
+ /**
4944
+ * Reactive viewer state as a Solid accessor for a handle accessor (for example a signal set by
4945
+ * `viewerRef`). Follows handle changes and cleans up with the owning scope.
4946
+ */
4947
+ function createVisioViewerState(handle) {
4948
+ const [state, setState] = createSignal(null);
4949
+ createEffect(() => {
4950
+ const current = handle();
4951
+ const source = viewerStateSource(() => current);
4952
+ setState(() => source.getSnapshot());
4953
+ onCleanup(source.subscribe(() => setState(() => source.getSnapshot())));
4954
+ });
4955
+ return state;
4956
+ }
4141
4957
  //#endregion
4142
- export { MAX_SVG_EXPORT_BYTES, PRINT_SNAPSHOT_LIMITS, TEXT_SEARCH_LIMITS, VIEWER_LAYER_LIMITS, ViewerController, VisioViewer, VisioViewerElement, compatibilityNotes, compatibilityText, createPrintSnapshot, createWorkerParser, eventKeys, exportPageSvg, mountViewer, propertyKeys, registerVisioViewer, renderPage };
4958
+ export { MAX_SVG_EXPORT_BYTES, PRINT_SNAPSHOT_LIMITS, TEXT_SEARCH_LIMITS, VIEWER_LAYER_LIMITS, ViewerController, VisioViewer, VisioViewerElement, compatibilityNotes, compatibilityText, createPrintSnapshot, createVisioViewerState, createWorkerParser, eventKeys, exportPageSvg, mountViewer, propertyKeys, registerVisioViewer, renderPage };