visio-solid-viewer 0.1.1 → 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.
package/dist/index.js CHANGED
@@ -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 { className, effect, style, template, use } from "solid-js/web";
3
5
  import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
6
  export * from "visio-core";
@@ -533,7 +535,7 @@ function createWorkerEditor(factory, timeoutMs = 2e4) {
533
535
  if (typeof Worker === "undefined") throw new Error("Editing requires browser worker support.");
534
536
  return new Worker(new URL(
535
537
  /* @vite-ignore */
536
- new URL("assets/edit-worker-DRgXgRud.js", import.meta.url).href,
538
+ new URL("assets/edit-worker-BcXrV_St.js", import.meta.url).href,
537
539
  "" + import.meta.url
538
540
  ), { type: "module" });
539
541
  });
@@ -2144,7 +2146,7 @@ function selection(target) {
2144
2146
  }
2145
2147
  /** Own every DOM listener and abort them together when the surface is disposed. */
2146
2148
  function wireViewerInputs(controls, controller, fit) {
2147
- const { viewport, commandRoot, pageSelect, searchInput } = controls;
2149
+ const { viewport, commandRoot, zoomSlider, searchInput } = controls;
2148
2150
  const events = new ((viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
2149
2151
  const options = { signal: events.signal };
2150
2152
  searchInput.addEventListener("input", () => controller.setSearchQuery(searchInput.value), options);
@@ -2160,13 +2162,9 @@ function wireViewerInputs(controls, controller, fit) {
2160
2162
  controller.setSearchQuery("");
2161
2163
  }
2162
2164
  }, options);
2163
- pageSelect.addEventListener("change", () => controller.setPage(Number(pageSelect.value)), options);
2165
+ zoomSlider.addEventListener("input", () => controller.setZoom(zoomSlider.value / 100), options);
2164
2166
  commandRoot.addEventListener("click", (event) => {
2165
2167
  const action = event.target?.closest?.("button")?.dataset.action;
2166
- if (action === "in") controller.setZoom(controller.state.zoom * 1.25);
2167
- if (action === "out") controller.setZoom(controller.state.zoom / 1.25);
2168
- if (action === "fit") fit();
2169
- if (action === "actual") controller.setZoom(1);
2170
2168
  if (action === "search-next") controller.nextSearchResult();
2171
2169
  if (action === "search-previous") controller.previousSearchResult();
2172
2170
  }, options);
@@ -2205,7 +2203,7 @@ function wireViewerInputs(controls, controller, fit) {
2205
2203
  }
2206
2204
  if (event.key === "0") {
2207
2205
  event.preventDefault();
2208
- fit();
2206
+ fit("page");
2209
2207
  }
2210
2208
  }, options);
2211
2209
  return () => events.abort();
@@ -2309,13 +2307,7 @@ button:disabled { opacity:.4; cursor:default; }
2309
2307
  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; }
2310
2308
  button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-accent-soft); }
2311
2309
  .toolbar { flex:none; min-width:0; border-bottom:1px solid var(--_vv-border); background:var(--_vv-surface); }
2312
- .ribbon-primary { height:32px; display:flex; align-items:center; gap:6px; padding:2px 8px; }
2313
- .page-picker { display:flex; min-width:0; align-items:center; gap:8px; color:var(--_vv-muted); font-size:11px; }
2314
- .page-picker select { width:190px; max-width:42vw; height:26px; min-height:26px; padding:2px 6px; font-size:11px; color:var(--_vv-ink); }
2315
- .page-stepper { display:flex; align-items:center; gap:2px; }
2316
- .page-stepper button { width:26px; height:26px; min-height:26px; padding:0; font-size:18px; line-height:22px; background:transparent; border-color:transparent; }
2317
2310
  .spacer { flex:1; }
2318
- .local-label { color:var(--_vv-muted); font-size:10px; white-space:nowrap; }
2319
2311
  .ribbon-tabs { height:35px; display:flex; align-items:stretch; padding:0 4px; border-bottom:1px solid var(--_vv-border); }
2320
2312
  .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; }
2321
2313
  .ribbon-tabs button[aria-selected="true"] { color:var(--_vv-accent); }
@@ -2325,15 +2317,6 @@ button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-acce
2325
2317
  .group-label { font-size:10px; line-height:12px; color:var(--_vv-muted); text-align:center; }
2326
2318
  .ribbon-actions { display:flex; align-items:stretch; gap:2px; flex:1; }
2327
2319
  .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; }
2328
- .command-icon { display:grid; place-items:center; width:26px; height:26px; color:var(--_vv-accent); }
2329
- .command-icon::before { content:""; width:24px; height:24px; background:currentColor; mask:var(--command-symbol) center / contain no-repeat; }
2330
- [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"); }
2331
- [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"); }
2332
- [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"); }
2333
- [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"); }
2334
- [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"); }
2335
- [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"); }
2336
- .ribbon-command[data-chrome="edit"] .command-icon { font-family:ui-serif,Georgia,serif; text-decoration:underline; text-decoration-thickness:1px; text-underline-offset:3px; }
2337
2320
  .ribbon-hint { align-self:center; flex:none; margin:0; padding:0 16px; font-size:11px; line-height:18px; color:var(--_vv-muted); }
2338
2321
  .search-group { width:330px; }
2339
2322
  .search-controls { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
@@ -2405,11 +2388,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2405
2388
  .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; }
2406
2389
  .status-message { display:flex; gap:16px; min-width:0; }
2407
2390
  .status-message span { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
2408
- .zoom-controls { display:flex; flex:none; align-items:center; gap:3px; }
2409
- .zoom-controls button { min-height:24px; height:24px; padding:1px 6px; border-color:transparent; background:transparent; font-size:10px; }
2410
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:24px; padding:0; font-size:17px; line-height:20px; }
2411
- .zoom-divider { width:1px; height:14px; background:var(--_vv-border); margin:0 3px; }
2412
- .zoom { min-width:40px; font-size:10px; text-align:center; font-variant-numeric:tabular-nums; }
2413
2391
  .empty { max-width:360px; margin:60px auto; padding:16px; text-align:center; color:var(--_vv-muted); font-size:12px; }
2414
2392
  .empty strong { display:block; color:var(--_vv-ink); font-size:18px; font-weight:500; margin-bottom:8px; }
2415
2393
  @media(max-width:1100px) { .ribbon-hint { display:none; } [data-diagnostics] { display:none; } }
@@ -2417,11 +2395,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2417
2395
  button,select,input,textarea { min-height:44px; }
2418
2396
  button { min-width:44px; }
2419
2397
  summary { min-height:44px; line-height:44px; }
2420
- .ribbon-primary { height:52px; padding:4px 8px; }
2421
- .page-picker { flex:1; }
2422
- .page-picker select { width:100%; max-width:none; min-height:44px; height:44px; font-size:12px; }
2423
- .page-stepper button { width:44px; min-height:44px; height:44px; }
2424
- .local-label,.ribbon-primary>.spacer { display:none; }
2425
2398
  .ribbon-tabs { height:44px; }
2426
2399
  .ribbon-tabs button { min-height:44px; padding:8px 16px; }
2427
2400
  .ribbon-content { min-height:86px; }
@@ -2433,7 +2406,7 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2433
2406
  .search-controls button { min-width:44px; font-size:0; padding:3px; }
2434
2407
  .search-controls [data-action="search-previous"]::before { content:"‹"; font-size:22px; }
2435
2408
  .search-controls [data-action="search-next"]::before { content:"›"; font-size:22px; }
2436
- #home-panel>.ribbon-group:not(.search-group) { display:none; }
2409
+ #home-panel>.ribbon-group:not(.search-group):not(.undo-group) { display:none; }
2437
2410
  .ribbon-command { min-width:90px; }
2438
2411
  .workspace { display:block; overflow:auto; }
2439
2412
  .page-rail { width:100%; max-height:190px; border-right:0; border-bottom:1px solid var(--_vv-border); }
@@ -2450,35 +2423,22 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2450
2423
  .notes-strip button { min-height:44px; }
2451
2424
  .status { flex-wrap:wrap; gap:0; padding:4px 8px; }
2452
2425
  .status-message { width:100%; min-height:18px; }
2453
- .zoom-controls { justify-content:flex-end; width:100%; }
2454
- .zoom-controls button { min-height:44px; height:44px; min-width:44px; font-size:11px; }
2455
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; font-size:20px; }
2456
- .zoom { font-size:11px; }
2457
2426
  }
2458
2427
  @media(pointer:coarse) {
2459
2428
  button,select,input,textarea { min-height:44px; }
2460
2429
  button { min-width:44px; }
2461
2430
  summary,.layer-controls label { min-height:44px; line-height:44px; }
2462
- .ribbon-primary { min-height:52px; height:52px; }
2463
- .page-picker select,.page-stepper button { min-height:44px; height:44px; }
2464
- .page-stepper button { min-width:44px; }
2465
2431
  .ribbon-tabs { min-height:44px; height:44px; }
2466
2432
  .ribbon-tabs button { min-height:44px; }
2467
2433
  .ribbon-content { min-height:96px; }
2468
2434
  .search-group { width:380px; max-width:100%; }
2469
- .zoom-controls button,.notes-strip button { min-height:44px; height:44px; }
2470
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; }
2435
+ .notes-strip button { min-height:44px; height:44px; }
2471
2436
  .layer-controls input { min-height:20px; }
2472
2437
  }
2473
- /* Suite ribbon: tabs lead directly into command groups, with page navigation inside. */
2438
+ /* Suite ribbon: tabs lead directly into Visio command groups. */
2474
2439
  .ribbon-tabs { height:40px; }
2475
2440
  .ribbon-tabs button { min-height:39px; padding:8px 16px; }
2476
2441
  .ribbon-content { min-height:104px; padding:4px 4px; }
2477
- .ribbon-primary { height:auto; min-height:92px; width:238px; flex:none; flex-wrap:wrap; justify-content:center; gap:2px; padding:10px 12px 18px; border-right:1px solid var(--_vv-border); position:relative; order:1; }
2478
- .ribbon-primary .page-picker { flex-direction:column; align-items:start; gap:5px; }
2479
- .ribbon-primary .page-picker select { width:160px; max-width:100%; }
2480
- .ribbon-primary .spacer { display:none; }
2481
- .ribbon-primary .local-label { position:absolute; bottom:4px; left:0; width:100%; text-align:center; }
2482
2442
  .search-group { order:2; width:320px; }
2483
2443
  .ribbon-hint { order:3; padding:10px 18px; }
2484
2444
  .ribbon-command { min-width:82px; }
@@ -2487,13 +2447,9 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2487
2447
  .ribbon-tabs { height:44px; }
2488
2448
  .ribbon-tabs button { min-height:44px; }
2489
2449
  .ribbon-content { min-height:54px; padding:4px 0; }
2490
- .ribbon-primary { min-height:44px; height:44px; width:100%; padding:0 8px; border:0; flex-wrap:nowrap; gap:4px; order:0; }
2491
- .ribbon-primary .page-picker { flex-direction:row; flex:1; align-items:center; gap:8px; }
2492
- .ribbon-primary .page-picker select { flex:1; width:100%; min-width:0; }
2493
2450
  .search-group { flex:0 0 100%; width:100%; min-height:54px; }
2494
2451
  .search-feedback,.group-label { display:none; }
2495
2452
  .ribbon-content { overflow-x:auto; scroll-snap-type:x mandatory; }
2496
- .ribbon-primary,.search-group { scroll-snap-align:start; }
2497
2453
  .workspace { display:flex; position:relative; overflow:hidden; }
2498
2454
  .viewport { flex:1; width:100%; height:100%; min-height:0; padding:16px 4px; }
2499
2455
  .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; }
@@ -2522,9 +2478,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2522
2478
  .status-message { flex:1; }
2523
2479
  @media(max-width:760px) {
2524
2480
  #home-panel { overflow:visible; min-height:54px; padding:4px; }
2525
- .ribbon-primary { width:calc(100% - 92px); min-width:0; flex:1; padding:0 4px; }
2526
- .ribbon-primary .page-picker { font-size:0; gap:0; }
2527
- .ribbon-primary .page-picker select { font-size:12px; }
2528
2481
  .ribbon-tools { display:block; flex:0 0 84px; width:84px; order:1; }
2529
2482
  .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; }
2530
2483
  .ribbon-tools>summary::-webkit-details-marker { display:none; }
@@ -2536,14 +2489,347 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2536
2489
  .ribbon-tools .search-group { order:1; flex:auto; }
2537
2490
  .ribbon-tools .search-feedback { display:block; }
2538
2491
  .status { flex-wrap:nowrap; gap:4px; min-height:48px; }
2539
- .zoom-controls { width:auto; flex:1; gap:0; }
2540
- .zoom-divider { margin:0; }
2541
2492
  .status .notes-strip { display:none; }
2542
2493
  }
2543
2494
  @media(pointer:coarse) { .ribbon-tabs {height:45px;min-height:45px;} .ribbon-tabs button {min-height:44px;height:44px;} }
2544
2495
  @media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; } }
