visio-react-viewer 0.1.1 → 0.3.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,5 +1,7 @@
1
- import { VISIO_RASTER_IMAGE_LIMITS, VISIO_VISIBILITY_LIMITS, getVisioPageLayers, inspectVisioRasterImage, parseVsdx, resolveVisioPageVisibility, validateVisioForeignVector } from "ooxml-core/visio";
2
- import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
1
+ import { VISIO_RASTER_IMAGE_LIMITS, VISIO_VISIBILITY_LIMITS, getVisioPageLayers, inspectVisioRasterImage, loadVisio, resolveVisioPageVisibility, validateVisioForeignVector } from "ooxml-core/visio";
2
+ import { registerControls } from "ooxml-ui/controls";
3
+ import { getIcon, registerIcon } from "ooxml-ui/icons";
4
+ import { createElement, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useSyncExternalStore } from "react";
3
5
  export * from "visio-core";
4
6
  //#region src/document-history.ts
5
7
  var EMPTY_EDIT_STATE = Object.freeze({
@@ -270,7 +272,7 @@ function member(value, choices, label) {
270
272
  }
271
273
  /** Defensive display limits also protect callers supplying their own typed scenes. */
272
274
  function assertViewableDocument(model) {
273
- if (model.format !== "vsdx" || !Array.isArray(model.pages) || model.pages.length > 256 || !Array.isArray(model.diagnostics) || model.diagnostics.length > 2e3) throw new Error("The scene has an invalid page list.");
275
+ if (model.format !== "vsdx" && model.format !== "vsd" || !Array.isArray(model.pages) || model.pages.length > 256 || !Array.isArray(model.diagnostics) || model.diagnostics.length > 2e3) throw new Error("The scene has an invalid page list.");
274
276
  const seen = /* @__PURE__ */ new Set();
275
277
  let pathBytes = 0, textBytes = 0, runCount = 0, imageBytes = 0, geometryCount = 0, uniquePixels = 0, drawnPixels = 0, layerCount = 0, paragraphCount = 0, gradientStops = 0;
276
278
  const imageResources = /* @__PURE__ */ new Map();
@@ -532,7 +534,7 @@ function createWorkerEditor(factory, timeoutMs = 2e4) {
532
534
  if (typeof Worker === "undefined") throw new Error("Editing requires browser worker support.");
533
535
  return new Worker(new URL(
534
536
  /* @vite-ignore */
535
- new URL("assets/edit-worker-DRgXgRud.js", import.meta.url).href,
537
+ new URL("assets/edit-worker-CZbtTOr4.js", import.meta.url).href,
536
538
  "" + import.meta.url
537
539
  ), { type: "module" });
538
540
  });
@@ -788,9 +790,10 @@ var ViewerController = class {
788
790
  #sourceGeneration = 0;
789
791
  #searchIndex = null;
790
792
  #history = null;
793
+ #sourceFormat = null;
791
794
  #editId = 0;
792
795
  #visible = documentVisibility(null);
793
- constructor(parser = parseVsdx, listenerError = (error) => {
796
+ constructor(parser = loadVisio, listenerError = (error) => {
794
797
  if (typeof globalThis.reportError === "function") globalThis.reportError(error);
795
798
  else console.error("Visio viewer listener failed:", error);
796
799
  }, editor = createWorkerEditor()) {
@@ -830,6 +833,7 @@ var ViewerController = class {
830
833
  ++this.#sourceGeneration;
831
834
  this.#invalidateEdit();
832
835
  this.#history = null;
836
+ this.#sourceFormat = null;
833
837
  this.#loadId++;
834
838
  this.parser.cancel?.();
835
839
  this.#searchIndex = null;
@@ -972,7 +976,7 @@ var ViewerController = class {
972
976
  this.#change({
973
977
  loading: true,
974
978
  error: null,
975
- edit: this.#history?.state ?? EMPTY_EDIT_STATE
979
+ edit: this.#sourceEditState()
976
980
  });
977
981
  if (this.#destroyed || id !== this.#loadId) return;
978
982
  let document;
@@ -983,10 +987,10 @@ var ViewerController = class {
983
987
  if (this.#destroyed || id !== this.#loadId) return;
984
988
  if (bytes.byteLength > 33554432) throw new Error("This viewer accepts files up to 32 MiB.");
985
989
  const owned = bytes instanceof Uint8Array ? Uint8Array.from(bytes) : new Uint8Array(bytes.slice(0));
986
- history = new DocumentHistory(owned);
987
990
  document = await this.parser(Uint8Array.from(owned));
988
991
  if (this.#destroyed || id !== this.#loadId) return;
989
992
  assertViewableDocument(document);
993
+ history = new DocumentHistory(owned);
990
994
  visible = documentVisibility(document);
991
995
  } catch (cause) {
992
996
  if (this.#destroyed || id !== this.#loadId) return;
@@ -999,13 +1003,14 @@ var ViewerController = class {
999
1003
  throw error;
1000
1004
  }
1001
1005
  this.#history = history;
1006
+ this.#sourceFormat = document.format;
1002
1007
  this.#searchIndex = null;
1003
1008
  this.#visible = visible;
1004
1009
  ++this.#documentGeneration;
1005
1010
  ++this.#sourceGeneration;
1006
1011
  this.#change({
1007
1012
  document,
1008
- edit: history.state,
1013
+ edit: document.format === "vsdx" ? history.state : EMPTY_EDIT_STATE,
1009
1014
  pageIndex: 0,
1010
1015
  loading: false,
1011
1016
  error: null,
@@ -1022,7 +1027,7 @@ var ViewerController = class {
1022
1027
  this.#invalidateEdit();
1023
1028
  this.#change({
1024
1029
  loading: false,
1025
- edit: this.#history?.state ?? EMPTY_EDIT_STATE
1030
+ edit: this.#sourceEditState()
1026
1031
  });
1027
1032
  }
1028
1033
  destroy() {
@@ -1030,6 +1035,7 @@ var ViewerController = class {
1030
1035
  this.#destroyed = true;
1031
1036
  this.#invalidateEdit();
1032
1037
  this.#history = null;
1038
+ this.#sourceFormat = null;
1033
1039
  ++this.#documentGeneration;
1034
1040
  ++this.#sourceGeneration;
1035
1041
  ++this.#loadId;
@@ -1074,11 +1080,11 @@ var ViewerController = class {
1074
1080
  if (!this.#state.edit.busy) return;
1075
1081
  this.#invalidateEdit();
1076
1082
  this.parser.cancel?.();
1077
- this.#change({ edit: this.#history?.state ?? EMPTY_EDIT_STATE });
1083
+ this.#change({ edit: this.#sourceEditState() });
1078
1084
  }
1079
1085
  exportVsdx() {
1080
1086
  this.#assertAlive();
1081
- if (!this.#history) throw new Error("Load a VSDX file before downloading a source-backed copy.");
1087
+ if (!this.#history || this.#sourceFormat !== "vsdx" || this.#state.document?.format !== "vsdx") throw new Error("Load a VSDX file before downloading a source-backed copy.");
1082
1088
  if (this.#state.edit.busy || this.#state.loading) throw new Error("Wait for the current document operation before downloading.");
1083
1089
  return this.#history.export();
1084
1090
  }
@@ -1086,10 +1092,13 @@ var ViewerController = class {
1086
1092
  ++this.#editId;
1087
1093
  this.editor.cancel?.();
1088
1094
  }
1095
+ #sourceEditState() {
1096
+ return this.#sourceFormat === "vsdx" && this.#state.document?.format === "vsdx" ? this.#history?.state ?? EMPTY_EDIT_STATE : EMPTY_EDIT_STATE;
1097
+ }
1089
1098
  async #mutate(kind, commands) {
1090
1099
  this.#assertAlive();
1091
1100
  const history = this.#history;
1092
- if (!history) throw new Error("Load a VSDX file before editing. Model-only documents are read only.");
1101
+ if (!history || this.#sourceFormat !== "vsdx" || this.#state.document?.format !== "vsdx") throw new Error("Load a VSDX file before editing. Model-only documents are read only.");
1093
1102
  if (this.#state.loading || this.#state.edit.busy) throw new Error("Another document operation is in progress.");
1094
1103
  const target = kind === "undo" ? history.undoTarget : kind === "redo" ? history.redoTarget : void 0;
1095
1104
  if (kind !== "edit" && !target) return;
@@ -1115,6 +1124,7 @@ var ViewerController = class {
1115
1124
  } else document = await this.parser(Uint8Array.from(target.bytes));
1116
1125
  assertCurrent();
1117
1126
  assertViewableDocument(document);
1127
+ if (document.format !== "vsdx") throw new Error("A VSDX edit or history operation cannot change the source format.");
1118
1128
  if (edited && !edited.changedParts.length) {
1119
1129
  this.#change({ edit: history.state });
1120
1130
  return;
@@ -1204,10 +1214,10 @@ var eventKeys = [
1204
1214
  //#region src/worker-parser.ts
1205
1215
  /** One disposable worker per document prevents old parses and long CPU tasks from occupying the UI. */
1206
1216
  function createWorkerParser(factory, timeoutMs = 15e3) {
1207
- if (!factory && typeof Worker === "undefined") return parseVsdx;
1217
+ if (!factory && typeof Worker === "undefined") return loadVisio;
1208
1218
  const create = factory ?? (() => new Worker(new URL(
1209
1219
  /* @vite-ignore */
1210
- new URL("assets/parse-worker-B8EBQQpD.js", import.meta.url).href,
1220
+ new URL("assets/parse-worker-BEaLqUCJ.js", import.meta.url).href,
1211
1221
  "" + import.meta.url
1212
1222
  ), { type: "module" }));
1213
1223
  let cancel;
@@ -2143,7 +2153,7 @@ function selection(target) {
2143
2153
  }
2144
2154
  /** Own every DOM listener and abort them together when the surface is disposed. */
2145
2155
  function wireViewerInputs(controls, controller, fit) {
2146
- const { viewport, commandRoot, pageSelect, searchInput } = controls;
2156
+ const { viewport, commandRoot, zoomSlider, searchInput } = controls;
2147
2157
  const events = new ((viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
2148
2158
  const options = { signal: events.signal };
2149
2159
  searchInput.addEventListener("input", () => controller.setSearchQuery(searchInput.value), options);
@@ -2159,13 +2169,9 @@ function wireViewerInputs(controls, controller, fit) {
2159
2169
  controller.setSearchQuery("");
2160
2170
  }
2161
2171
  }, options);
2162
- pageSelect.addEventListener("change", () => controller.setPage(Number(pageSelect.value)), options);
2172
+ zoomSlider.addEventListener("input", () => controller.setZoom(zoomSlider.value / 100), options);
2163
2173
  commandRoot.addEventListener("click", (event) => {
2164
2174
  const action = event.target?.closest?.("button")?.dataset.action;
2165
- if (action === "in") controller.setZoom(controller.state.zoom * 1.25);
2166
- if (action === "out") controller.setZoom(controller.state.zoom / 1.25);
2167
- if (action === "fit") fit();
2168
- if (action === "actual") controller.setZoom(1);
2169
2175
  if (action === "search-next") controller.nextSearchResult();
2170
2176
  if (action === "search-previous") controller.previousSearchResult();
2171
2177
  }, options);
@@ -2204,7 +2210,7 @@ function wireViewerInputs(controls, controller, fit) {
2204
2210
  }
2205
2211
  if (event.key === "0") {
2206
2212
  event.preventDefault();
2207
- fit();
2213
+ fit("page");
2208
2214
  }
2209
2215
  }, options);
2210
2216
  return () => events.abort();
@@ -2308,13 +2314,7 @@ button:disabled { opacity:.4; cursor:default; }
2308
2314
  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; }
2309
2315
  button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-accent-soft); }
2310
2316
  .toolbar { flex:none; min-width:0; border-bottom:1px solid var(--_vv-border); background:var(--_vv-surface); }
2311
- .ribbon-primary { height:32px; display:flex; align-items:center; gap:6px; padding:2px 8px; }
2312
- .page-picker { display:flex; min-width:0; align-items:center; gap:8px; color:var(--_vv-muted); font-size:11px; }
2313
- .page-picker select { width:190px; max-width:42vw; height:26px; min-height:26px; padding:2px 6px; font-size:11px; color:var(--_vv-ink); }
2314
- .page-stepper { display:flex; align-items:center; gap:2px; }
2315
- .page-stepper button { width:26px; height:26px; min-height:26px; padding:0; font-size:18px; line-height:22px; background:transparent; border-color:transparent; }
2316
2317
  .spacer { flex:1; }
2317
- .local-label { color:var(--_vv-muted); font-size:10px; white-space:nowrap; }
2318
2318
  .ribbon-tabs { height:35px; display:flex; align-items:stretch; padding:0 4px; border-bottom:1px solid var(--_vv-border); }
2319
2319
  .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; }
2320
2320
  .ribbon-tabs button[aria-selected="true"] { color:var(--_vv-accent); }
@@ -2324,15 +2324,6 @@ button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-acce
2324
2324
  .group-label { font-size:10px; line-height:12px; color:var(--_vv-muted); text-align:center; }
2325
2325
  .ribbon-actions { display:flex; align-items:stretch; gap:2px; flex:1; }
2326
2326
  .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; }
2327
- .command-icon { display:grid; place-items:center; width:26px; height:26px; color:var(--_vv-accent); }
2328
- .command-icon::before { content:""; width:24px; height:24px; background:currentColor; mask:var(--command-symbol) center / contain no-repeat; }
2329
- [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"); }
2330
- [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"); }
2331
- [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"); }
2332
- [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"); }
2333
- [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"); }
2334
- [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"); }
2335
- .ribbon-command[data-chrome="edit"] .command-icon { font-family:ui-serif,Georgia,serif; text-decoration:underline; text-decoration-thickness:1px; text-underline-offset:3px; }
2336
2327
  .ribbon-hint { align-self:center; flex:none; margin:0; padding:0 16px; font-size:11px; line-height:18px; color:var(--_vv-muted); }
2337
2328
  .search-group { width:330px; }
2338
2329
  .search-controls { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
@@ -2404,11 +2395,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2404
2395
  .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; }
2405
2396
  .status-message { display:flex; gap:16px; min-width:0; }
2406
2397
  .status-message span { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
2407
- .zoom-controls { display:flex; flex:none; align-items:center; gap:3px; }
2408
- .zoom-controls button { min-height:24px; height:24px; padding:1px 6px; border-color:transparent; background:transparent; font-size:10px; }
2409
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:24px; padding:0; font-size:17px; line-height:20px; }
2410
- .zoom-divider { width:1px; height:14px; background:var(--_vv-border); margin:0 3px; }
2411
- .zoom { min-width:40px; font-size:10px; text-align:center; font-variant-numeric:tabular-nums; }
2412
2398
  .empty { max-width:360px; margin:60px auto; padding:16px; text-align:center; color:var(--_vv-muted); font-size:12px; }
2413
2399
  .empty strong { display:block; color:var(--_vv-ink); font-size:18px; font-weight:500; margin-bottom:8px; }
2414
2400
  @media(max-width:1100px) { .ribbon-hint { display:none; } [data-diagnostics] { display:none; } }
@@ -2416,11 +2402,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2416
2402
  button,select,input,textarea { min-height:44px; }
2417
2403
  button { min-width:44px; }
2418
2404
  summary { min-height:44px; line-height:44px; }
2419
- .ribbon-primary { height:52px; padding:4px 8px; }
2420
- .page-picker { flex:1; }
2421
- .page-picker select { width:100%; max-width:none; min-height:44px; height:44px; font-size:12px; }
2422
- .page-stepper button { width:44px; min-height:44px; height:44px; }
2423
- .local-label,.ribbon-primary>.spacer { display:none; }
2424
2405
  .ribbon-tabs { height:44px; }
2425
2406
  .ribbon-tabs button { min-height:44px; padding:8px 16px; }
2426
2407
  .ribbon-content { min-height:86px; }
@@ -2432,7 +2413,7 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2432
2413
  .search-controls button { min-width:44px; font-size:0; padding:3px; }
2433
2414
  .search-controls [data-action="search-previous"]::before { content:"‹"; font-size:22px; }
2434
2415
  .search-controls [data-action="search-next"]::before { content:"›"; font-size:22px; }
2435
- #home-panel>.ribbon-group:not(.search-group) { display:none; }
2416
+ #home-panel>.ribbon-group:not(.search-group):not(.undo-group) { display:none; }
2436
2417
  .ribbon-command { min-width:90px; }
2437
2418
  .workspace { display:block; overflow:auto; }
2438
2419
  .page-rail { width:100%; max-height:190px; border-right:0; border-bottom:1px solid var(--_vv-border); }
@@ -2449,35 +2430,22 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2449
2430
  .notes-strip button { min-height:44px; }
2450
2431
  .status { flex-wrap:wrap; gap:0; padding:4px 8px; }
2451
2432
  .status-message { width:100%; min-height:18px; }
2452
- .zoom-controls { justify-content:flex-end; width:100%; }
2453
- .zoom-controls button { min-height:44px; height:44px; min-width:44px; font-size:11px; }
2454
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; font-size:20px; }
2455
- .zoom { font-size:11px; }
2456
2433
  }
2457
2434
  @media(pointer:coarse) {
2458
2435
  button,select,input,textarea { min-height:44px; }
2459
2436
  button { min-width:44px; }
2460
2437
  summary,.layer-controls label { min-height:44px; line-height:44px; }
2461
- .ribbon-primary { min-height:52px; height:52px; }
2462
- .page-picker select,.page-stepper button { min-height:44px; height:44px; }
2463
- .page-stepper button { min-width:44px; }
2464
2438
  .ribbon-tabs { min-height:44px; height:44px; }
2465
2439
  .ribbon-tabs button { min-height:44px; }
2466
2440
  .ribbon-content { min-height:96px; }
2467
2441
  .search-group { width:380px; max-width:100%; }
2468
- .zoom-controls button,.notes-strip button { min-height:44px; height:44px; }
2469
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; }
2442
+ .notes-strip button { min-height:44px; height:44px; }
2470
2443
  .layer-controls input { min-height:20px; }
2471
2444
  }
2472
- /* Suite ribbon: tabs lead directly into command groups, with page navigation inside. */
2445
+ /* Suite ribbon: tabs lead directly into Visio command groups. */
2473
2446
  .ribbon-tabs { height:40px; }
2474
2447
  .ribbon-tabs button { min-height:39px; padding:8px 16px; }
2475
2448
  .ribbon-content { min-height:104px; padding:4px 4px; }
2476
- .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; }
2477
- .ribbon-primary .page-picker { flex-direction:column; align-items:start; gap:5px; }
2478
- .ribbon-primary .page-picker select { width:160px; max-width:100%; }
2479
- .ribbon-primary .spacer { display:none; }
2480
- .ribbon-primary .local-label { position:absolute; bottom:4px; left:0; width:100%; text-align:center; }
2481
2449
  .search-group { order:2; width:320px; }
2482
2450
  .ribbon-hint { order:3; padding:10px 18px; }
2483
2451
  .ribbon-command { min-width:82px; }
@@ -2486,13 +2454,9 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2486
2454
  .ribbon-tabs { height:44px; }
2487
2455
  .ribbon-tabs button { min-height:44px; }
2488
2456
  .ribbon-content { min-height:54px; padding:4px 0; }
2489
- .ribbon-primary { min-height:44px; height:44px; width:100%; padding:0 8px; border:0; flex-wrap:nowrap; gap:4px; order:0; }
2490
- .ribbon-primary .page-picker { flex-direction:row; flex:1; align-items:center; gap:8px; }
2491
- .ribbon-primary .page-picker select { flex:1; width:100%; min-width:0; }
2492
2457
  .search-group { flex:0 0 100%; width:100%; min-height:54px; }
2493
2458
  .search-feedback,.group-label { display:none; }
2494
2459
  .ribbon-content { overflow-x:auto; scroll-snap-type:x mandatory; }
2495
- .ribbon-primary,.search-group { scroll-snap-align:start; }
2496
2460
  .workspace { display:flex; position:relative; overflow:hidden; }
2497
2461
  .viewport { flex:1; width:100%; height:100%; min-height:0; padding:16px 4px; }
2498
2462
  .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; }
@@ -2521,9 +2485,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2521
2485
  .status-message { flex:1; }
2522
2486
  @media(max-width:760px) {
2523
2487
  #home-panel { overflow:visible; min-height:54px; padding:4px; }
2524
- .ribbon-primary { width:calc(100% - 92px); min-width:0; flex:1; padding:0 4px; }
2525
- .ribbon-primary .page-picker { font-size:0; gap:0; }
2526
- .ribbon-primary .page-picker select { font-size:12px; }
2527
2488
  .ribbon-tools { display:block; flex:0 0 84px; width:84px; order:1; }
2528
2489
  .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; }
2529
2490
  .ribbon-tools>summary::-webkit-details-marker { display:none; }
@@ -2535,14 +2496,347 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2535
2496
  .ribbon-tools .search-group { order:1; flex:auto; }
2536
2497
  .ribbon-tools .search-feedback { display:block; }
2537
2498
  .status { flex-wrap:nowrap; gap:4px; min-height:48px; }
2538
- .zoom-controls { width:auto; flex:1; gap:0; }
2539
- .zoom-divider { margin:0; }
2540
2499
  .status .notes-strip { display:none; }
2541
2500
  }
2542
2501
  @media(pointer:coarse) { .ribbon-tabs {height:45px;min-height:45px;} .ribbon-tabs button {min-height:44px;height:44px;} }
2543
2502
  @media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; } }
2544
2503
  `;
2545
2504
  //#endregion
2505
+ //#region src/styles/index.ts
2506
+ /**
2507
+ * Viewer stylesheets as text for the shadow root. New styles are CSS files here; the legacy
2508
+ * `styles.ts` string moves into this directory when it is next changed.
2509
+ */
2510
+ 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");
2511
+ //#endregion
2512
+ //#region src/ribbon-action.ts
2513
+ /** Event name shared by every ribbon control; detail is the typed action. */
2514
+ var RIBBON_ACTION_EVENT = "ribbon-action";
2515
+ /** Internal, non-composed event: it stays inside the viewer's shadow tree. */
2516
+ function emitRibbonAction(target, action) {
2517
+ target.dispatchEvent(new CustomEvent(RIBBON_ACTION_EVENT, {
2518
+ detail: action,
2519
+ bubbles: true
2520
+ }));
2521
+ }
2522
+ //#endregion
2523
+ //#region src/ribbon-parts.ts
2524
+ /**
2525
+ * A large stacked ribbon command. `id` is a stable name for state sync and tests; the typed
2526
+ * `action` is what runs. Labels, icons and shortcuts are static application strings.
2527
+ */
2528
+ function tool(doc, id, label, icon, action, options = {}) {
2529
+ const el = doc.createElement("office-ui-button");
2530
+ el.setAttribute("variant", "stacked");
2531
+ el.setAttribute("command", id);
2532
+ el.setAttribute("icon", icon);
2533
+ el.setAttribute("label", label);
2534
+ el.setAttribute("title", options.keys ? `${label} (${options.keys[1]})` : label);
2535
+ if (options.keys) el.setAttribute("keyshortcuts", options.keys[0]);
2536
+ if (options.pressed !== void 0) el.setAttribute("pressed", String(options.pressed));
2537
+ if (options.controls) el.setAttribute("aria-controls", options.controls);
2538
+ el.addEventListener("office-command", (event) => {
2539
+ event.stopPropagation();
2540
+ emitRibbonAction(el, action);
2541
+ });
2542
+ return el;
2543
+ }
2544
+ /** A labelled ribbon group (the shared `office-ui-ribbon-group`). */
2545
+ function group(doc, label, children, className = "") {
2546
+ const el = doc.createElement("office-ui-ribbon-group");
2547
+ el.setAttribute("label", label);
2548
+ if (className) el.className = className;
2549
+ el.append(...children);
2550
+ return el;
2551
+ }
2552
+ /** A keyboard-navigable row of groups (the shared `office-ui-toolbar`). */
2553
+ function commandRow(doc, label, children) {
2554
+ const el = doc.createElement("office-ui-toolbar");
2555
+ el.className = "ribbon-row";
2556
+ el.setAttribute("aria-label", label);
2557
+ el.append(...children);
2558
+ return el;
2559
+ }
2560
+ /** Muted two-line guidance at the end of a ribbon panel. */
2561
+ function hint(doc, lines) {
2562
+ const el = doc.createElement("p");
2563
+ el.className = "ribbon-hint";
2564
+ lines.forEach((line, index) => {
2565
+ if (index) el.append(doc.createElement("br"));
2566
+ el.append(line);
2567
+ });
2568
+ return el;
2569
+ }
2570
+ //#endregion
2571
+ //#region src/viewer-search.ts
2572
+ /** Builds the Find group: a search field, previous/next result buttons and a live status. */
2573
+ function createSearchGroup(doc) {
2574
+ const group = doc.createElement("div");
2575
+ group.className = "ribbon-group search-group";
2576
+ group.setAttribute("role", "group");
2577
+ group.setAttribute("aria-label", "Find");
2578
+ const controls = doc.createElement("div");
2579
+ controls.className = "search-controls";
2580
+ controls.setAttribute("role", "search");
2581
+ controls.setAttribute("aria-label", "Diagram text search");
2582
+ const label = doc.createElement("label");
2583
+ label.append("Find ");
2584
+ const input = doc.createElement("input");
2585
+ input.type = "search";
2586
+ input.setAttribute("aria-label", "Search diagram text");
2587
+ input.placeholder = "Text across pages";
2588
+ input.maxLength = TEXT_SEARCH_LIMITS.queryCharacters;
2589
+ input.autocomplete = "off";
2590
+ input.spellcheck = false;
2591
+ input.setAttribute("aria-describedby", "search-status");
2592
+ label.append(input);
2593
+ const step = (action, name, text) => {
2594
+ const button = doc.createElement("button");
2595
+ button.type = "button";
2596
+ button.dataset.action = action;
2597
+ button.setAttribute("aria-label", name);
2598
+ button.textContent = text;
2599
+ return button;
2600
+ };
2601
+ const status = doc.createElement("span");
2602
+ status.id = "search-status";
2603
+ status.setAttribute("role", "status");
2604
+ status.setAttribute("aria-live", "polite");
2605
+ status.setAttribute("aria-atomic", "true");
2606
+ controls.append(label, step("search-previous", "Previous matching shape", "Previous"), step("search-next", "Next matching shape", "Next"), status);
2607
+ const caption = doc.createElement("span");
2608
+ caption.className = "group-label";
2609
+ caption.textContent = "Find in drawing";
2610
+ group.append(controls, caption);
2611
+ return group;
2612
+ }
2613
+ function searchControls(root) {
2614
+ return {
2615
+ input: root.querySelector("input[type=\"search\"]"),
2616
+ status: root.querySelector("#search-status"),
2617
+ previous: root.querySelector("[data-action=\"search-previous\"]"),
2618
+ next: root.querySelector("[data-action=\"search-next\"]")
2619
+ };
2620
+ }
2621
+ function renderSearchControls(controls, state) {
2622
+ if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2623
+ controls.input.disabled = !state.document?.pages.length;
2624
+ controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2625
+ controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2626
+ const result = state.search.results[state.search.activeIndex];
2627
+ controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2628
+ }
2629
+ //#endregion
2630
+ //#region src/ribbon-home.ts
2631
+ /**
2632
+ * Visio's Home tab, limited to commands this build can honestly perform: Undo, Tools and
2633
+ * Editing. Clipboard, Font, Paragraph, Shape Styles and Arrange wait for core edit commands.
2634
+ * The compact (phone) layout folds every group but Undo into the Tools disclosure.
2635
+ */
2636
+ function buildHomePanel(doc, panel) {
2637
+ const tools = doc.createElement("details");
2638
+ tools.className = "ribbon-tools";
2639
+ tools.open = true;
2640
+ const summary = doc.createElement("summary");
2641
+ const caret = doc.createElement("span");
2642
+ caret.setAttribute("aria-hidden", "true");
2643
+ caret.textContent = "⌄";
2644
+ summary.append("Tools ", caret);
2645
+ const content = doc.createElement("div");
2646
+ content.className = "tools-content";
2647
+ content.append(group(doc, "Tools", [
2648
+ tool(doc, "pointer", "Pointer Tool", "pointer", {
2649
+ type: "tool",
2650
+ tool: "pointer"
2651
+ }, {
2652
+ keys: ["Control+1", "Ctrl+1"],
2653
+ pressed: true
2654
+ }),
2655
+ tool(doc, "rectangle", "Rectangle", "rectangle", {
2656
+ type: "tool",
2657
+ tool: "rectangle"
2658
+ }, {
2659
+ keys: ["Control+8", "Ctrl+8"],
2660
+ pressed: false
2661
+ }),
2662
+ tool(doc, "edit", "Edit text", "pencil", {
2663
+ type: "reveal",
2664
+ panel: "edit"
2665
+ }, { keys: ["F2", "F2"] })
2666
+ ]), createSearchGroup(doc), group(doc, "Editing", [
2667
+ tool(doc, "delete", "Delete", "trash", { type: "delete" }, { keys: ["Delete", "Del"] }),
2668
+ tool(doc, "layers", "Layers", "visioLayers", {
2669
+ type: "reveal",
2670
+ panel: "layers"
2671
+ }),
2672
+ tool(doc, "selection", "Shape details", "visioShapeData", {
2673
+ type: "reveal",
2674
+ panel: "selection"
2675
+ })
2676
+ ]));
2677
+ tools.append(summary, content);
2678
+ panel.append(commandRow(doc, "Home commands", [group(doc, "Undo", [tool(doc, "undo", "Undo", "undo", {
2679
+ type: "history",
2680
+ key: "undo"
2681
+ }, { keys: ["Control+Z", "Ctrl+Z"] }), tool(doc, "redo", "Redo", "redo", {
2682
+ type: "history",
2683
+ key: "redo"
2684
+ }, { keys: ["Control+Y", "Ctrl+Y"] })], "undo-group"), tools]), hint(doc, ["Rectangle: drag on the page to draw.", "Editing is experimental. Keep your original file."]));
2685
+ }
2686
+ //#endregion
2687
+ //#region src/ribbon-view.ts
2688
+ /** Visio's View tab: Views, Show (grid and task panes) and Zoom. */
2689
+ function buildViewPanel(doc, panel) {
2690
+ panel.append(commandRow(doc, "View commands", [
2691
+ group(doc, "Views", [tool(doc, "fullscreen", "Full Screen", "fullscreen", { type: "fullscreen" }, {
2692
+ keys: ["F5", "F5"],
2693
+ pressed: false
2694
+ })]),
2695
+ group(doc, "Show", [
2696
+ tool(doc, "grid", "Grid", "grid", { type: "grid" }, { pressed: false }),
2697
+ tool(doc, "pages", "Pages pane", "visioPagesPane", {
2698
+ type: "pane",
2699
+ pane: "pages"
2700
+ }, {
2701
+ pressed: true,
2702
+ controls: "page-rail"
2703
+ }),
2704
+ tool(doc, "inspector", "Inspector pane", "visioInspectorPane", {
2705
+ type: "pane",
2706
+ pane: "inspector"
2707
+ }, {
2708
+ pressed: true,
2709
+ controls: "inspector-pane"
2710
+ }),
2711
+ tool(doc, "notes", "Review notes", "message", {
2712
+ type: "reveal",
2713
+ panel: "notes"
2714
+ })
2715
+ ]),
2716
+ group(doc, "Zoom", [
2717
+ tool(doc, "zoom-fit", "Fit to Window", "fitPage", {
2718
+ type: "zoom",
2719
+ mode: "fit"
2720
+ }, { keys: ["Control+Shift+W", "Ctrl+Shift+W"] }),
2721
+ tool(doc, "page-width", "Page Width", "pageWidth", {
2722
+ type: "zoom",
2723
+ mode: "width"
2724
+ }),
2725
+ tool(doc, "actual-size", "100%", "search", {
2726
+ type: "zoom",
2727
+ mode: "actual"
2728
+ })
2729
+ ])
2730
+ ]), hint(doc, ["Ctrl+wheel or + / − to zoom.", "Ctrl+Page Up / Page Down changes page."]));
2731
+ }
2732
+ //#endregion
2733
+ //#region src/ribbon.ts
2734
+ var TABS = [[
2735
+ "home",
2736
+ "Home",
2737
+ buildHomePanel
2738
+ ], [
2739
+ "view",
2740
+ "View",
2741
+ buildViewPanel
2742
+ ]];
2743
+ /**
2744
+ * The Visio ribbon: a tab list and one panel per tab. Tab switching and keyboard movement
2745
+ * belong to the chrome; commands emit `ribbon-action` events for the router.
2746
+ */
2747
+ function createRibbon(doc) {
2748
+ const toolbar = doc.createElement("div");
2749
+ toolbar.className = "toolbar";
2750
+ toolbar.setAttribute("role", "group");
2751
+ toolbar.setAttribute("aria-label", "Diagram controls");
2752
+ const tabs = doc.createElement("div");
2753
+ tabs.className = "ribbon-tabs";
2754
+ tabs.setAttribute("role", "tablist");
2755
+ tabs.setAttribute("aria-label", "Ribbon");
2756
+ toolbar.append(tabs);
2757
+ for (const [key, name, build] of TABS) {
2758
+ const selected = key === "home";
2759
+ const tab = doc.createElement("button");
2760
+ tab.type = "button";
2761
+ tab.id = `${key}-tab`;
2762
+ tab.dataset.tab = key;
2763
+ tab.textContent = name;
2764
+ tab.setAttribute("role", "tab");
2765
+ tab.setAttribute("aria-selected", String(selected));
2766
+ tab.setAttribute("aria-controls", `${key}-panel`);
2767
+ if (!selected) tab.tabIndex = -1;
2768
+ const panel = doc.createElement("div");
2769
+ panel.className = "ribbon-content";
2770
+ panel.id = `${key}-panel`;
2771
+ panel.setAttribute("role", "tabpanel");
2772
+ panel.setAttribute("aria-labelledby", tab.id);
2773
+ panel.hidden = !selected;
2774
+ build(doc, panel);
2775
+ tabs.append(tab);
2776
+ toolbar.append(panel);
2777
+ }
2778
+ return toolbar;
2779
+ }
2780
+ //#endregion
2781
+ //#region src/status-bar.ts
2782
+ /**
2783
+ * Visio's status bar on the shared `office-ui-status-bar`: page and shape metrics, a live
2784
+ * message region, compatibility notes, the host footer slot and the shared zoom slider.
2785
+ */
2786
+ function createStatusBar(doc) {
2787
+ const bar = doc.createElement("office-ui-status-bar");
2788
+ bar.className = "status";
2789
+ bar.setAttribute("label", "Status bar");
2790
+ const item = (name) => {
2791
+ const el = doc.createElement("office-ui-status-item");
2792
+ el.setAttribute(name, "");
2793
+ el.setAttribute("label", "");
2794
+ el.setAttribute("value", "");
2795
+ return el;
2796
+ };
2797
+ const message = doc.createElement("div");
2798
+ message.className = "status-message";
2799
+ message.setAttribute("role", "status");
2800
+ const text = doc.createElement("span");
2801
+ text.dataset.status = "";
2802
+ const diagnostics = doc.createElement("span");
2803
+ diagnostics.dataset.diagnostics = "";
2804
+ message.append(text, diagnostics);
2805
+ const notes = doc.createElement("div");
2806
+ notes.className = "notes-strip";
2807
+ const notesButton = doc.createElement("button");
2808
+ notesButton.type = "button";
2809
+ notesButton.dataset.chrome = "notes";
2810
+ notesButton.setAttribute("aria-controls", "inspector-pane");
2811
+ notesButton.setAttribute("aria-expanded", "false");
2812
+ const count = doc.createElement("span");
2813
+ count.className = "notes-count";
2814
+ count.dataset.noteCount = "";
2815
+ notesButton.append("Notes", count);
2816
+ const local = doc.createElement("span");
2817
+ local.textContent = "Files stay in your browser";
2818
+ notes.append(notesButton, local);
2819
+ const footer = doc.createElement("slot");
2820
+ footer.name = "workspace-footer";
2821
+ const zoom = doc.createElement("office-ui-zoom-slider");
2822
+ zoom.slot = "end";
2823
+ zoom.className = "zoom-controls";
2824
+ zoom.setAttribute("label", "Canvas zoom");
2825
+ zoom.setAttribute("fit", "Fit page to current window");
2826
+ zoom.setAttribute("value", "100");
2827
+ bar.append(item("data-page-status"), item("data-shape-status"), message, notes, footer, zoom);
2828
+ return bar;
2829
+ }
2830
+ /** Visio's page tabs under the drawing, on the shared document tab strip. */
2831
+ function createPageTabs(doc) {
2832
+ const strip = doc.createElement("office-ui-tab-strip");
2833
+ strip.className = "page-tabs";
2834
+ strip.setAttribute("label", "Pages");
2835
+ strip.setAttribute("previous-label", "Previous page");
2836
+ strip.setAttribute("next-label", "Next page");
2837
+ return strip;
2838
+ }
2839
+ //#endregion
2546
2840
  //#region src/edit-error.ts
2547
2841
  /** Core error codes remain visible for a refused transaction; document content is plain text. */
2548
2842
  function editErrorMessage(error) {
@@ -2830,59 +3124,13 @@ var ViewerEditControls = class {
2830
3124
  }
2831
3125
  };
2832
3126
  //#endregion
2833
- //#region src/viewer-search.ts
2834
- 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>`;
2835
- function searchControls(root) {
2836
- return {
2837
- input: root.querySelector("input[type=\"search\"]"),
2838
- status: root.querySelector("#search-status"),
2839
- previous: root.querySelector("[data-action=\"search-previous\"]"),
2840
- next: root.querySelector("[data-action=\"search-next\"]")
2841
- };
2842
- }
2843
- function renderSearchControls(controls, state) {
2844
- if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2845
- controls.input.disabled = !state.document?.pages.length;
2846
- controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2847
- controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2848
- const result = state.search.results[state.search.activeIndex];
2849
- controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2850
- }
2851
- //#endregion
2852
3127
  //#region src/viewer-chrome.ts
2853
- /** Only static application markup belongs here. All document labels are inserted as text. */
2854
- var viewerChromeTemplate = `
2855
- <div class="toolbar" role="group" aria-label="Diagram controls">
2856
-
2857
- <div class="ribbon-tabs" role="tablist" aria-label="Ribbon">
2858
- <button type="button" id="home-tab" role="tab" aria-selected="true" aria-controls="home-panel" data-tab="home">Home</button>
2859
- <button type="button" id="view-tab" role="tab" aria-selected="false" aria-controls="view-panel" tabindex="-1" data-tab="view">View</button>
2860
- </div>
2861
- <div class="ribbon-content" id="home-panel" role="tabpanel" aria-labelledby="home-tab">
2862
- <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>
2863
- <div class="ribbon-group"><div class="ribbon-actions">
2864
- <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>
2865
- <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>
2866
- <button class="ribbon-command" type="button" data-chrome="layers"><span class="command-icon" data-icon="layers" aria-hidden="true"></span><span>Layers</span></button>
2867
- </div><span class="group-label">Inspect and edit</span></div>
2868
- </div></details>
2869
- <div class="ribbon-primary">
2870
- <label class="page-picker">Page <select aria-label="Page"></select></label>
2871
- <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>
2872
- <span class="spacer"></span><span class="local-label">Local workspace</span>
2873
- </div>
2874
- <p class="ribbon-hint">Select a shape to inspect its details.<br>Text editing is experimental.</p>
2875
- </div>
2876
- <div class="ribbon-content" id="view-panel" role="tabpanel" aria-labelledby="view-tab" hidden>
2877
- <div class="ribbon-group"><div class="ribbon-actions">
2878
- <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>
2879
- <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>
2880
- <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>
2881
- </div><span class="group-label">Workspace panes</span></div>
2882
- <p class="ribbon-hint">Canvas shortcuts: + / − to zoom, 0 to fit.<br>Arrow keys move between focused shapes.</p>
2883
- </div>
2884
- </div>
2885
- <div class="workspace">
3128
+ /**
3129
+ * Static workspace markup (legacy; migrate to builders when next changed). The ribbon, page
3130
+ * tabs and status bar are built by `createRibbon`, `createPageTabs` and `createStatusBar`.
3131
+ * All document labels are inserted as text.
3132
+ */
3133
+ var viewerChromeTemplate = `<div class="workspace">
2886
3134
  <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>
2887
3135
  <div class="viewport" tabindex="0" role="region" aria-label="Diagram canvas"></div>
2888
3136
  <aside id="inspector-pane" class="inspector-pane" aria-label="Drawing inspector">
@@ -2895,14 +3143,15 @@ var viewerChromeTemplate = `
2895
3143
  <details class="notes inspector-card"><summary>Compatibility notes</summary><ul></ul></details>
2896
3144
  </div>
2897
3145
  </aside>
2898
- </div>
2899
- <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>`;
3146
+ </div>`;
2900
3147
  /** Presentation state stays local; page navigation uses the same controller as every binding. */
2901
3148
  var ViewerChrome = class {
2902
3149
  #root;
2903
3150
  #controller;
2904
3151
  #document;
2905
3152
  #pageList;
3153
+ #pageTabs;
3154
+ #tools;
2906
3155
  #rail;
2907
3156
  #inspector;
2908
3157
  #notes;
@@ -2914,6 +3163,8 @@ var ViewerChrome = class {
2914
3163
  this.#root = root;
2915
3164
  this.#controller = controller;
2916
3165
  this.#pageList = root.querySelector(".page-list");
3166
+ this.#pageTabs = root.querySelector("office-ui-tab-strip");
3167
+ this.#tools = root.querySelector(".ribbon-tools");
2917
3168
  this.#rail = root.querySelector(".page-rail");
2918
3169
  this.#inspector = root.querySelector(".inspector-pane");
2919
3170
  this.#notes = root.querySelector(".notes");
@@ -2924,7 +3175,7 @@ var ViewerChrome = class {
2924
3175
  this.#responsive = view?.matchMedia?.("(max-width: 980px)");
2925
3176
  const compact = view?.matchMedia?.("(max-width: 760px)");
2926
3177
  this.#compact = compact;
2927
- const tools = this.#root.querySelector(".ribbon-tools");
3178
+ const tools = this.#tools;
2928
3179
  const toolLayout = () => {
2929
3180
  tools.open = !compact?.matches;
2930
3181
  };
@@ -2951,22 +3202,16 @@ var ViewerChrome = class {
2951
3202
  this.#root.addEventListener("click", (event) => {
2952
3203
  const button = event.target?.closest?.("button");
2953
3204
  if (!button || button.disabled) return;
2954
- if (compact?.matches && button.dataset.chrome) tools.open = false;
2955
3205
  const tab = button.dataset.tab;
2956
- if (tab === "home" || tab === "view") this.#showTab(tab);
3206
+ if (tab === "home" || tab === "view") this.showTab(tab);
2957
3207
  if (button.dataset.pageIndex !== void 0) this.#controller.setPage(Number(button.dataset.pageIndex));
2958
3208
  const action = button.dataset.chrome;
2959
- if (action === "previous-page") this.#controller.setPage(this.#controller.state.pageIndex - 1);
2960
- if (action === "next-page") this.#controller.setPage(this.#controller.state.pageIndex + 1);
2961
- if (action === "pages") {
2962
- this.#pagesManuallyToggled = true;
2963
- this.#setPane("pages", this.#rail.hidden);
2964
- }
2965
- if (action === "inspector") {
2966
- this.#inspectorManuallyToggled = true;
2967
- this.#setPane("inspector", this.#inspector.hidden);
2968
- }
2969
- if (action === "notes" || action === "selection" || action === "edit" || action === "layers") this.#reveal(action);
3209
+ if (action === "inspector") this.togglePane("inspector");
3210
+ if (action === "notes") this.reveal("notes");
3211
+ }, options);
3212
+ this.#root.addEventListener("office-tab-select", (event) => {
3213
+ const id = Number(event.detail.id);
3214
+ if (Number.isSafeInteger(id)) this.#controller.setPage(id);
2970
3215
  }, options);
2971
3216
  this.#root.addEventListener("keydown", (event) => {
2972
3217
  const key = event;
@@ -2983,11 +3228,11 @@ var ViewerChrome = class {
2983
3228
  if (key.key === "ArrowUp" || key.key === "ArrowDown") return;
2984
3229
  key.preventDefault();
2985
3230
  const tab = key.key === "Home" ? "home" : key.key === "End" ? "view" : button.dataset.tab === "home" ? "view" : "home";
2986
- this.#showTab(tab);
3231
+ this.showTab(tab);
2987
3232
  this.#root.querySelector(`[data-tab="${tab}"]`).focus();
2988
3233
  } else if (button.dataset.pageIndex !== void 0) {
2989
3234
  key.preventDefault();
2990
- const pages = [...this.#pageList.querySelectorAll("button")];
3235
+ const pages = [...this.#pageList.querySelectorAll("[data-page-index]")];
2991
3236
  const current = Number(button.dataset.pageIndex);
2992
3237
  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)));
2993
3238
  this.#controller.setPage(next);
@@ -2997,7 +3242,7 @@ var ViewerChrome = class {
2997
3242
  this.#notes.addEventListener("toggle", () => this.#syncNotes(), options);
2998
3243
  return () => events.abort();
2999
3244
  }
3000
- #showTab(tab) {
3245
+ showTab(tab) {
3001
3246
  for (const button of this.#root.querySelectorAll("[data-tab]")) {
3002
3247
  const selected = button.dataset.tab === tab;
3003
3248
  button.setAttribute("aria-selected", String(selected));
@@ -3005,27 +3250,41 @@ var ViewerChrome = class {
3005
3250
  }
3006
3251
  for (const panel of this.#root.querySelectorAll("[role=\"tabpanel\"]")) panel.hidden = panel.id !== `${tab}-panel`;
3007
3252
  }
3253
+ /** Show or hide a task pane; compact layouts close the Tools menu afterwards. */
3254
+ togglePane(pane) {
3255
+ if (this.#compact?.matches) this.#tools.open = false;
3256
+ if (pane === "pages") this.#pagesManuallyToggled = true;
3257
+ else this.#inspectorManuallyToggled = true;
3258
+ this.#setPane(pane, (pane === "pages" ? this.#rail : this.#inspector).hidden);
3259
+ }
3260
+ #command(name) {
3261
+ return this.#root.querySelector(`office-ui-button[command="${name}"]`);
3262
+ }
3008
3263
  #setPane(pane, visible) {
3009
3264
  if (visible && this.#compact?.matches) {
3010
3265
  const other = pane === "pages" ? "inspector" : "pages";
3011
3266
  (other === "pages" ? this.#rail : this.#inspector).hidden = true;
3012
- this.#root.querySelector(`[data-chrome="${other}"]`).setAttribute("aria-pressed", "false");
3267
+ this.#command(other).setAttribute("pressed", "false");
3013
3268
  }
3014
3269
  (pane === "pages" ? this.#rail : this.#inspector).hidden = !visible;
3015
- this.#root.querySelector(`[data-chrome="${pane}"]`).setAttribute("aria-pressed", String(visible));
3270
+ this.#command(pane).setAttribute("pressed", String(visible));
3016
3271
  this.#syncNotes();
3017
3272
  }
3018
3273
  #syncNotes() {
3019
3274
  this.#root.querySelector(".notes-strip button").setAttribute("aria-expanded", String(!this.#inspector.hidden && this.#notes.open));
3020
3275
  }
3021
- #reveal(kind) {
3276
+ /** Open an inspector disclosure; text editing may focus the text field directly (F2). */
3277
+ reveal(kind, focusText = false) {
3278
+ if (this.#compact?.matches) this.#tools.open = false;
3022
3279
  this.#inspectorManuallyToggled = true;
3023
3280
  this.#setPane("inspector", true);
3024
3281
  const selector = kind === "notes" ? ".notes" : kind === "edit" ? ".edit-controls" : kind === "layers" ? ".layer-controls" : ".shape-inspector";
3025
3282
  const panel = this.#root.querySelector(selector);
3026
3283
  if (panel.hidden) return;
3027
3284
  panel.open = true;
3028
- panel.querySelector("summary").focus();
3285
+ const text = panel.querySelector("textarea");
3286
+ if (focusText && text && !text.disabled) text.focus();
3287
+ else panel.querySelector("summary").focus();
3029
3288
  panel.scrollIntoView?.({
3030
3289
  block: "nearest",
3031
3290
  inline: "nearest"
@@ -3066,14 +3325,21 @@ var ViewerChrome = class {
3066
3325
  fragment.append(item);
3067
3326
  }
3068
3327
  this.#pageList.replaceChildren(fragment);
3328
+ this.#pageTabs.tabs = (state.document?.pages ?? []).map((candidate, index) => ({
3329
+ id: String(index),
3330
+ label: candidate.name,
3331
+ title: candidate.isBackground ? `${candidate.name} (background page)` : candidate.name
3332
+ }));
3069
3333
  if (restoreFocus !== void 0) [...this.#pageList.querySelectorAll("button")].find((button) => button.dataset.pageIndex === restoreFocus)?.focus();
3070
3334
  }
3335
+ this.#pageTabs.selected = page ? String(state.pageIndex) : "";
3071
3336
  for (const button of this.#pageList.querySelectorAll("button")) {
3072
3337
  const selected = Number(button.dataset.pageIndex) === state.pageIndex;
3073
3338
  if (selected) button.setAttribute("aria-current", "page");
3074
3339
  else button.removeAttribute("aria-current");
3075
3340
  button.tabIndex = selected ? 0 : -1;
3076
3341
  }
3342
+ this.#root.querySelector("[data-page-status]").setAttribute("value", page ? `Page ${state.pageIndex + 1} of ${state.document.pages.length}` : "");
3077
3343
  const count = state.document?.pages.length ?? 0;
3078
3344
  this.#root.querySelector("[data-page-count]").textContent = String(count);
3079
3345
  this.#root.querySelector(".page-empty").hidden = count > 0;
@@ -3081,17 +3347,405 @@ var ViewerChrome = class {
3081
3347
  this.#root.querySelector("[data-page-size]").textContent = page ? `${page.width} × ${page.height} in` : "No page";
3082
3348
  this.#root.querySelector("[data-page-shapes]").textContent = page ? `${page.shapes.length} top-level` : "No shapes";
3083
3349
  this.#root.querySelector(".selection-hint").hidden = !!state.selectedShape;
3084
- this.#root.querySelector("[data-chrome=\"previous-page\"]").disabled = !page || state.pageIndex === 0;
3085
- this.#root.querySelector("[data-chrome=\"next-page\"]").disabled = !page || state.pageIndex >= count - 1;
3086
- this.#root.querySelector("[data-chrome=\"selection\"]").disabled = !state.selectedShape;
3087
- this.#root.querySelector("[data-chrome=\"edit\"]").disabled = !page;
3088
- this.#root.querySelector("[data-chrome=\"layers\"]").disabled = this.#root.querySelector(".layer-controls").hidden;
3089
- for (const button of this.#root.querySelectorAll("[data-chrome=\"notes\"]")) button.disabled = noteCount === 0;
3350
+ this.#command("selection").disabled = !state.selectedShape;
3351
+ this.#command("edit").disabled = !page;
3352
+ this.#command("layers").disabled = this.#root.querySelector(".layer-controls").hidden;
3353
+ this.#command("notes").disabled = noteCount === 0;
3354
+ this.#root.querySelector(".notes-strip button").disabled = noteCount === 0;
3090
3355
  this.#root.querySelector("[data-note-count]").textContent = noteCount ? ` · ${noteCount}` : "";
3091
3356
  this.#syncNotes();
3092
3357
  }
3093
3358
  };
3094
3359
  //#endregion
3360
+ //#region src/ribbon-router.ts
3361
+ /** Routes a ribbon, status-bar or shortcut action to the controller that owns it. */
3362
+ function routeRibbonAction(targets, action) {
3363
+ const { controller } = targets;
3364
+ const page = controller.state.document?.pages[controller.state.pageIndex];
3365
+ switch (action.type) {
3366
+ case "history": return targets.history(action.key);
3367
+ case "delete": return targets.deleteSelection();
3368
+ case "tool": return targets.setTool(action.tool);
3369
+ case "grid": return targets.toggleGrid();
3370
+ case "fullscreen": return targets.toggleFullscreen();
3371
+ case "pane": return targets.togglePane(action.pane);
3372
+ case "reveal": return targets.reveal(action.panel, action.focusText ?? false);
3373
+ case "search": return targets.focusSearch();
3374
+ case "page": return controller.setPage(controller.state.pageIndex + action.step);
3375
+ case "zoom":
3376
+ if (!page) return;
3377
+ if (action.mode === "actual") return controller.setZoom(1);
3378
+ return targets.fit(action.mode === "fit" ? "page" : "width");
3379
+ }
3380
+ }
3381
+ //#endregion
3382
+ //#region src/viewer-draw-tool.ts
3383
+ /** Visio snaps new geometry to ruler subdivisions; 1/16 inch matches its default fine grid. */
3384
+ var SNAP = 1 / 16;
3385
+ var MIN_SIZE = SNAP;
3386
+ var snap = (value) => Math.round(value / SNAP) * SNAP;
3387
+ /** Next free numeric shape ID on the page tree. Core still rejects any collision. */
3388
+ function nextShapeId(page) {
3389
+ let max = 0;
3390
+ const pending = [...page.shapes];
3391
+ while (pending.length) {
3392
+ const shape = pending.pop();
3393
+ if (/^\d{1,9}$/.test(shape.id)) max = Math.max(max, Number(shape.id));
3394
+ pending.push(...shape.children);
3395
+ }
3396
+ return String(max + 1);
3397
+ }
3398
+ /** Page inches with a top-left origin (SVG user space) from a client point. */
3399
+ function pagePoint(svg, page, event) {
3400
+ const matrix = svg.getScreenCTM?.();
3401
+ if (!matrix) return void 0;
3402
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
3403
+ return {
3404
+ x: snap(Math.max(0, Math.min(page.width, point.x))),
3405
+ y: snap(Math.max(0, Math.min(page.height, point.y)))
3406
+ };
3407
+ }
3408
+ /** Drag-to-draw rectangle tool. The preview is viewer-only; the shape is created by core. */
3409
+ var RectangleDrawTool = class {
3410
+ viewport;
3411
+ controller;
3412
+ options;
3413
+ #drag;
3414
+ #request = 0;
3415
+ constructor(viewport, controller, options) {
3416
+ this.viewport = viewport;
3417
+ this.controller = controller;
3418
+ this.options = options;
3419
+ }
3420
+ get drawing() {
3421
+ return !!this.#drag;
3422
+ }
3423
+ wire() {
3424
+ const events = new ((this.viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
3425
+ const options = { signal: events.signal };
3426
+ const viewport = this.viewport;
3427
+ viewport.addEventListener("click", (event) => {
3428
+ if (this.options.active()) event.stopImmediatePropagation();
3429
+ }, {
3430
+ ...options,
3431
+ capture: true
3432
+ });
3433
+ viewport.addEventListener("pointerdown", (event) => this.#start(event), options);
3434
+ viewport.addEventListener("pointermove", (event) => this.#move(event), options);
3435
+ viewport.addEventListener("pointerup", (event) => void this.#finish(event), options);
3436
+ viewport.addEventListener("pointercancel", () => this.#cancel(), options);
3437
+ viewport.addEventListener("keydown", (event) => {
3438
+ if (event.key === "Escape" && this.#drag) {
3439
+ event.preventDefault();
3440
+ this.#cancel();
3441
+ }
3442
+ }, options);
3443
+ return () => {
3444
+ ++this.#request;
3445
+ this.#cancel();
3446
+ events.abort();
3447
+ };
3448
+ }
3449
+ #start(event) {
3450
+ if (!this.options.active() || event.button !== 0 || this.#drag) return;
3451
+ const state = this.controller.state;
3452
+ const page = state.document?.pages[state.pageIndex];
3453
+ const svg = this.viewport.querySelector("svg.paper");
3454
+ if (!page || !svg || !state.edit.sourceAvailable || state.edit.busy) return;
3455
+ const start = pagePoint(svg, page, event);
3456
+ if (!start) return;
3457
+ event.preventDefault();
3458
+ const rect = this.viewport.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "rect");
3459
+ rect.classList.add("draw-preview");
3460
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
3461
+ svg.append(rect);
3462
+ this.#drag = {
3463
+ pointer: event.pointerId,
3464
+ svg,
3465
+ page,
3466
+ ...start,
3467
+ rect
3468
+ };
3469
+ this.viewport.setPointerCapture?.(event.pointerId);
3470
+ this.#update(start);
3471
+ }
3472
+ #move(event) {
3473
+ if (!this.#drag || event.pointerId !== this.#drag.pointer) return;
3474
+ const point = pagePoint(this.#drag.svg, this.#drag.page, event);
3475
+ if (point) this.#update(point);
3476
+ }
3477
+ #update(point) {
3478
+ const { x, y, rect } = this.#drag;
3479
+ rect.setAttribute("x", String(Math.min(x, point.x)));
3480
+ rect.setAttribute("y", String(Math.min(y, point.y)));
3481
+ rect.setAttribute("width", String(Math.abs(point.x - x)));
3482
+ rect.setAttribute("height", String(Math.abs(point.y - y)));
3483
+ }
3484
+ #cancel() {
3485
+ this.#drag?.rect.remove();
3486
+ this.#drag = void 0;
3487
+ }
3488
+ async #finish(event) {
3489
+ const drag = this.#drag;
3490
+ if (!drag || event.pointerId !== drag.pointer) return;
3491
+ const end = pagePoint(drag.svg, drag.page, event) ?? drag;
3492
+ this.#cancel();
3493
+ const width = Math.abs(end.x - drag.x), height = Math.abs(end.y - drag.y);
3494
+ if (width < MIN_SIZE || height < MIN_SIZE) {
3495
+ this.options.announce("Drag on the page to draw a rectangle.");
3496
+ return;
3497
+ }
3498
+ const state = this.controller.state;
3499
+ if (state.document?.pages[state.pageIndex] !== drag.page) return;
3500
+ const shapeId = nextShapeId(drag.page);
3501
+ const pageId = drag.page.id;
3502
+ const request = ++this.#request;
3503
+ try {
3504
+ await this.controller.applyEdits([{
3505
+ type: "create-rectangle",
3506
+ pageId,
3507
+ shapeId,
3508
+ x: (drag.x + end.x) / 2,
3509
+ y: drag.page.height - (drag.y + end.y) / 2,
3510
+ width,
3511
+ height
3512
+ }]);
3513
+ if (request !== this.#request) return;
3514
+ const created = this.controller.state.document?.pages.find((page) => page.id === pageId)?.shapes.find((shape) => shape.id === shapeId);
3515
+ if (created) this.controller.selectShape({
3516
+ id: shapeId,
3517
+ name: created.name,
3518
+ pageId
3519
+ });
3520
+ this.options.announce(`Rectangle ${shapeId} added (${+width.toFixed(4)} × ${+height.toFixed(4)} in).`);
3521
+ } catch (error) {
3522
+ if (request === this.#request && !isEditCancellation(error)) this.options.announce(editErrorMessage(error));
3523
+ }
3524
+ }
3525
+ };
3526
+ //#endregion
3527
+ //#region src/viewer-commands.ts
3528
+ var editable = (target) => target instanceof Element && !!target.closest("input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])");
3529
+ /**
3530
+ * Visio command state (tool, grid, pending edits) and keyboard shortcuts. Ribbon controls emit
3531
+ * typed `ribbon-action` events and shortcuts build the same actions; both go through
3532
+ * `routeRibbonAction`. Document mutation stays in the controller and core.
3533
+ */
3534
+ var ViewerCommands = class {
3535
+ host;
3536
+ #tool = "pointer";
3537
+ #grid = false;
3538
+ #pending = 0;
3539
+ #draw;
3540
+ #targets;
3541
+ constructor(host) {
3542
+ this.host = host;
3543
+ this.#draw = new RectangleDrawTool(host.viewport, host.controller, {
3544
+ active: () => this.#tool === "rectangle",
3545
+ announce: host.announce
3546
+ });
3547
+ this.#targets = {
3548
+ controller: host.controller,
3549
+ history: (key) => this.#history(key),
3550
+ deleteSelection: () => this.#delete(),
3551
+ setTool: (tool) => this.setTool(tool),
3552
+ toggleGrid: () => {
3553
+ this.#grid = !this.#grid;
3554
+ this.render(host.controller.state);
3555
+ },
3556
+ toggleFullscreen: () => this.#toggleFullscreen(),
3557
+ togglePane: host.togglePane,
3558
+ reveal: host.reveal,
3559
+ fit: host.fit,
3560
+ focusSearch: host.focusSearch
3561
+ };
3562
+ }
3563
+ get tool() {
3564
+ return this.#tool;
3565
+ }
3566
+ /** Run one typed action, exactly as a ribbon control or shortcut would. */
3567
+ run(action) {
3568
+ routeRibbonAction(this.#targets, action);
3569
+ }
3570
+ wire() {
3571
+ const { root, viewport } = this.host;
3572
+ const doc = root.ownerDocument;
3573
+ const events = new ((doc.defaultView?.AbortController) ?? AbortController)();
3574
+ const options = { signal: events.signal };
3575
+ root.addEventListener(RIBBON_ACTION_EVENT, (event) => this.run(event.detail), options);
3576
+ root.addEventListener("office-command", (event) => {
3577
+ if (event.detail?.command === "zoom-fit") this.run({
3578
+ type: "zoom",
3579
+ mode: "fit"
3580
+ });
3581
+ }, options);
3582
+ root.addEventListener("keydown", (event) => this.#shortcut(event), options);
3583
+ viewport.addEventListener("wheel", (event) => {
3584
+ if (!event.ctrlKey && !event.metaKey) return;
3585
+ event.preventDefault();
3586
+ const zoom = this.host.controller.state.zoom;
3587
+ this.host.controller.setZoom(event.deltaY < 0 ? zoom * 1.1 : zoom / 1.1);
3588
+ }, {
3589
+ ...options,
3590
+ passive: false
3591
+ });
3592
+ viewport.addEventListener("pointerdown", () => {
3593
+ if (!viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3594
+ }, options);
3595
+ viewport.addEventListener("dblclick", (event) => {
3596
+ if (this.#tool !== "pointer") return;
3597
+ if (event.target?.closest?.("[data-shape-id]")) this.run({
3598
+ type: "reveal",
3599
+ panel: "edit",
3600
+ focusText: true
3601
+ });
3602
+ }, options);
3603
+ doc.addEventListener("fullscreenchange", () => this.render(this.host.controller.state), options);
3604
+ const disposeDraw = this.#draw.wire();
3605
+ return () => {
3606
+ ++this.#pending;
3607
+ events.abort();
3608
+ disposeDraw();
3609
+ };
3610
+ }
3611
+ setTool(tool) {
3612
+ if (tool === "rectangle" && !this.#canEdit(this.host.controller.state)) return;
3613
+ this.#tool = tool;
3614
+ this.render(this.host.controller.state);
3615
+ }
3616
+ #canEdit(state) {
3617
+ return state.edit.sourceAvailable && !state.loading && !state.edit.busy;
3618
+ }
3619
+ #history(key) {
3620
+ const { controller } = this.host;
3621
+ const { edit, loading } = controller.state;
3622
+ if ((key === "undo" ? edit.canUndo : edit.canRedo) && !edit.busy && !loading) this.#edit(() => controller[key]());
3623
+ }
3624
+ #delete() {
3625
+ const state = this.host.controller.state;
3626
+ const shape = state.selectedShape;
3627
+ const pageId = shape?.pageId ?? state.document?.pages[state.pageIndex]?.id;
3628
+ if (!shape || pageId === void 0 || !this.#canEdit(state)) return;
3629
+ this.#edit(() => this.host.controller.applyEdits([{
3630
+ type: "delete-shape",
3631
+ pageId,
3632
+ shapeId: shape.id
3633
+ }]), `Deleted ${shape.name || `shape ${shape.id}`}.`);
3634
+ }
3635
+ async #edit(action, success) {
3636
+ const request = ++this.#pending;
3637
+ const { root, viewport } = this.host;
3638
+ const canvasFocused = viewport.contains(root.activeElement);
3639
+ try {
3640
+ await action();
3641
+ if (canvasFocused && !viewport.contains(root.activeElement)) viewport.focus({ preventScroll: true });
3642
+ if (success && request === this.#pending) this.host.announce(success);
3643
+ } catch (error) {
3644
+ if (request === this.#pending && !isEditCancellation(error) && !this.host.controller.state.edit.error) this.host.announce(editErrorMessage(error));
3645
+ }
3646
+ }
3647
+ #toggleFullscreen() {
3648
+ const host = this.host.root.host;
3649
+ const doc = this.host.root.ownerDocument;
3650
+ (doc.fullscreenElement === host ? doc.exitFullscreen?.() : host.requestFullscreen?.())?.catch?.(() => this.host.announce("Full screen is not available in this browser frame."));
3651
+ }
3652
+ /** Visio shortcuts as typed actions. Text fields keep native undo, deletion and editing keys. */
3653
+ #shortcutAction(event) {
3654
+ const state = this.host.controller.state;
3655
+ const control = event.ctrlKey || event.metaKey;
3656
+ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
3657
+ if (control && key === "f") return { type: "search" };
3658
+ if (control && event.shiftKey && key === "w") return {
3659
+ type: "zoom",
3660
+ mode: "fit"
3661
+ };
3662
+ if (control && (key === "PageDown" || key === "PageUp")) return {
3663
+ type: "page",
3664
+ step: key === "PageDown" ? 1 : -1
3665
+ };
3666
+ if (key === "F5" && !control) return { type: "fullscreen" };
3667
+ if (editable(event.target)) return void 0;
3668
+ if (control && !event.shiftKey && key === "z") return {
3669
+ type: "history",
3670
+ key: "undo"
3671
+ };
3672
+ if (control && (key === "y" || event.shiftKey && key === "z")) return {
3673
+ type: "history",
3674
+ key: "redo"
3675
+ };
3676
+ if (control && key === "1") return {
3677
+ type: "tool",
3678
+ tool: "pointer"
3679
+ };
3680
+ if (control && key === "8") return {
3681
+ type: "tool",
3682
+ tool: "rectangle"
3683
+ };
3684
+ if (!control && key === "Delete" && state.selectedShape) return { type: "delete" };
3685
+ if (!control && key === "F2" && state.document) return {
3686
+ type: "reveal",
3687
+ panel: "edit",
3688
+ focusText: true
3689
+ };
3690
+ if (key === "Escape" && this.#tool !== "pointer" && !this.#draw.drawing) return {
3691
+ type: "tool",
3692
+ tool: "pointer"
3693
+ };
3694
+ }
3695
+ #shortcut(event) {
3696
+ if (event.defaultPrevented || event.isComposing || event.altKey) return;
3697
+ const action = this.#shortcutAction(event);
3698
+ if (!action) return;
3699
+ event.preventDefault();
3700
+ this.run(action);
3701
+ }
3702
+ render(state) {
3703
+ const { root, viewport } = this.host;
3704
+ const button = (name) => root.querySelector(`office-ui-button[command="${name}"]`);
3705
+ const editing = this.#canEdit(state);
3706
+ if (this.#tool === "rectangle" && !state.edit.sourceAvailable) this.#tool = "pointer";
3707
+ const page = state.document?.pages[state.pageIndex];
3708
+ button("undo").disabled = !state.edit.canUndo || state.edit.busy || state.loading;
3709
+ button("redo").disabled = !state.edit.canRedo || state.edit.busy || state.loading;
3710
+ button("delete").disabled = !editing || !state.selectedShape;
3711
+ button("pointer").setAttribute("pressed", String(this.#tool === "pointer"));
3712
+ const rectangle = button("rectangle");
3713
+ rectangle.setAttribute("pressed", String(this.#tool === "rectangle"));
3714
+ rectangle.disabled = !editing || !page;
3715
+ rectangle.title = state.edit.sourceAvailable ? "Rectangle (Ctrl+8)" : "Rectangle (Ctrl+8): open a .vsdx file to draw. Model-only documents are read only.";
3716
+ button("grid").setAttribute("pressed", String(this.#grid));
3717
+ button("grid").disabled = !page;
3718
+ for (const name of [
3719
+ "zoom-fit",
3720
+ "page-width",
3721
+ "actual-size"
3722
+ ]) button(name).disabled = !page;
3723
+ const fullscreen = button("fullscreen");
3724
+ const host = root.host;
3725
+ fullscreen.disabled = typeof host.requestFullscreen !== "function";
3726
+ fullscreen.setAttribute("pressed", String(root.ownerDocument.fullscreenElement === host));
3727
+ viewport.dataset.tool = this.#tool;
3728
+ viewport.dataset.grid = String(this.#grid);
3729
+ }
3730
+ };
3731
+ //#endregion
3732
+ //#region src/office-ui.ts
3733
+ /**
3734
+ * Visio-only glyphs (20x20 path data). Neutral Office glyphs come from ooxml-ui; add a glyph
3735
+ * there instead when another Office product could use it.
3736
+ */
3737
+ var VISIO_ICONS = {
3738
+ 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",
3739
+ visioShapeData: "M3 3.5h14v13H3ZM3 7.5h14M8 7.5v9M10.5 11h4M10.5 14h4",
3740
+ visioPagesPane: "M2.5 3h15v14h-15ZM7 3v14M3.8 6h1.9M3.8 9h1.9M3.8 12h1.9",
3741
+ visioInspectorPane: "M2.5 3h15v14h-15ZM13 3v14M14.5 6h1.5M14.5 9h1.5M14.5 12h1.5"
3742
+ };
3743
+ /** Define the shared Office controls the viewer chrome uses. Idempotent; browser only. */
3744
+ function registerViewerControls(registry) {
3745
+ for (const [name, path] of Object.entries(VISIO_ICONS)) if (!getIcon(name)) registerIcon(name, path);
3746
+ registerControls(registry);
3747
+ }
3748
+ //#endregion
3095
3749
  //#region src/export-svg.ts
3096
3750
  var MAX_SVG_EXPORT_BYTES = 16777216;
3097
3751
  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.";
@@ -3702,8 +4356,10 @@ var VisioViewerElement = class extends BaseElement {
3702
4356
  #root;
3703
4357
  #disposeInputs;
3704
4358
  #viewport;
3705
- #pageSelect;
3706
- #zoomLabel;
4359
+ #zoomSlider;
4360
+ #shapeStatus;
4361
+ #announcement;
4362
+ #commands;
3707
4363
  #status;
3708
4364
  #diagnostics;
3709
4365
  #toolbar;
@@ -3736,10 +4392,14 @@ var VisioViewerElement = class extends BaseElement {
3736
4392
  constructor() {
3737
4393
  super();
3738
4394
  this.#root = this.attachShadow({ mode: "open" });
3739
- this.#root.innerHTML = `<style>${viewerStyles}</style>${viewerChromeTemplate}`;
4395
+ registerViewerControls();
4396
+ this.#root.innerHTML = `<style>${viewerStyles}${canvasAndRibbonStyles}</style>${viewerChromeTemplate}`;
4397
+ const workspace = this.#root.querySelector(".workspace");
4398
+ workspace.before(createRibbon(document));
4399
+ workspace.after(createPageTabs(document), createStatusBar(document));
3740
4400
  this.#viewport = this.#root.querySelector(".viewport");
3741
- this.#pageSelect = this.#root.querySelector("select");
3742
- this.#zoomLabel = this.#root.querySelector("output");
4401
+ this.#zoomSlider = this.#root.querySelector("office-ui-zoom-slider");
4402
+ this.#shapeStatus = this.#root.querySelector("[data-shape-status]");
3743
4403
  this.#status = this.#root.querySelector("[data-status]");
3744
4404
  this.#diagnostics = this.#root.querySelector("[data-diagnostics]");
3745
4405
  this.#toolbar = this.#root.querySelector(".toolbar");
@@ -3750,6 +4410,23 @@ var VisioViewerElement = class extends BaseElement {
3750
4410
  this.#notesPanel = this.#root.querySelector(".notes");
3751
4411
  this.#inspector = this.#root.querySelector(".shape-inspector");
3752
4412
  this.#chrome = new ViewerChrome(this.#root, this.controller);
4413
+ this.#commands = new ViewerCommands({
4414
+ root: this.#root,
4415
+ viewport: this.#viewport,
4416
+ controller: this.controller,
4417
+ fit: (mode) => this.#fit(mode),
4418
+ togglePane: (pane) => this.#chrome.togglePane(pane),
4419
+ reveal: (panel, focusText) => this.#chrome.reveal(panel, focusText),
4420
+ focusSearch: () => {
4421
+ this.#chrome.showTab("home");
4422
+ this.#search.input.focus();
4423
+ this.#search.input.select();
4424
+ },
4425
+ announce: (message) => {
4426
+ this.#announcement = message;
4427
+ this.#status.textContent = message;
4428
+ }
4429
+ });
3753
4430
  this.#disposeInputs = this.#wireInputs();
3754
4431
  this.#unsubscribe = this.controller.subscribe((state) => this.#render(state));
3755
4432
  this.#eventUnsubscribe = this.controller.onEvent((name, detail) => {
@@ -3823,13 +4500,24 @@ var VisioViewerElement = class extends BaseElement {
3823
4500
  }
3824
4501
  fit() {
3825
4502
  this.#assertAlive();
4503
+ this.#fit("page");
4504
+ }
4505
+ /** Visio Fit to Window shows the whole page; Page Width fills the canvas width. */
4506
+ #fit(mode) {
3826
4507
  const page = this.document?.pages[this.pageIndex];
3827
4508
  if (!page) return;
3828
4509
  const style = this.ownerDocument.defaultView?.getComputedStyle(this.#viewport);
3829
4510
  const padding = (value) => Number.parseFloat(value ?? "") || 0;
3830
4511
  const width = Math.max(1, this.#viewport.clientWidth - padding(style?.paddingLeft) - padding(style?.paddingRight));
3831
4512
  const height = Math.max(1, this.#viewport.clientHeight - padding(style?.paddingTop) - padding(style?.paddingBottom));
3832
- this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4513
+ if (mode === "page") {
4514
+ this.zoom = Math.min(1, width / (page.width * 96), height / (page.height * 96));
4515
+ return;
4516
+ }
4517
+ const scrollbar = this.#viewport.offsetWidth - this.#viewport.clientWidth;
4518
+ const fitted = width / (page.width * 96);
4519
+ const reserve = page.height * 96 * fitted > height && scrollbar <= 0 ? 17 : 0;
4520
+ this.zoom = Math.max(1, width - reserve - 1) / (page.width * 96);
3833
4521
  }
3834
4522
  setLayerVisibility(pageId, layerId, visible) {
3835
4523
  this.#assertAlive();
@@ -3899,16 +4587,18 @@ var VisioViewerElement = class extends BaseElement {
3899
4587
  }
3900
4588
  #wireInputs() {
3901
4589
  const disposeChrome = this.#chrome.wire();
4590
+ const disposeCommands = this.#commands.wire();
3902
4591
  const disposeEdit = this.#edit.wire();
3903
4592
  const disposeLayers = wireLayerControls(this.#layers, this.controller);
3904
4593
  const disposeInputs = wireViewerInputs({
3905
4594
  viewport: this.#viewport,
3906
4595
  commandRoot: this.#root,
3907
- pageSelect: this.#pageSelect,
4596
+ zoomSlider: this.#zoomSlider,
3908
4597
  searchInput: this.#search.input
3909
- }, this.controller, () => this.fit());
4598
+ }, this.controller, (mode) => this.#fit(mode));
3910
4599
  return () => {
3911
4600
  disposeChrome();
4601
+ disposeCommands();
3912
4602
  disposeInputs();
3913
4603
  disposeLayers();
3914
4604
  disposeEdit();
@@ -3927,14 +4617,7 @@ var VisioViewerElement = class extends BaseElement {
3927
4617
  this.#document = state.document;
3928
4618
  this.#renderedPage = state.pageIndex;
3929
4619
  this.#renderedLayerOverrides = state.layerVisibilityOverrides;
3930
- this.#pageSelect.replaceChildren();
3931
- for (const [index, candidate] of (state.document?.pages ?? []).entries()) {
3932
- const option = document.createElement("option");
3933
- option.value = String(index);
3934
- option.textContent = candidate.name + (candidate.isBackground ? " (background)" : "");
3935
- this.#pageSelect.append(option);
3936
- }
3937
- this.#pageSelect.value = String(state.pageIndex);
4620
+ this.#announcement = void 0;
3938
4621
  this.#renderWarnings = [];
3939
4622
  if (state.document && page) {
3940
4623
  const result = renderPage(state.document, page, { layerVisibilityOverrides: state.layerVisibilityOverrides });
@@ -3946,7 +4629,7 @@ var VisioViewerElement = class extends BaseElement {
3946
4629
  empty.className = "empty";
3947
4630
  const heading = document.createElement("strong");
3948
4631
  heading.textContent = "Open a Visio drawing";
3949
- empty.append(heading, "Open a .vsdx file to start. Files stay in this browser.");
4632
+ empty.append(heading, "Open a .vsdx or supported .vsd file to start. Files stay in this browser.");
3950
4633
  this.#viewport.replaceChildren(empty);
3951
4634
  }
3952
4635
  const notes = compatibilityNotes(state.document?.diagnostics ?? [], this.#renderWarnings);
@@ -3961,6 +4644,7 @@ var VisioViewerElement = class extends BaseElement {
3961
4644
  if (svg && page) {
3962
4645
  svg.style.width = `${page.width * 96 * state.zoom}px`;
3963
4646
  svg.style.height = `${page.height * 96 * state.zoom}px`;
4647
+ svg.style.setProperty("--vv-inch", `${96 * state.zoom}px`);
3964
4648
  }
3965
4649
  const searchResult = state.search.results[state.search.activeIndex];
3966
4650
  const selection = state.selectedShape;
@@ -3988,16 +4672,20 @@ var VisioViewerElement = class extends BaseElement {
3988
4672
  this.#renderedSelection = selection ? { ...selection } : null;
3989
4673
  }
3990
4674
  if (!searchResult) this.#revealedSearchResult = void 0;
3991
- this.#zoomLabel.value = `${Math.round(state.zoom * 100)}%`;
3992
- this.#pageSelect.disabled = !page;
4675
+ this.#zoomSlider.value = Math.round(state.zoom * 100);
4676
+ this.#zoomSlider.disabled = !page;
4677
+ const inspected = selectedShape(state.document, state.selectedShape, state.pageIndex);
4678
+ const inches = (value) => `${+value.toFixed(3)} in`;
4679
+ this.#shapeStatus.setAttribute("value", inspected ? `Width: ${inches(inspected.width)} Height: ${inches(inspected.height)}` : "");
3993
4680
  for (const button of this.#root.querySelectorAll("[data-action]")) button.disabled = !page;
3994
4681
  renderSearchControls(this.#search, state);
3995
4682
  this.#edit.render(state);
3996
4683
  if (changed) renderLayerControls(this.#layers, state);
3997
4684
  else this.#layers.querySelector("[data-layer-reset=\"all\"]").disabled = state.layerVisibilityOverrides.length === 0;
3998
4685
  this.#chrome.render(state, this.#notes.children.length);
4686
+ this.#commands.render(state);
3999
4687
  this.#viewport.setAttribute("aria-busy", String(state.loading || state.edit.busy));
4000
- this.#status.textContent = state.loading ? "Opening diagram…" : state.error?.message ?? (page ? `${page.name} · ${page.shapes.length} top-level shapes` : "No diagram open");
4688
+ 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");
4001
4689
  const warnings = (state.document?.diagnostics.length ?? 0) + this.#renderWarnings.length;
4002
4690
  this.#diagnostics.textContent = warnings ? `${this.#notes.children.length} compatibility notes` : "Local-only viewing";
4003
4691
  }
@@ -4207,6 +4895,23 @@ function viewerHandle(current) {
4207
4895
  }
4208
4896
  };
4209
4897
  }
4898
+ function viewerStateSource(current) {
4899
+ const controller = () => {
4900
+ try {
4901
+ return current()?.controller;
4902
+ } catch {
4903
+ return;
4904
+ }
4905
+ };
4906
+ return {
4907
+ subscribe(onChange) {
4908
+ return controller()?.subscribe(() => onChange()) ?? (() => {});
4909
+ },
4910
+ getSnapshot() {
4911
+ return controller()?.state ?? null;
4912
+ }
4913
+ };
4914
+ }
4210
4915
  //#endregion
4211
4916
  //#region packages/bindings/src/react.ts
4212
4917
  /** React owns only the host and lifecycle; the shared element owns all viewer behavior. */
@@ -4232,5 +4937,13 @@ var VisioViewer = forwardRef(function VisioViewer(props, ref) {
4232
4937
  "aria-label": props["aria-label"]
4233
4938
  });
4234
4939
  });
4940
+ /**
4941
+ * Reactive viewer state (page, zoom, selection, search, edit and undo state) for the `ref`
4942
+ * given to `<VisioViewer>`, through React's external-store hook. `null` until mounted.
4943
+ */
4944
+ function useVisioViewerState(ref) {
4945
+ const source = useMemo(() => viewerStateSource(() => ref.current), [ref]);
4946
+ return useSyncExternalStore(source.subscribe, source.getSnapshot, () => null);
4947
+ }
4235
4948
  //#endregion
4236
- 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 };
4949
+ 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, useVisioViewerState };