2545
2496
  `;
2546
2497
  //#endregion
2498
+ //#region src/styles/index.ts
2499
+ /**
2500
+ * Viewer stylesheets as text for the shadow root. New styles are CSS files here; the legacy
2501
+ * `styles.ts` string moves into this directory when it is next changed.
2502
+ */
2503
+ var canvasAndRibbonStyles = ["/*\n * Layout for the shared ooxml-ui ribbon, tab strip and status controls inside the viewer.\n * Viewer theme tokens feed the shared --office-* tokens, so host themes still apply.\n */\n:host {\n --office-foreground: var(--_vv-ink);\n --office-muted-foreground: var(--_vv-muted);\n --office-background: var(--_vv-surface);\n --office-surface: var(--_vv-accent-soft);\n --office-border: var(--_vv-border);\n --office-accent: var(--_vv-accent);\n --office-accent-foreground: var(--vv-accent-ink, #fff);\n --office-ring: var(--vv-focus, var(--_vv-accent));\n --office-font: 'Segoe UI', Arial, sans-serif;\n}\n.ribbon-row {\n display: flex;\n flex-wrap: nowrap;\n align-items: stretch;\n gap: 0;\n padding: 0;\n border: 0;\n background: transparent;\n}\n.ribbon-row office-ui-ribbon-group {\n min-width: 0;\n}\n.ribbon-row office-ui-button {\n --office-target-size: 28px;\n}\n.ribbon-row office-ui-button[variant='stacked']::part(button) {\n min-width: 64px;\n min-height: 62px;\n font-size: 12px;\n}\noffice-ui-button[disabled]::part(button) {\n opacity: 0.45;\n}\n.page-tabs {\n flex: none;\n}\noffice-ui-status-bar.status {\n gap: 16px;\n min-height: 29px;\n padding: 0 8px 0 12px;\n font-size: 11px;\n background: var(--_vv-surface);\n color: var(--_vv-muted);\n}\noffice-ui-status-item[value=''] {\n display: none;\n}\noffice-ui-status-item[data-page-status] {\n color: var(--_vv-ink);\n}\n.status-message {\n flex: 1;\n min-width: 0;\n}\n.viewport[data-tool='rectangle'],\n.viewport[data-tool='rectangle'] .paper [data-shape-id] {\n cursor: crosshair;\n}\n.viewport[data-tool='rectangle'] {\n touch-action: none;\n}\n.draw-preview {\n fill: var(--_vv-accent-soft);\n stroke: var(--_vv-accent);\n stroke-width: 1.5px;\n stroke-dasharray: 4 3;\n pointer-events: none;\n}\n.viewport[data-grid='true'] .paper {\n --_vv-grid-minor: rgb(0 0 0 / 7%);\n --_vv-grid-major: rgb(0 0 0 / 16%);\n background-image:\n linear-gradient(to right, var(--_vv-grid-major) 1px, transparent 1px),\n linear-gradient(to top, var(--_vv-grid-major) 1px, transparent 1px),\n linear-gradient(to right, var(--_vv-grid-minor) 1px, transparent 1px),\n linear-gradient(to top, var(--_vv-grid-minor) 1px, transparent 1px);\n background-size:\n var(--vv-inch, 96px) var(--vv-inch, 96px),\n var(--vv-inch, 96px) var(--vv-inch, 96px),\n calc(var(--vv-inch, 96px) / 4) calc(var(--vv-inch, 96px) / 4),\n calc(var(--vv-inch, 96px) / 4) calc(var(--vv-inch, 96px) / 4);\n background-position: left bottom;\n}\n:host(:fullscreen) {\n width: 100vw;\n height: 100vh;\n}\n@media (max-width: 760px) {\n .ribbon-row {\n flex: 1;\n min-width: 0;\n }\n .undo-group {\n flex: none;\n }\n .ribbon-row office-ui-button[variant='stacked']::part(button) {\n min-width: 52px;\n min-height: 44px;\n }\n .ribbon-tools .tools-content office-ui-ribbon-group {\n width: 100%;\n }\n .search-group {\n scroll-snap-align: start;\n }\n office-ui-status-bar.status {\n gap: 4px;\n min-height: 48px;\n }\n office-ui-status-bar .notes-strip,\n office-ui-status-bar .status-message,\n office-ui-status-item[data-shape-status],\n office-ui-status-item[data-page-status] {\n display: none;\n }\n .zoom-controls {\n flex: 1;\n min-width: 0;\n --office-zoom-slider-width: 0;\n }\n}\n", "/*\n * Drawing canvas focus. SVG outlines are drawn in drawing units (inches here), so a browser\n * focus ring on a shape becomes a page-sized blob. Shapes show focus with a dashed accent\n * stroke instead, only for keyboard focus; selection keeps its solid accent stroke.\n */\n.paper [data-shape-id]:focus,\n.paper [data-shape-id]:focus-visible {\n outline: none;\n}\n.paper [data-shape-id]:focus-visible > [data-geometry] {\n stroke: var(--vv-focus, var(--_vv-accent)) !important;\n stroke-width: 0.04 !important;\n stroke-dasharray: 0.08 0.05;\n}\n"].join("\n");
2504
+ //#endregion
2505
+ //#region src/ribbon-action.ts
2506
+ /** Event name shared by every ribbon control; detail is the typed action. */
2507
+ var RIBBON_ACTION_EVENT = "ribbon-action";
2508
+ /** Internal, non-composed event: it stays inside the viewer's shadow tree. */
2509
+ function emitRibbonAction(target, action) {
2510
+ target.dispatchEvent(new CustomEvent(RIBBON_ACTION_EVENT, {
2511
+ detail: action,
2512
+ bubbles: true
2513
+ }));
2514
+ }
2515
+ //#endregion
2516
+ //#region src/ribbon-parts.ts
2517
+ /**
2518
+ * A large stacked ribbon command. `id` is a stable name for state sync and tests; the typed
2519
+ * `action` is what runs. Labels, icons and shortcuts are static application strings.
2520
+ */
2521
+ function tool(doc, id, label, icon, action, options = {}) {
2522
+ const el = doc.createElement("office-ui-button");
2523
+ el.setAttribute("variant", "stacked");
2524
+ el.setAttribute("command", id);
2525
+ el.setAttribute("icon", icon);
2526
+ el.setAttribute("label", label);
2527
+ el.setAttribute("title", options.keys ? `${label} (${options.keys[1]})` : label);
2528
+ if (options.keys) el.setAttribute("keyshortcuts", options.keys[0]);
2529
+ if (options.pressed !== void 0) el.setAttribute("pressed", String(options.pressed));
2530
+ if (options.controls) el.setAttribute("aria-controls", options.controls);
2531
+ el.addEventListener("office-command", (event) => {
2532
+ event.stopPropagation();
2533
+ emitRibbonAction(el, action);
2534
+ });
2535
+ return el;
2536
+ }
2537
+ /** A labelled ribbon group (the shared `office-ui-ribbon-group`). */
2538
+ function group(doc, label, children, className = "") {
2539
+ const el = doc.createElement("office-ui-ribbon-group");
2540
+ el.setAttribute("label", label);
2541
+ if (className) el.className = className;
2542
+ el.append(...children);
2543
+ return el;
2544
+ }
2545
+ /** A keyboard-navigable row of groups (the shared `office-ui-toolbar`). */
2546
+ function commandRow(doc, label, children) {
2547
+ const el = doc.createElement("office-ui-toolbar");
2548
+ el.className = "ribbon-row";
2549
+ el.setAttribute("aria-label", label);
2550
+ el.append(...children);
2551
+ return el;
2552
+ }
2553
+ /** Muted two-line guidance at the end of a ribbon panel. */
2554
+ function hint(doc, lines) {
2555
+ const el = doc.createElement("p");
2556
+ el.className = "ribbon-hint";
2557
+ lines.forEach((line, index) => {
2558
+ if (index) el.append(doc.createElement("br"));
2559
+ el.append(line);
2560
+ });
2561
+ return el;
2562
+ }
2563
+ //#endregion
2564
+ //#region src/viewer-search.ts
2565
+ /** Builds the Find group: a search field, previous/next result buttons and a live status. */
2566
+ function createSearchGroup(doc) {
2567
+ const group = doc.createElement("div");
2568
+ group.className = "ribbon-group search-group";
2569
+ group.setAttribute("role", "group");
2570
+ group.setAttribute("aria-label", "Find");
2571
+ const controls = doc.createElement("div");
2572
+ controls.className = "search-controls";
2573
+ controls.setAttribute("role", "search");
2574
+ controls.setAttribute("aria-label", "Diagram text search");
2575
+ const label = doc.createElement("label");
2576
+ label.append("Find ");
2577
+ const input = doc.createElement("input");
2578
+ input.type = "search";
2579
+ input.setAttribute("aria-label", "Search diagram text");
2580
+ input.placeholder = "Text across pages";
2581
+ input.maxLength = TEXT_SEARCH_LIMITS.queryCharacters;
2582
+ input.autocomplete = "off";
2583
+ input.spellcheck = false;
2584
+ input.setAttribute("aria-describedby", "search-status");
2585
+ label.append(input);
2586
+ const step = (action, name, text) => {
2587
+ const button = doc.createElement("button");
2588
+ button.type = "button";
2589
+ button.dataset.action = action;
2590
+ button.setAttribute("aria-label", name);
2591
+ button.textContent = text;
2592
+ return button;
2593
+ };
2594
+ const status = doc.createElement("span");
2595
+ status.id = "search-status";
2596
+ status.setAttribute("role", "status");
2597
+ status.setAttribute("aria-live", "polite");
2598
+ status.setAttribute("aria-atomic", "true");
2599
+ controls.append(label, step("search-previous", "Previous matching shape", "Previous"), step("search-next", "Next matching shape", "Next"), status);
2600
+ const caption = doc.createElement("span");
2601
+ caption.className = "group-label";
2602
+ caption.textContent = "Find in drawing";
2603
+ group.append(controls, caption);
2604
+ return group;
2605
+ }
2606
+ function searchControls(root) {
2607
+ return {
2608
+ input: root.querySelector("input[type=\"search\"]"),
2609
+ status: root.querySelector("#search-status"),
2610
+ previous: root.querySelector("[data-action=\"search-previous\"]"),
2611
+ next: root.querySelector("[data-action=\"search-next\"]")
2612
+ };
2613
+ }
2614
+ function renderSearchControls(controls, state) {
2615
+ if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2616
+ controls.input.disabled = !state.document?.pages.length;
2617
+ controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2618
+ controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2619
+ const result = state.search.results[state.search.activeIndex];
2620
+ controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2621
+ }
2622
+ //#endregion
2623
+ //#region src/ribbon-home.ts
2624
+ /**
2625
+ * Visio's Home tab, limited to commands this build can honestly perform: Undo, Tools and
2626
+ * Editing. Clipboard, Font, Paragraph, Shape Styles and Arrange wait for core edit commands.
2627
+ * The compact (phone) layout folds every group but Undo into the Tools disclosure.
2628
+ */
2629
+ function buildHomePanel(doc, panel) {
2630
+ const tools = doc.createElement("details");
2631
+ tools.className = "ribbon-tools";
2632
+ tools.open = true;
2633
+ const summary = doc.createElement("summary");
2634
+ const caret = doc.createElement("span");
2635
+ caret.setAttribute("aria-hidden", "true");
2636
+ caret.textContent = "⌄";
2637
+ summary.append("Tools ", caret);
2638
+ const content = doc.createElement("div");
2639
+ content.className = "tools-content";
2640
+ content.append(group(doc, "Tools", [
2641
+ tool(doc, "pointer", "Pointer Tool", "pointer", {
2642
+ type: "tool",
2643
+ tool: "pointer"
2644
+ }, {
2645
+ keys: ["Control+1", "Ctrl+1"],
2646
+ pressed: true
2647
+ }),
2648
+ tool(doc, "rectangle", "Rectangle", "rectangle", {
2649
+ type: "tool",
2650
+ tool: "rectangle"
2651
+ }, {
2652
+ keys: ["Control+8", "Ctrl+8"],
2653
+ pressed: false
2654
+ }),
2655
+ tool(doc, "edit", "Edit text", "pencil", {
2656
+ type: "reveal",
2657
+ panel: "edit"
2658
+ }, { keys: ["F2", "F2"] })
2659
+ ]), createSearchGroup(doc), group(doc, "Editing", [
2660
+ tool(doc, "delete", "Delete", "trash", { type: "delete" }, { keys: ["Delete", "Del"] }),
2661
+ tool(doc, "layers", "Layers", "visioLayers", {
2662
+ type: "reveal",
2663
+ panel: "layers"
2664
+ }),
2665
+ tool(doc, "selection", "Shape details", "visioShapeData", {
2666
+ type: "reveal",
2667
+ panel: "selection"
2668
+ })
2669
+ ]));
2670
+ tools.append(summary, content);
2671
+ panel.append(commandRow(doc, "Home commands", [group(doc, "Undo", [tool(doc, "undo", "Undo", "undo", {
2672
+ type: "history",
2673
+ key: "undo"
2674
+ }, { keys: ["Control+Z", "Ctrl+Z"] }), tool(doc, "redo", "Redo", "redo", {
2675
+ type: "history",
2676
+ key: "redo"
2677
+ }, { keys: ["Control+Y", "Ctrl+Y"] })], "undo-group"), tools]), hint(doc, ["Rectangle: drag on the page to draw.", "Editing is experimental. Keep your original file."]));
2678
+ }
2679
+ //#endregion
2680
+ //#region src/ribbon-view.ts
2681
+ /** Visio's View tab: Views, Show (grid and task panes) and Zoom. */
2682
+ function buildViewPanel(doc, panel) {
2683
+ panel.append(commandRow(doc, "View commands", [
2684
+ group(doc, "Views", [tool(doc, "fullscreen", "Full Screen", "fullscreen", { type: "fullscreen" }, {
2685
+ keys: ["F5", "F5"],
2686
+ pressed: false
2687
+ })]),
2688
+ group(doc, "Show", [
2689
+ tool(doc, "grid", "Grid", "grid", { type: "grid" }, { pressed: false }),
2690
+ tool(doc, "pages", "Pages pane", "visioPagesPane", {
2691
+ type: "pane",
2692
+ pane: "pages"
2693
+ }, {
2694
+ pressed: true,
2695
+ controls: "page-rail"
2696
+ }),
2697
+ tool(doc, "inspector", "Inspector pane", "visioInspectorPane", {
2698
+ type: "pane",
2699
+ pane: "inspector"
2700
+ }, {
2701
+ pressed: true,
2702
+ controls: "inspector-pane"
2703
+ }),
2704
+ tool(doc, "notes", "Review notes", "message", {
2705
+ type: "reveal",
2706
+ panel: "notes"
2707
+ })
2708
+ ]),
2709
+ group(doc, "Zoom", [
2710
+ tool(doc, "zoom-fit", "Fit to Window", "fitPage", {
2711
+ type: "zoom",
2712
+ mode: "fit"
2713
+ }, { keys: ["Control+Shift+W", "Ctrl+Shift+W"] }),
2714
+ tool(doc, "page-width", "Page Width", "pageWidth", {
2715
+ type: "zoom",
2716
+ mode: "width"
2717
+ }),
2718
+ tool(doc, "actual-size", "100%", "search", {
2719
+ type: "zoom",
2720
+ mode: "actual"
2721
+ })
2722
+ ])
2723
+ ]), hint(doc, ["Ctrl+wheel or + / − to zoom.", "Ctrl+Page Up / Page Down changes page."]));
2724
+ }
2725
+ //#endregion
2726
+ //#region src/ribbon.ts
2727
+ var TABS = [[
2728
+ "home",
2729
+ "Home",
2730
+ buildHomePanel
2731
+ ], [
2732
+ "view",
2733
+ "View",
2734
+ buildViewPanel
2735
+ ]];
2736
+ /**
2737
+ * The Visio ribbon: a tab list and one panel per tab. Tab switching and keyboard movement
2738
+ * belong to the chrome; commands emit `ribbon-action` events for the router.
2739
+ */
2740
+ function createRibbon(doc) {
2741
+ const toolbar = doc.createElement("div");
2742
+ toolbar.className = "toolbar";
2743
+ toolbar.setAttribute("role", "group");
2744
+ toolbar.setAttribute("aria-label", "Diagram controls");
2745
+ const tabs = doc.createElement("div");
2746
+ tabs.className = "ribbon-tabs";
2747
+ tabs.setAttribute("role", "tablist");
2748
+ tabs.setAttribute("aria-label", "Ribbon");
2749
+ toolbar.append(tabs);
2750
+ for (const [key, name, build] of TABS) {
2751
+ const selected = key === "home";
2752
+ const tab = doc.createElement("button");
2753
+ tab.type = "button";
2754
+ tab.id = `${key}-tab`;
2755
+ tab.dataset.tab = key;
2756
+ tab.textContent = name;
2757
+ tab.setAttribute("role", "tab");
2758
+ tab.setAttribute("aria-selected", String(selected));
2759
+ tab.setAttribute("aria-controls", `${key}-panel`);
2760
+ if (!selected) tab.tabIndex = -1;
2761
+ const panel = doc.createElement("div");
2762
+ panel.className = "ribbon-content";
2763
+ panel.id = `${key}-panel`;
2764
+ panel.setAttribute("role", "tabpanel");
2765
+ panel.setAttribute("aria-labelledby", tab.id);
2766
+ panel.hidden = !selected;
2767
+ build(doc, panel);
2768
+ tabs.append(tab);
2769
+ toolbar.append(panel);
2770
+ }
2771
+ return toolbar;
2772
+ }
2773
+ //#endregion
2774
+ //#region src/status-bar.ts
2775
+ /**
2776
+ * Visio's status bar on the shared `office-ui-status-bar`: page and shape metrics, a live
2777
+ * message region, compatibility notes, the host footer slot and the shared zoom slider.
2778
+ */
2779
+ function createStatusBar(doc) {
2780
+ const bar = doc.createElement("office-ui-status-bar");
2781
+ bar.className = "status";
2782
+ bar.setAttribute("label", "Status bar");
2783
+ const item = (name) => {
2784
+ const el = doc.createElement("office-ui-status-item");
2785
+ el.setAttribute(name, "");
2786
+ el.setAttribute("label", "");
2787
+ el.setAttribute("value", "");
2788
+ return el;
2789
+ };
2790
+ const message = doc.createElement("div");
2791
+ message.className = "status-message";
2792
+ message.setAttribute("role", "status");
2793
+ const text = doc.createElement("span");
2794
+ text.dataset.status = "";
2795
+ const diagnostics = doc.createElement("span");
2796
+ diagnostics.dataset.diagnostics = "";
2797
+ message.append(text, diagnostics);
2798
+ const notes = doc.createElement("div");
2799
+ notes.className = "notes-strip";
2800
+ const notesButton = doc.createElement("button");
2801
+ notesButton.type = "button";
2802
+ notesButton.dataset.chrome = "notes";
2803
+ notesButton.setAttribute("aria-controls", "inspector-pane");
2804
+ notesButton.setAttribute("aria-expanded", "false");
2805
+ const count = doc.createElement("span");
2806
+ count.className = "notes-count";
2807
+ count.dataset.noteCount = "";
2808
+ notesButton.append("Notes", count);
2809
+ const local = doc.createElement("span");
2810
+ local.textContent = "Files stay in your browser";
2811
+ notes.append(notesButton, local);
2812
+ const footer = doc.createElement("slot");
2813
+ footer.name = "workspace-footer";
2814
+ const zoom = doc.createElement("office-ui-zoom-slider");
2815
+ zoom.slot = "end";
2816
+ zoom.className = "zoom-controls";
2817
+ zoom.setAttribute("label", "Canvas zoom");
2818
+ zoom.setAttribute("fit", "Fit page to current window");
2819
+ zoom.setAttribute("value", "100");
2820
+ bar.append(item("data-page-status"), item("data-shape-status"), message, notes, footer, zoom);
2821
+ return bar;
2822
+ }
2823
+ /** Visio's page tabs under the drawing, on the shared document tab strip. */
2824
+ function createPageTabs(doc) {
2825
+ const strip = doc.createElement("office-ui-tab-strip");
2826
+ strip.className = "page-tabs";
2827
+ strip.setAttribute("label", "Pages");
2828
+ strip.setAttribute("previous-label", "Previous page");
2829
+ strip.setAttribute("next-label", "Next page");
2830
+ return strip;
2831
+ }
2832
+ //#endregion
2547
2833
  //#region src/edit-error.ts
2548
2834
  /** Core error codes remain visible for a refused transaction; document content is plain text. */
2549
2835
  function editErrorMessage(error) {
@@ -2831,59 +3117,13 @@ var ViewerEditControls = class {
2831
3117
  }
2832
3118
  };
2833
3119
  //#endregion
2834
- //#region src/viewer-search.ts
2835
- 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>`;
2836
- function searchControls(root) {
2837
- return {
2838
- input: root.querySelector("input[type=\"search\"]"),
2839
- status: root.querySelector("#search-status"),
2840
- previous: root.querySelector("[data-action=\"search-previous\"]"),
2841
- next: root.querySelector("[data-action=\"search-next\"]")
2842
- };
2843
- }
2844
- function renderSearchControls(controls, state) {
2845
- if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2846
- controls.input.disabled = !state.document?.pages.length;
2847
- controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2848
- controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2849
- const result = state.search.results[state.search.activeIndex];
2850
- controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2851
- }
2852
- //#endregion
2853
3120
  //#region src/viewer-chrome.ts
2854
- /** Only static application markup belongs here. All document labels are inserted as text. */
2855
- var viewerChromeTemplate = `
2856
- <div class="toolbar" role="group" aria-label="Diagram controls">
2857
-
2858
- <div class="ribbon-tabs" role="tablist" aria-label="Ribbon">
2859
- <button type="button" id="home-tab" role="tab" aria-selected="true" aria-controls="home-panel" data-tab="home">Home</button>
2860
- <button type="button" id="view-tab" role="tab" aria-selected="false" aria-controls="view-panel" tabindex="-1" data-tab="view">View</button>
2861
- </div>
2862
- <div class="ribbon-content" id="home-panel" role="tabpanel" aria-labelledby="home-tab">
2863
- <details class="ribbon-tools" open><summary>Tools <span aria-hidden="true">⌄</span></summary><div class="tools-content"><div class="ribbon-group search-group">${searchTemplate}<span class="group-label">Find in drawing</span></div>
2864
- <div class="ribbon-group"><div class="ribbon-actions">
2865
- <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>
2866
- <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>
2867
- <button class="ribbon-command" type="button" data-chrome="layers"><span class="command-icon" data-icon="layers" aria-hidden="true"></span><span>Layers</span></button>
2868
- </div><span class="group-label">Inspect and edit</span></div>
2869
- </div></details>
2870
- <div class="ribbon-primary">
2871
- <label class="page-picker">Page <select aria-label="Page"></select></label>
2872
- <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>
2873
- <span class="spacer"></span><span class="local-label">Local workspace</span>
2874
- </div>
2875
- <p class="ribbon-hint">Select a shape to inspect its details.<br>Text editing is experimental.</p>
2876
- </div>
2877
- <div class="ribbon-content" id="view-panel" role="tabpanel" aria-labelledby="view-tab" hidden>
2878
- <div class="ribbon-group"><div class="ribbon-actions">
2879
- <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>
2880
- <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>
2881
- <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>
2882
- </div><span class="group-label">Workspace panes</span></div>
2883
- <p class="ribbon-hint">Canvas shortcuts: + / − to zoom, 0 to fit.<br>Arrow keys move between focused shapes.</p>
2884
- </div>
2885
- </div>
2886
- <div class="workspace">
3121
+ /**
3122
+ * Static workspace markup (legacy; migrate to builders when next changed). The ribbon, page
3123
+ * tabs and status bar are built by `createRibbon`, `createPageTabs` and `createStatusBar`.
3124
+ * All document labels are inserted as text.
3125
+ */
3126
+ var viewerChromeTemplate = `<div class="workspace">
2887
3127
  <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>
2888
3128
  <div class="viewport" tabindex="0" role="region" aria-label="Diagram canvas"></div>
2889
3129
  <aside id="inspector-pane" class="inspector-pane" aria-label="Drawing inspector">
@@ -2896,14 +3136,15 @@ var viewerChromeTemplate = `
2896
3136
  <details class="notes inspector-card"><summary>Compatibility notes</summary><ul></ul></details>
2897
3137
  </div>
2898
3138
  </aside>
2899
- </div>
2900
- <div class="status"><div class="status-message" role="status"><span data-status></span><span data-diagnostics></span></div><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><slot name="workspace-footer"></slot><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>`;
3139
+ </div>`;
2901
3140
  /** Presentation state stays local; page navigation uses the same controller as every binding. */
2902
3141
  var ViewerChrome = class {
2903
3142
  #root;
2904
3143
  #controller;
2905
3144
  #document;
2906
3145
  #pageList;
3146
+ #pageTabs;
3147
+ #tools;
2907
3148
  #rail;
2908
3149
  #inspector;
2909
3150
  #notes;
@@ -2915,6 +3156,8 @@ var ViewerChrome = class {
2915
3156
  this.#root = root;
2916
3157
  this.#controller = controller;
2917
3158
  this.#pageList = root.querySelector(".page-list");
3159
+ this.#pageTabs = root.querySelector("office-ui-tab-strip");
3160
+ this.#tools = root.querySelector(".ribbon-tools");
2918
3161
  this.#rail = root.querySelector(".page-rail");
2919
3162
  this.#inspector = root.querySelector(".inspector-pane");
2920
3163
  this.#notes = root.querySelector(".notes");
@@ -2925,7 +3168,7 @@ var ViewerChrome = class {
2925
3168
  this.#responsive = view?.matchMedia?.("(max-width: 980px)");
2926
3169
  const compact = view?.matchMedia?.("(max-width: 760px)");
2927
3170
  this.#compact = compact;
2928
- const tools = this.#root.querySelector(".ribbon-tools");
3171
+ const tools = this.#tools;
2929
3172
  const toolLayout = () => {
2930
3173
  tools.open = !compact?.matches;
2931
3174
  };
@@ -2952,22 +3195,16 @@ var ViewerChrome = class {
2952
3195
  this.#root.addEventListener("click", (event) => {
2953
3196
  const button = event.target?.closest?.("button");
2954
3197
  if (!button || button.disabled) return;
2955
- if (compact?.matches && button.dataset.chrome) tools.open = false;
2956
3198
  const tab = button.dataset.tab;
2957
- if (tab === "home" || tab === "view") this.#showTab(tab);
3199
+ if (tab === "home" || tab === "view") this.showTab(tab);
2958
3200
  if (button.dataset.pageIndex !== void 0) this.#controller.setPage(Number(button.dataset.pageIndex));
2959
3201
  const action = button.dataset.chrome;
2960
- if (action === "previous-page") this.#controller.setPage(this.#controller.state.pageIndex - 1);
2961
- if (action === "next-page") this.#controller.setPage(this.#controller.state.pageIndex + 1);
2962
- if (action === "pages") {
2963
- this.#pagesManuallyToggled = true;
2964
- this.#setPane("pages", this.#rail.hidden);
2965
- }
2966
- if (action === "inspector") {
2967
- this.#inspectorManuallyToggled = true;
2968
- this.#setPane("inspector", this.#inspector.hidden);
2969
- }
2970
- if (action === "notes" || action === "selection" || action === "edit" || action === "layers") this.#reveal(action);
3202
+ if (action === "inspector") this.togglePane("inspector");
3203
+ if (action === "notes") this.reveal("notes");
3204
+ }, options);
3205
+ this.#root.addEventListener("office-tab-select", (event) => {
3206
+ const id = Number(event.detail.id);
3207
+ if (Number.isSafeInteger(id)) this.#controller.setPage(id);
2971
3208
  }, options);
2972
3209
  this.#root.addEventListener("keydown", (event) => {
2973
3210
  const key = event;
@@ -2984,11 +3221,11 @@ var ViewerChrome = class {
2984
3221
  if (key.key === "ArrowUp" || key.key === "ArrowDown") return;
2985
3222
  key.preventDefault();
2986
3223
  const tab = key.key === "Home" ? "home" : key.key === "End" ? "view" : button.dataset.tab === "home" ? "view" : "home";
2987
- this.#showTab(tab);
3224
+ this.showTab(tab);
2988
3225
  this.#root.querySelector(`[data-tab="${tab}"]`).focus();
2989
3226
  } else if (button.dataset.pageIndex !== void 0) {
2990
3227
  key.preventDefault();
2991
- const pages = [...this.#pageList.querySelectorAll("button")];
3228
+ const pages = [...this.#pageList.querySelectorAll("[data-page-index]")];
2992
3229
  const current = Number(button.dataset.pageIndex);
2993
3230
  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)));
2994
3231
  this.#controller.setPage(next);
@@ -2998,7 +3235,7 @@ var ViewerChrome = class {
2998
3235
  this.#notes.addEventListener("toggle", () => this.#syncNotes(), options);
2999
3236
  return () => events.abort();
3000
3237
  }
3001
- #showTab(tab) {
3238
+ showTab(tab) {
3002
3239
  for (const button of this.#root.querySelectorAll("[data-tab]")) {
3003
3240
  const selected = button.dataset.tab === tab;
3004
3241
  button.setAttribute("aria-selected", String(selected));
@@ -3006,27 +3243,41 @@ var ViewerChrome = class {
3006
3243
  }
3007
3244
  for (const panel of this.#root.querySelectorAll("[role=\"tabpanel\"]")) panel.hidden = panel.id !== `${tab}-panel`;
3008
3245
  }
3246
+ /** Show or hide a task pane; compact layouts close the Tools menu afterwards. */
3247
+ togglePane(pane) {
3248
+ if (this.#compact?.matches) this.#tools.open = false;
3249
+ if (pane === "pages") this.#pagesManuallyToggled = true;
3250
+ else this.#inspectorManuallyToggled = true;
3251
+ this.#setPane(pane, (pane === "pages" ? this.#rail : this.#inspector).hidden);
3252
+ }
3253
+ #command(name) {
3254
+ return this.#root.querySelector(`office-ui-button[command="${name}"]`);
3255
+ }
3009
3256
  #setPane(pane, visible) {
3010
3257
  if (visible && this.#compact?.matches) {
3011
3258
  const other = pane === "pages" ? "inspector" : "pages";
3012
3259
  (other === "pages" ? this.#rail : this.#inspector).hidden = true;
3013
- this.#root.querySelector(`[data-chrome="${other}"]`).setAttribute("aria-pressed", "false");
3260
+ this.#command(other).setAttribute("pressed", "false");
3014
3261
  }
3015
3262
  (pane === "pages" ? this.#rail : this.#inspector).hidden = !visible;
3016
- this.#root.querySelector(`[data-chrome="${pane}"]`).setAttribute("aria-pressed", String(visible));
3263
+ this.#command(pane).setAttribute("pressed", String(visible));
3017
3264
  this.#syncNotes();
3018
3265
  }
3019
3266
  #syncNotes() {
3020
3267
  this.#root.querySelector(".notes-strip button").setAttribute("aria-expanded", String(!this.#inspector.hidden && this.#notes.open));
3021
3268
  }
3022
- #reveal(kind) {
3269
+ /** Open an inspector disclosure; text editing may focus the text field directly (F2). */
3270
+ reveal(kind, focusText = false) {
3271
+ if (this.#compact?.matches) this.#tools.open = false;
3023
3272
  this.#inspectorManuallyToggled = true;
3024
3273
  this.#setPane("inspector", true);
3025
3274
  const selector = kind === "notes" ? ".notes" : kind === "edit" ? ".edit-controls" : kind === "layers" ? ".layer-controls" : ".shape-inspector";
3026
3275
  const panel = this.#root.querySelector(selector);
3027
3276
  if (panel.hidden) return;
3028
3277
  panel.open = true;
3029
- panel.querySelector("summary").focus();
3278
+ const text = panel.querySelector("textarea");
3279
+ if (focusText && text && !text.disabled) text.focus();
3280
+ else panel.querySelector("summary").focus();
3030
3281
  panel.scrollIntoView?.({
3031
3282
  block: "nearest",
3032
3283
  inline: "nearest"
@@ -3067,14 +3318,21 @@ var ViewerChrome = class {
3067
3318
  fragment.append(item);
3068
3319
  }
3069
3320
  this.#pageList.replaceChildren(fragment);
3321
+ this.#pageTabs.tabs = (state.document?.pages ?? []).map((candidate, index) => ({
3322
+ id: String(index),
3323
+ label: candidate.name,
3324
+ title: candidate.isBackground ? `${candidate.name} (background page)` : candidate.name
3325
+ }));
3070
3326
  if (restoreFocus !== void 0) [...this.#pageList.querySelectorAll("button")].find((button) => button.dataset.pageIndex === restoreFocus)?.focus();
3071
3327
  }
3328
+ this.#pageTabs.selected = page ? String(state.pageIndex) : "";
3072
3329
  for (const button of this.#pageList.querySelectorAll("button")) {
3073
3330
  const selected = Number(button.dataset.pageIndex) === state.pageIndex;
3074
3331
  if (selected) button.setAttribute("aria-current", "page");
3075
3332
  else button.removeAttribute("aria-current");
3076
3333
  button.tabIndex = selected ? 0 : -1;
3077
3334
  }
3335
+ this.#root.querySelector("[data-page-status]").setAttribute("value", page ? `Page ${state.pageIndex + 1} of ${state.document.pages.length}` : "");
3078
3336
  const count = state.document?.pages.length ?? 0;
3079
3337
  this.#root.querySelector("[data-page-count]").textContent = String(count);
3080
3338
  this.#root.querySelector(".page-empty").hidden = count > 0;
@@ -3082,17 +3340,405 @@ var ViewerChrome = class {
3082
3340
  this.#root.querySelector("[data-page-size]").textContent = page ? `${page.width} × ${page.height} in` : "No page";
3083
3341
  this.#root.querySelector("[data-page-shapes]").textContent = page ? `${page.shapes.length} top-level` : "No shapes";
3084
3342
  this.#root.querySelector(".selection-hint").hidden = !!state.selectedShape;
3085
- this.#root.querySelector("[data-chrome=\"previous-page\"]").disabled = !page || state.pageIndex === 0;
3086
- this.#root.querySelector("[data-chrome=\"next-page\"]").disabled = !page || state.pageIndex >= count - 1;
3087
- this.#root.querySelector("[data-chrome=\"selection\"]").disabled = !state.selectedShape;
3088
- this.#root.querySelector("[data-chrome=\"edit\"]").disabled = !page;
3089
- this.#root.querySelector("[data-chrome=\"layers\"]").disabled = this.#root.querySelector(".layer-controls").hidden;
3090
- for (const button of this.#root.querySelectorAll("[data-chrome=\"notes\"]")) button.disabled = noteCount === 0;
3343
+ this.#command("selection").disabled = !state.selectedShape;
3344
+ this.#command("edit").disabled = !page;
3345
+ this.#command("layers").disabled = this.#root.querySelector(".layer-controls").hidden;
3346
+ this.#command("notes").disabled = noteCount === 0;
3347
+ this.#root.querySelector(".notes-strip button").disabled = noteCount === 0;
3091
3348
  this.#root.querySelector("[data-note-count]").textContent = noteCount ? ` · ${noteCount}` : "";
3092
3349
  this.#syncNotes();
3093
3350
  }
3094
3351
  };
3095
3352
  //#endregion
3353
+ //#region src/ribbon-router.ts
3354
+ /** Routes a ribbon, status-bar or shortcut action to the controller that owns it. */
3355
+ function routeRibbonAction(targets, action) {
3356
+ const { controller } = targets;
3357
+ const page = controller.state.document?.pages[controller.state.pageIndex];
3358
+ switch (action.type) {
3359
+ case "history": return targets.history(action.key);
3360
+ case "delete": return targets.deleteSelection();
3361
+ case "tool": return targets.setTool(action.tool);
3362
+ case "grid": return targets.toggleGrid();
3363
+ case "fullscreen": return targets.toggleFullscreen();
3364
+ case "pane": return targets.togglePane(action.pane);
3365
+ case "reveal": return targets.reveal(action.panel, action.focusText ?? false);
3366
+ case "search": return targets.focusSearch();
3367
+ case "page": return controller.setPage(controller.state.pageIndex + action.step);
3368
+ case "zoom":
3369
+ if (!page) return;
3370
+ if (action.mode === "actual") return controller.setZoom(1);
3371
+ return targets.fit(action.mode === "fit" ? "page" : "width");
3372
+ }
3373
+ }
3374
+ //#endregion
3375
+ //#region src/viewer-draw-tool.ts
3376
+ /** Visio snaps new geometry to ruler subdivisions; 1/16 inch matches its default fine grid. */
3377
+ var SNAP = 1 / 16;
3378
+ var MIN_SIZE = SNAP;
3379
+ var snap = (value) => Math.round(value / SNAP) * SNAP;
3380
+ /** Next free numeric shape ID on the page tree. Core still rejects any collision. */
3381
+ function nextShapeId(page) {
3382
+ let max = 0;
3383
+ const pending = [...page.shapes];
3384
+ while (pending.length) {
3385
+ const shape = pending.pop();
3386
+ if (/^\d{1,9}$/.test(shape.id)) max = Math.max(max, Number(shape.id));
3387
+ pending.push(...shape.children);
3388
+ }
3389
+ return String(max + 1);
3390
+ }
3391
+ /** Page inches with a top-left origin (SVG user space) from a client point. */
3392
+ function pagePoint(svg, page, event) {
3393
+ const matrix = svg.getScreenCTM?.();
3394
+ if (!matrix) return void 0;
3395
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
3396
+ return {
3397
+ x: snap(Math.max(0, Math.min(page.width, point.x))),
3398
+ y: snap(Math.max(0, Math.min(page.height, point.y)))
3399
+ };
3400
+ }
3401
+ /** Drag-to-draw rectangle tool. The preview is viewer-only; the shape is created by core. */
3402
+ var RectangleDrawTool = class {
3403
+ viewport;
3404
+ controller;
3405
+ options;
3406
+ #drag;
3407
+ #request = 0;
3408
+ constructor(viewport, controller, options) {
3409
+ this.viewport = viewport;
3410
+ this.controller = controller;
3411
+ this.options = options;
3412
+ }
3413
+ get drawing() {
3414
+ return !!this.#drag;
3415
+ }
3416
+ wire() {
3417
+ const events = new ((this.viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
3418
+ const options = { signal: events.signal };
3419
+ const viewport = this.viewport;
3420
+ viewport.addEventListener("click", (event) => {
3421
+ if (this.options.active()) event.stopImmediatePropagation();
3422
+ }, {
3423
+ ...options,
3424
+ capture: true
3425
+ });
3426
+ viewport.addEventListener("pointerdown", (event) => this.#start(event), options);
3427
+ viewport.addEventListener("pointermove", (event) => this.#move(event), options);
3428
+ viewport.addEventListener("pointerup", (event) => void this.#finish(event), options);
3429
+ viewport.addEventListener("pointercancel", () => this.#cancel(), options);
3430
+ viewport.addEventListener("keydown", (event) => {
3431
+ if (event.key === "Escape" && this.#drag) {
3432
+ event.preventDefault();
3433
+ this.#cancel();
3434
+ }
3435
+ }, options);
3436
+ return () => {
3437
+ ++this.#request;
3438
+ this.#cancel();
3439
+ events.abort();
3440
+ };
3441
+ }
3442
+ #start(event) {
3443
+ if (!this.options.active() || event.button !== 0 || this.#drag) return;
3444
+ const state = this.controller.state;
3445
+ const page = state.document?.pages[state.pageIndex];
3446
+ const svg = this.viewport.querySelector("svg.paper");
3447
+ if (!page || !svg || !state.edit.sourceAvailable || state.edit.busy) return;
3448
+ const start = pagePoint(svg, page, event);
3449
+ if (!start) return;
3450
+ event.preventDefault();
3451
+ const rect = this.viewport.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "rect");
3452
+ rect.classList.add("draw-preview");
3453
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
3454
+ svg.append(rect);
3455
+ this.#drag = {
3456
+ pointer: event.pointerId,
3457
+ svg,
3458
+ page,
3459
+ ...start,
3460
+ rect
3461
+ };
3462
+ this.viewport.setPointerCapture?.(event.pointerId);
3463
+ this.#update(start);
3464
+ }
3465
+ #move(event) {
3466
+ if (!this.#drag || event.pointerId !== this.#drag.pointer) return;
3467
+ const point = pagePoint(this.#drag.svg, this.#drag.page, event);
3468
+ if (point) this.#update(point);
3469
+ }
3470
+ #update(point) {
3471
+ const { x, y, rect } = this.#drag;
3472
+ rect.setAttribute("x", String(Math.min(x, point.x)));
3473
+ rect.setAttribute("y", String(Math.min(y, point.y)));
3474
+ rect.setAttribute("width", String(Math.abs(point.x - x)));
3475
+ rect.setAttribute("height", String(Math.abs(point.y - y)));
3476
+ }
3477
+ #cancel() {
3478
+ this.#drag?.rect.remove();
3479
+ this.#drag = void 0;
3480
+ }
3481
+ async #finish(event) {
3482
+ const drag = this.#drag;
3483
+ if (!drag || event.pointerId !== drag.pointer) return;
3484
+ const end = pagePoint(drag.svg, drag.page, event) ?? drag;
3485
+ this.#cancel();
3486
+ const width = Math.abs(end.x - drag.x), height = Math.abs(end.y - drag.y);
3487
+ if (width < MIN_SIZE || height < MIN_SIZE) {
3488
+ this.options.announce("Drag on the page to draw a rectangle.");
3489
+ return;
3490
+ }
3491
+ const state = this.controller.state;
3492
+ if (state.document?.pages[state.pageIndex] !== drag.page) return;
3493
+ const shapeId = nextShapeId(drag.page);
3494
+ const pageId = drag.page.id;
3495
+ const request = ++this.#request;
3496
+ try {
3497
+ await this.controller.applyEdits([{
3498
+ type: "create-rectangle",
3499
+ pageId,
3500
+ shapeId,
3501
+ x: (drag.x + end.x) / 2,
3502
+ y: drag.page.height - (drag.y + end.y) / 2,
3503
+ width,
3504
+ height
3505
+ }]);
3506
+ if (request !== this.#request) return;
3507
+ const created = this.controller.state.document?.pages.find((page) => page.id === pageId)?.shapes.find((shape) => shape.id === shapeId);
3508
+ if (created) this.controller.selectShape({
3509
+ id: shapeId,
3510
+ name: created.name,
3511
+ pageId
3512
+ });
3513
+ this.options.announce(`Rectangle ${shapeId} added (${+width.toFixed(4)} × ${+height.toFixed(4)} in).`);
3514
+ } catch (error) {
3515
+ if (request === this.#request && !isEditCancellation(error)) this.options.announce(editErrorMessage(error));
3516
+ }
3517
+ }
3518
+ };
3519
+ //#endregion
3520
+ //#region src/viewer-commands.ts
3521
+ var editable = (target) => target instanceof Element && !!target.closest("input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])");
3522
+ /**
3523
+ * Visio command state (tool, grid, pending edits) and keyboard shortcuts. Ribbon controls emit
3524
+ * typed `ribbon-action` events and shortcuts build the same actions; both go through
3525
+ * `routeRibbonAction`. Document mutation stays in the controller and core.
3526
+ */
3527
+ var ViewerCommands = class {
3528
+ host;
3529
+ #tool = "pointer";
3530
+ #grid = false;
3531
+ #pending = 0;
3532
+ #draw;
3533
+ #targets;
3534
+ constructor(host) {
3535
+ this.host = host;
3536
+ this.#draw = new RectangleDrawTool(host.viewport, host.controller, {
3537
+ active: () => this.#tool === "rectangle",
3538
+ announce: host.announce
3539
+ });
3540
+ this.#targets = {
3541
+ controller: host.controller,
3542
+ history: (key) => this.#history(key),
3543
+ deleteSelection: () => this.#delete(),
3544
+ setTool: (tool) => this.setTool(tool),
3545
+ toggleGrid: () => {
3546
+ this.#grid = !this.#grid;
3547
+ this.render(host.controller.state);
3548
+ },
3549
+ toggleFullscreen: () => this.#toggleFullscreen(),
3550
+ togglePane: host.togglePane,
3551
+ reveal: host.reveal,
3552
+ fit: host.fit,
3553
+ focusSearch: host.focusSearch
3554
+ };
3555
+ }
3556
+ get tool() {
3557
+ return this.#tool;
3558
+ }
3559
+ /** Run one typed action, exactly as a ribbon control or shortcut would. */
3560
+ run(action) {
3561
+ routeRibbonAction(this.#targets, action);
3562
+ }
3563
+ wire() {
3564
+ const { root, viewport } = this.host;
3565
+ const doc = root.ownerDocument;
3566
+ const events = new ((doc.defaultView?.AbortController) ?? AbortController)();
3567
+ const options = { signal: events.signal };
3568
+ root.addEventListener(RIBBON_ACTION_EVENT, (event) => this.run(event.detail), options);
3569
+ root.addEventListener("office-command", (event) => {
3570
+ if (event.detail?.command === "zoom-fit") this.run({
3571
+ type: "zoom",
3572
+ mode: "fit"
3573
+ });
3574
+ }, options);
3575
+ root.addEventListener("keydown", (event) => this.#shortcut(event), options);
3576
+ viewport.addEventListener("wheel", (event) => {
3577
+ if (!event.ctrlKey && !event.metaKey) return;
3578
+ event.preventDefault();
3579
+ const zoom = this.host.controller.state.zoom;
3580
+ this.host.controller.setZoom(event.deltaY < 0 ? zoom * 1.1 : zoom / 1.1);
3581
+ }, {
3582
+ ...options,
3583
+ passive: false
3584
+ });
3585
+ viewport.addEventListener("pointerdown", () => {
3586
+ if (!viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3587
+ }, options);
3588
+ viewport.addEventListener("dblclick", (event) => {
3589
+ if (this.#tool !== "pointer") return;
3590
+ if (event.target?.closest?.("[data-shape-id]")) this.run({
3591
+ type: "reveal",
3592
+ panel: "edit",
3593
+ focusText: true
3594
+ });
3595
+ }, options);
3596
+ doc.addEventListener("fullscreenchange", () => this.render(this.host.controller.state), options);
3597
+ const disposeDraw = this.#draw.wire();
3598
+ return () => {
3599
+ ++this.#pending;
3600
+ events.abort();
3601
+ disposeDraw();
3602
+ };
3603
+ }
3604
+ setTool(tool) {
3605
+ if (tool === "rectangle" && !this.#canEdit(this.host.controller.state)) return;
3606
+ this.#tool = tool;
3607
+ this.render(this.host.controller.state);
3608
+ }
3609
+ #canEdit(state) {
3610
+ return state.edit.sourceAvailable && !state.loading && !state.edit.busy;
3611
+ }
3612
+ #history(key) {
3613
+ const { controller } = this.host;
3614
+ const { edit, loading } = controller.state;
3615
+ if ((key === "undo" ? edit.canUndo : edit.canRedo) && !edit.busy && !loading) this.#edit(() => controller[key]());
3616
+ }
3617
+ #delete() {
3618
+ const state = this.host.controller.state;
3619
+ const shape = state.selectedShape;
3620
+ const pageId = shape?.pageId ?? state.document?.pages[state.pageIndex]?.id;
3621
+ if (!shape || pageId === void 0 || !this.#canEdit(state)) return;
3622
+ this.#edit(() => this.host.controller.applyEdits([{
3623
+ type: "delete-shape",
3624
+ pageId,
3625
+ shapeId: shape.id
3626
+ }]), `Deleted ${shape.name || `shape ${shape.id}`}.`);
3627
+ }
3628
+ async #edit(action, success) {
3629
+ const request = ++this.#pending;
3630
+ const { root, viewport } = this.host;
3631
+ const canvasFocused = viewport.contains(root.activeElement);
3632
+ try {
3633
+ await action();
3634
+ if (canvasFocused && !viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3635
+ if (success && request === this.#pending) this.host.announce(success);
3636
+ } catch (error) {
3637
+ if (request === this.#pending && !isEditCancellation(error) && !this.host.controller.state.edit.error) this.host.announce(editErrorMessage(error));
3638
+ }
3639
+ }
3640
+ #toggleFullscreen() {
3641
+ const host = this.host.root.host;
3642
+ const doc = this.host.root.ownerDocument;
3643
+ (doc.fullscreenElement === host ? doc.exitFullscreen?.() : host.requestFullscreen?.())?.catch?.(() => this.host.announce("Full screen is not available in this browser frame."));
3644
+ }
3645
+ /** Visio shortcuts as typed actions. Text fields keep native undo, deletion and editing keys. */
3646
+ #shortcutAction(event) {
3647
+ const state = this.host.controller.state;
3648
+ const control = event.ctrlKey || event.metaKey;
3649
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
3650
+ if (control && key === "f") return { type: "search" };
3651
+ if (control && event.shiftKey && key === "w") return {
3652
+ type: "zoom",
3653
+ mode: "fit"
3654
+ };
3655
+ if (control && (key === "PageDown" || key === "PageUp")) return {
3656
+ type: "page",
3657
+ step: key === "PageDown" ? 1 : -1
3658
+ };
3659
+ if (key === "F5" && !control) return { type: "fullscreen" };
3660
+ if (editable(event.target)) return void 0;
3661
+ if (control && !event.shiftKey && key === "z") return {
3662
+ type: "history",
3663
+ key: "undo"
3664
+ };
3665
+ if (control && (key === "y" || event.shiftKey && key === "z")) return {
3666
+ type: "history",
3667
+ key: "redo"
3668
+ };
3669
+ if (control && key === "1") return {
3670
+ type: "tool",
3671
+ tool: "pointer"
3672
+ };
3673
+ if (control && key === "8") return {
3674
+ type: "tool",
3675
+ tool: "rectangle"
3676
+ };
3677
+ if (!control && key === "Delete" && state.selectedShape) return { type: "delete" };
3678
+ if (!control && key === "F2" && state.document) return {
3679
+ type: "reveal",
3680
+ panel: "edit",
3681
+ focusText: true
3682
+ };
3683
+ if (key === "Escape" && this.#tool !== "pointer" && !this.#draw.drawing) return {
3684
+ type: "tool",
3685
+ tool: "pointer"
3686
+ };
3687
+ }
3688
+ #shortcut(event) {
3689
+ if (event.defaultPrevented || event.isComposing || event.altKey) return;
3690
+ const action = this.#shortcutAction(event);
3691
+ if (!action) return;
3692
+ event.preventDefault();
3693
+ this.run(action);
3694
+ }
3695
+ render(state) {
3696
+ const { root, viewport } = this.host;
3697
+ const button = (name) => root.querySelector(`office-ui-button[command="${name}"]`);
3698
+ const editing = this.#canEdit(state);
3699
+ if (this.#tool === "rectangle" && !state.edit.sourceAvailable) this.#tool = "pointer";
3700
+ const page = state.document?.pages[state.pageIndex];
3701
+ button("undo").disabled = !state.edit.canUndo || state.edit.busy || state.loading;
3702
+ button("redo").disabled = !state.edit.canRedo || state.edit.busy || state.loading;
3703
+ button("delete").disabled = !editing || !state.selectedShape;
3704
+ button("pointer").setAttribute("pressed", String(this.#tool === "pointer"));
3705
+ const rectangle = button("rectangle");
3706
+ rectangle.setAttribute("pressed", String(this.#tool === "rectangle"));
3707
+ rectangle.disabled = !editing || !page;
3708
+ rectangle.title = state.edit.sourceAvailable ? "Rectangle (Ctrl+8)" : "Rectangle (Ctrl+8): open a .vsdx file to draw. Model-only documents are read only.";
3709
+ button("grid").setAttribute("pressed", String(this.#grid));
3710
+ button("grid").disabled = !page;
3711
+ for (const name of [
3712
+ "zoom-fit",
3713
+ "page-width",
3714
+ "actual-size"
3715
+ ]) button(name).disabled = !page;
3716
+ const fullscreen = button("fullscreen");
3717
+ const host = root.host;
3718
+ fullscreen.disabled = typeof host.requestFullscreen !== "function";
3719
+ fullscreen.setAttribute("pressed", String(root.ownerDocument.fullscreenElement === host));
3720
+ viewport.dataset.tool = this.#tool;
3721
+ viewport.dataset.grid = String(this.#grid);
3722
+ }
3723
+ };
3724
+ //#endregion
3725
+ //#region src/office-ui.ts
3726
+ /**
3727
+ * Visio-only glyphs (20x20 path data). Neutral Office glyphs come from ooxml-ui; add a glyph
3728
+ * there instead when another Office product could use it.
3729
+ */
3730
+ var VISIO_ICONS = {
3731
+ 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",
3732
+ visioShapeData: "M3 3.5h14v13H3ZM3 7.5h14M8 7.5v9M10.5 11h4M10.5 14h4",
3733
+ visioPagesPane: "M2.5 3h15v14h-15ZM7 3v14M3.8 6h1.9M3.8 9h1.9M3.8 12h1.9",
3734
+ visioInspectorPane: "M2.5 3h15v14h-15ZM13 3v14M14.5 6h1.5M14.5 9h1.5M14.5 12h1.5"
3735
+ };
3736
+ /** Define the shared Office controls the viewer chrome uses. Idempotent; browser only. */
3737
+ function registerViewerControls(registry) {
3738
+ for (const [name, path] of Object.entries(VISIO_ICONS)) if (!getIcon(name)) registerIcon(name, path);
3739
+ registerControls(registry);
3740
+ }
3741
+ //#endregion
3096
3742
  //#region src/export-svg.ts
3097
3743
  var MAX_SVG_EXPORT_BYTES = 16777216;
3098
3744
  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.";
@@ -3703,8 +4349,10 @@ var VisioViewerElement = class extends BaseElement {
3703
4349
  #root;
3704
4350
  #disposeInputs;
3705
4351
  #viewport;
3706
- #pageSelect;
3707
- #zoomLabel;
4352
+ #zoomSlider;
4353
+ #shapeStatus;
4354
+ #announcement;
4355
+ #commands;
3708
4356
  #status;
3709
4357
  #diagnostics;
3710
4358
  #toolbar;
@@ -3737,10 +4385,14 @@ var VisioViewerElement = class extends BaseElement {
3737
4385
  constructor() {
3738
4386
  super();
3739
4387
  this.#root = this.attachShadow({ mode: "open" });
3740
- this.#root.innerHTML = `<style>${viewerStyles}</style>${viewerChromeTemplate}`;
4388
+ registerViewerControls();
4389
+ this.#root.innerHTML = `<style>${viewerStyles}${canvasAndRibbonStyles}</style>${viewerChromeTemplate}`;
4390
+ const workspace = this.#root.querySelector(".workspace");
4391
+ workspace.before(createRibbon(document));
4392
+ workspace.after(createPageTabs(document), createStatusBar(document));
3741
4393
  this.#viewport = this.#root.querySelector(".viewport");
3742
- this.#pageSelect = this.#root.querySelector("select");
3743
- this.#zoomLabel = this.#root.querySelector("output");
4394
+ this.#zoomSlider = this.#root.querySelector("office-ui-zoom-slider");
4395
+ this.#shapeStatus = this.#root.querySelector("[data-shape-status]");
3744
4396
  this.#status = this.#root.querySelector("[data-status]");
3745
4397
  this.#diagnostics = this.#root.querySelector("[data-diagnostics]");
3746
4398
  this.#toolbar = this.#root.querySelector(".toolbar");
@@ -3751,6 +4403,23 @@ var VisioViewerElement = class extends BaseElement {
3751
4403
  this.#notesPanel = this.#root.querySelector(".notes");
3752
4404
  this.#inspector = this.#root.querySelector(".shape-inspector");
3753
4405
  this.#chrome = new ViewerChrome(this.#root, this.controller);
4406
+ this.#commands = new ViewerCommands({
4407
+ root: this.#root,
4408
+ viewport: this.#viewport,
4409
+ controller: this.controller,
4410
+ fit: (mode) => this.#fit(mode),
4411
+ togglePane: (pane) => this.#chrome.togglePane(pane),
4412
+ reveal: (panel, focusText) => this.#chrome.reveal(panel, focusText),
4413
+ focusSearch: () => {
4414
+ this.#chrome.showTab("home");
4415
+ this.#search.input.focus();
4416
+ this.#search.input.select();
4417
+ },
4418
+ announce: (message) => {
4419
+ this.#announcement = message;
4420
+ this.#status.textContent = message;
4421
+ }
4422
+ });
3754
4423
  this.#disposeInputs = this.#wireInputs();
3755
4424
  this.#unsubscribe = this.controller.subscribe((state) => this.#render(state));
3756
4425
  this.#eventUnsubscribe = this.controller.onEvent((name, detail) => {
@@ -3824,13 +4493,24 @@ var VisioViewerElement = class extends BaseElement {
3824
4493
  }
3825
4494
  fit() {
3826
4495
  this.#assertAlive();
4496
+ this.#fit("page");
4497
+ }
4498
+ /** Visio Fit to Window shows the whole page; Page Width fills the canvas width. */
4499
+ #fit(mode) {
3827
4500
  const page = this.document?.pages[this.pageIndex];
3828
4501
  if (!page) return;
3829
4502
  const style = this.ownerDocument.defaultView?.getComputedStyle(this.#viewport);
3830
4503
  const padding = (value) => Number.parseFloat(value ?? "") || 0;
3831
4504
  const width = Math.max(1, this.#viewport.clientWidth - padding(style?.paddingLeft) - padding(style?.paddingRight));
3832
4505
  const height = Math.max(1, this.#viewport.clientHeight - padding(style?.paddingTop) - padding(style?.paddingBottom));
3833
- this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4506
+ if (mode === "page") {
4507
+ this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4508
+ return;
4509
+ }
4510
+ const scrollbar = this.#viewport.offsetWidth - this.#viewport.clientWidth;
4511
+ const fitted = width / (page.width * 96);
4512
+ const reserve = page.height * 96 * fitted > height && scrollbar <= 0 ? 17 : 0;
4513
+ this.zoom = Math.max(1, width - reserve - 1) / (page.width * 96);
3834
4514
  }
3835
4515
  setLayerVisibility(pageId, layerId, visible) {
3836
4516
  this.#assertAlive();
@@ -3900,16 +4580,18 @@ var VisioViewerElement = class extends BaseElement {
3900
4580
  }
3901
4581
  #wireInputs() {
3902
4582
  const disposeChrome = this.#chrome.wire();
4583
+ const disposeCommands = this.#commands.wire();
3903
4584
  const disposeEdit = this.#edit.wire();
3904
4585
  const disposeLayers = wireLayerControls(this.#layers, this.controller);
3905
4586
  const disposeInputs = wireViewerInputs({
3906
4587
  viewport: this.#viewport,
3907
4588
  commandRoot: this.#root,
3908
- pageSelect: this.#pageSelect,
4589
+ zoomSlider: this.#zoomSlider,
3909
4590
  searchInput: this.#search.input
3910
- }, this.controller, () => this.fit());
4591
+ }, this.controller, (mode) => this.#fit(mode));
3911
4592
  return () => {
3912
4593
  disposeChrome();
4594
+ disposeCommands();
3913
4595
  disposeInputs();
3914
4596
  disposeLayers();
3915
4597
  disposeEdit();
@@ -3928,14 +4610,7 @@ var VisioViewerElement = class extends BaseElement {
3928
4610
  this.#document = state.document;
3929
4611
  this.#renderedPage = state.pageIndex;
3930
4612
  this.#renderedLayerOverrides = state.layerVisibilityOverrides;
3931
- this.#pageSelect.replaceChildren();
3932
- for (const [index, candidate] of (state.document?.pages ?? []).entries()) {
3933
- const option = document.createElement("option");
3934
- option.value = String(index);
3935
- option.textContent = candidate.name + (candidate.isBackground ? " (background)" : "");
3936
- this.#pageSelect.append(option);
3937
- }
3938
- this.#pageSelect.value = String(state.pageIndex);
4613
+ this.#announcement = void 0;
3939
4614
  this.#renderWarnings = [];
3940
4615
  if (state.document && page) {
3941
4616
  const result = renderPage(state.document, page, { layerVisibilityOverrides: state.layerVisibilityOverrides });
@@ -3962,6 +4637,7 @@ var VisioViewerElement = class extends BaseElement {
3962
4637
  if (svg && page) {
3963
4638
  svg.style.width = `${page.width * 96 * state.zoom}px`;
3964
4639
  svg.style.height = `${page.height * 96 * state.zoom}px`;
4640
+ svg.style.setProperty("--vv-inch", `${96 * state.zoom}px`);
3965
4641
  }
3966
4642
  const searchResult = state.search.results[state.search.activeIndex];
3967
4643
  const selection = state.selectedShape;
@@ -3989,16 +4665,20 @@ var VisioViewerElement = class extends BaseElement {
3989
4665
  this.#renderedSelection = selection ? { ...selection } : null;
3990
4666
  }
3991
4667
  if (!searchResult) this.#revealedSearchResult = void 0;
3992
- this.#zoomLabel.value = `${Math.round(state.zoom * 100)}%`;
3993
- this.#pageSelect.disabled = !page;
4668
+ this.#zoomSlider.value = Math.round(state.zoom * 100);
4669
+ this.#zoomSlider.disabled = !page;
4670
+ const inspected = selectedShape(state.document, state.selectedShape, state.pageIndex);
4671
+ const inches = (value) => `${+value.toFixed(3)} in`;
4672
+ this.#shapeStatus.setAttribute("value", inspected ? `Width: ${inches(inspected.width)} Height: ${inches(inspected.height)}` : "");
3994
4673
  for (const button of this.#root.querySelectorAll("[data-action]")) button.disabled = !page;
3995
4674
  renderSearchControls(this.#search, state);
3996
4675
  this.#edit.render(state);
3997
4676
  if (changed) renderLayerControls(this.#layers, state);
3998
4677
  else this.#layers.querySelector("[data-layer-reset=\"all\"]").disabled = state.layerVisibilityOverrides.length === 0;
3999
4678
  this.#chrome.render(state, this.#notes.children.length);
4679
+ this.#commands.render(state);
4000
4680
  this.#viewport.setAttribute("aria-busy", String(state.loading || state.edit.busy));
4001
- this.#status.textContent = state.loading ? "Opening diagram…" : state.error?.message ?? (page ? `${page.name} · ${page.shapes.length} top-level shapes` : "No diagram open");
4681
+ 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");
4002
4682
  const warnings = (state.document?.diagnostics.length ?? 0) + this.#renderWarnings.length;
4003
4683
  this.#diagnostics.textContent = warnings ? `${this.#notes.children.length} compatibility notes` : "Local-only viewing";
4004
4684
  }
@@ -4208,6 +4888,23 @@ function viewerHandle(current) {
4208
4888
  }
4209
4889
  };
4210
4890
  }
4891
+ function viewerStateSource(current) {
4892
+ const controller = () => {
4893
+ try {
4894
+ return current()?.controller;
4895
+ } catch {
4896
+ return;
4897
+ }
4898
+ };
4899
+ return {
4900
+ subscribe(onChange) {
4901
+ return controller()?.subscribe(() => onChange()) ?? (() => {});
4902
+ },
4903
+ getSnapshot() {
4904
+ return controller()?.state ?? null;
4905
+ }
4906
+ };
4907
+ }
4211
4908
  //#endregion
4212
4909
  //#region packages/bindings/src/solid.tsx
4213
4910
  var _tmpl$ = /*#__PURE__*/ template(`<div>`);
@@ -4261,5 +4958,19 @@ function VisioViewer(props) {
4261
4958
  return _el$;
4262
4959
  })();
4263
4960
  }
4961
+ /**
4962
+ * Reactive viewer state as a Solid accessor for a handle accessor (for example a signal set by
4963
+ * `viewerRef`). Follows handle changes and cleans up with the owning scope.
4964
+ */
4965
+ function createVisioViewerState(handle) {
4966
+ const [state, setState] = createSignal(null);
4967
+ createEffect(() => {
4968
+ const current = handle();
4969
+ const source = viewerStateSource(() => current);
4970
+ setState(() => source.getSnapshot());
4971
+ onCleanup(source.subscribe(() => setState(() => source.getSnapshot())));
4972
+ });
4973
+ return state;
4974
+ }
4264
4975
  //#endregion
4265
- 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 };
4976
+ 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 };