visio-solid-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.
@@ -1,4 +1,6 @@
1
- import { VISIO_RASTER_IMAGE_LIMITS, VISIO_VISIBILITY_LIMITS, getVisioPageLayers, inspectVisioRasterImage, parseVsdx, resolveVisioPageVisibility, validateVisioForeignVector } from "ooxml-core/visio";
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";
2
4
  import { escape, ssr, ssrAttribute, ssrHydrationKey, ssrStyle } from "solid-js/web";
3
5
  import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
6
  export * from "visio-core";
@@ -271,7 +273,7 @@ function member(value, choices, label) {
271
273
  }
272
274
  /** Defensive display limits also protect callers supplying their own typed scenes. */
273
275
  function assertViewableDocument(model) {
274
- 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.");
276
+ 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.");
275
277
  const seen = /* @__PURE__ */ new Set();
276
278
  let pathBytes = 0, textBytes = 0, runCount = 0, imageBytes = 0, geometryCount = 0, uniquePixels = 0, drawnPixels = 0, layerCount = 0, paragraphCount = 0, gradientStops = 0;
277
279
  const imageResources = /* @__PURE__ */ new Map();
@@ -785,9 +787,10 @@ var ViewerController = class {
785
787
  #sourceGeneration = 0;
786
788
  #searchIndex = null;
787
789
  #history = null;
790
+ #sourceFormat = null;
788
791
  #editId = 0;
789
792
  #visible = documentVisibility(null);
790
- constructor(parser = parseVsdx, listenerError = (error) => {
793
+ constructor(parser = loadVisio, listenerError = (error) => {
791
794
  if (typeof globalThis.reportError === "function") globalThis.reportError(error);
792
795
  else console.error("Visio viewer listener failed:", error);
793
796
  }, editor = createWorkerEditor()) {
@@ -827,6 +830,7 @@ var ViewerController = class {
827
830
  ++this.#sourceGeneration;
828
831
  this.#invalidateEdit();
829
832
  this.#history = null;
833
+ this.#sourceFormat = null;
830
834
  this.#loadId++;
831
835
  this.parser.cancel?.();
832
836
  this.#searchIndex = null;
@@ -969,7 +973,7 @@ var ViewerController = class {
969
973
  this.#change({
970
974
  loading: true,
971
975
  error: null,
972
- edit: this.#history?.state ?? EMPTY_EDIT_STATE
976
+ edit: this.#sourceEditState()
973
977
  });
974
978
  if (this.#destroyed || id !== this.#loadId) return;
975
979
  let document;
@@ -980,10 +984,10 @@ var ViewerController = class {
980
984
  if (this.#destroyed || id !== this.#loadId) return;
981
985
  if (bytes.byteLength > 33554432) throw new Error("This viewer accepts files up to 32 MiB.");
982
986
  const owned = bytes instanceof Uint8Array ? Uint8Array.from(bytes) : new Uint8Array(bytes.slice(0));
983
- history = new DocumentHistory(owned);
984
987
  document = await this.parser(Uint8Array.from(owned));
985
988
  if (this.#destroyed || id !== this.#loadId) return;
986
989
  assertViewableDocument(document);
990
+ history = new DocumentHistory(owned);
987
991
  visible = documentVisibility(document);
988
992
  } catch (cause) {
989
993
  if (this.#destroyed || id !== this.#loadId) return;
@@ -996,13 +1000,14 @@ var ViewerController = class {
996
1000
  throw error;
997
1001
  }
998
1002
  this.#history = history;
1003
+ this.#sourceFormat = document.format;
999
1004
  this.#searchIndex = null;
1000
1005
  this.#visible = visible;
1001
1006
  ++this.#documentGeneration;
1002
1007
  ++this.#sourceGeneration;
1003
1008
  this.#change({
1004
1009
  document,
1005
- edit: history.state,
1010
+ edit: document.format === "vsdx" ? history.state : EMPTY_EDIT_STATE,
1006
1011
  pageIndex: 0,
1007
1012
  loading: false,
1008
1013
  error: null,
@@ -1019,7 +1024,7 @@ var ViewerController = class {
1019
1024
  this.#invalidateEdit();
1020
1025
  this.#change({
1021
1026
  loading: false,
1022
- edit: this.#history?.state ?? EMPTY_EDIT_STATE
1027
+ edit: this.#sourceEditState()
1023
1028
  });
1024
1029
  }
1025
1030
  destroy() {
@@ -1027,6 +1032,7 @@ var ViewerController = class {
1027
1032
  this.#destroyed = true;
1028
1033
  this.#invalidateEdit();
1029
1034
  this.#history = null;
1035
+ this.#sourceFormat = null;
1030
1036
  ++this.#documentGeneration;
1031
1037
  ++this.#sourceGeneration;
1032
1038
  ++this.#loadId;
@@ -1071,11 +1077,11 @@ var ViewerController = class {
1071
1077
  if (!this.#state.edit.busy) return;
1072
1078
  this.#invalidateEdit();
1073
1079
  this.parser.cancel?.();
1074
- this.#change({ edit: this.#history?.state ?? EMPTY_EDIT_STATE });
1080
+ this.#change({ edit: this.#sourceEditState() });
1075
1081
  }
1076
1082
  exportVsdx() {
1077
1083
  this.#assertAlive();
1078
- if (!this.#history) throw new Error("Load a VSDX file before downloading a source-backed copy.");
1084
+ if (!this.#history || this.#sourceFormat !== "vsdx" || this.#state.document?.format !== "vsdx") throw new Error("Load a VSDX file before downloading a source-backed copy.");
1079
1085
  if (this.#state.edit.busy || this.#state.loading) throw new Error("Wait for the current document operation before downloading.");
1080
1086
  return this.#history.export();
1081
1087
  }
@@ -1083,10 +1089,13 @@ var ViewerController = class {
1083
1089
  ++this.#editId;
1084
1090
  this.editor.cancel?.();
1085
1091
  }
1092
+ #sourceEditState() {
1093
+ return this.#sourceFormat === "vsdx" && this.#state.document?.format === "vsdx" ? this.#history?.state ?? EMPTY_EDIT_STATE : EMPTY_EDIT_STATE;
1094
+ }
1086
1095
  async #mutate(kind, commands) {
1087
1096
  this.#assertAlive();
1088
1097
  const history = this.#history;
1089
- if (!history) throw new Error("Load a VSDX file before editing. Model-only documents are read only.");
1098
+ 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.");
1090
1099
  if (this.#state.loading || this.#state.edit.busy) throw new Error("Another document operation is in progress.");
1091
1100
  const target = kind === "undo" ? history.undoTarget : kind === "redo" ? history.redoTarget : void 0;
1092
1101
  if (kind !== "edit" && !target) return;
@@ -1112,6 +1121,7 @@ var ViewerController = class {
1112
1121
  } else document = await this.parser(Uint8Array.from(target.bytes));
1113
1122
  assertCurrent();
1114
1123
  assertViewableDocument(document);
1124
+ if (document.format !== "vsdx") throw new Error("A VSDX edit or history operation cannot change the source format.");
1115
1125
  if (edited && !edited.changedParts.length) {
1116
1126
  this.#change({ edit: history.state });
1117
1127
  return;
@@ -1201,7 +1211,7 @@ var eventKeys = [
1201
1211
  //#region src/worker-parser.ts
1202
1212
  /** One disposable worker per document prevents old parses and long CPU tasks from occupying the UI. */
1203
1213
  function createWorkerParser(factory, timeoutMs = 15e3) {
1204
- if (!factory && typeof Worker === "undefined") return parseVsdx;
1214
+ if (!factory && typeof Worker === "undefined") return loadVisio;
1205
1215
  const create = factory ?? (() => new Worker(new URL("./parse-worker.js", import.meta.url), { type: "module" }));
1206
1216
  let cancel;
1207
1217
  const parse = (bytes) => {
@@ -2136,7 +2146,7 @@ function selection(target) {
2136
2146
  }
2137
2147
  /** Own every DOM listener and abort them together when the surface is disposed. */
2138
2148
  function wireViewerInputs(controls, controller, fit) {
2139
- const { viewport, commandRoot, pageSelect, searchInput } = controls;
2149
+ const { viewport, commandRoot, zoomSlider, searchInput } = controls;
2140
2150
  const events = new ((viewport.ownerDocument.defaultView?.AbortController) ?? AbortController)();
2141
2151
  const options = { signal: events.signal };
2142
2152
  searchInput.addEventListener("input", () => controller.setSearchQuery(searchInput.value), options);
@@ -2152,13 +2162,9 @@ function wireViewerInputs(controls, controller, fit) {
2152
2162
  controller.setSearchQuery("");
2153
2163
  }
2154
2164
  }, options);
2155
- pageSelect.addEventListener("change", () => controller.setPage(Number(pageSelect.value)), options);
2165
+ zoomSlider.addEventListener("input", () => controller.setZoom(zoomSlider.value / 100), options);
2156
2166
  commandRoot.addEventListener("click", (event) => {
2157
2167
  const action = event.target?.closest?.("button")?.dataset.action;
2158
- if (action === "in") controller.setZoom(controller.state.zoom * 1.25);
2159
- if (action === "out") controller.setZoom(controller.state.zoom / 1.25);
2160
- if (action === "fit") fit();
2161
- if (action === "actual") controller.setZoom(1);
2162
2168
  if (action === "search-next") controller.nextSearchResult();
2163
2169
  if (action === "search-previous") controller.previousSearchResult();
2164
2170
  }, options);
@@ -2197,7 +2203,7 @@ function wireViewerInputs(controls, controller, fit) {
2197
2203
  }
2198
2204
  if (event.key === "0") {
2199
2205
  event.preventDefault();
2200
- fit();
2206
+ fit("page");
2201
2207
  }
2202
2208
  }, options);
2203
2209
  return () => events.abort();
@@ -2301,13 +2307,7 @@ button:disabled { opacity:.4; cursor:default; }
2301
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; }
2302
2308
  button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-accent-soft); }
2303
2309
  .toolbar { flex:none; min-width:0; border-bottom:1px solid var(--_vv-border); background:var(--_vv-surface); }
2304
- .ribbon-primary { height:32px; display:flex; align-items:center; gap:6px; padding:2px 8px; }
2305
- .page-picker { display:flex; min-width:0; align-items:center; gap:8px; color:var(--_vv-muted); font-size:11px; }
2306
- .page-picker select { width:190px; max-width:42vw; height:26px; min-height:26px; padding:2px 6px; font-size:11px; color:var(--_vv-ink); }
2307
- .page-stepper { display:flex; align-items:center; gap:2px; }
2308
- .page-stepper button { width:26px; height:26px; min-height:26px; padding:0; font-size:18px; line-height:22px; background:transparent; border-color:transparent; }
2309
2310
  .spacer { flex:1; }
2310
- .local-label { color:var(--_vv-muted); font-size:10px; white-space:nowrap; }
2311
2311
  .ribbon-tabs { height:35px; display:flex; align-items:stretch; padding:0 4px; border-bottom:1px solid var(--_vv-border); }
2312
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; }
2313
2313
  .ribbon-tabs button[aria-selected="true"] { color:var(--_vv-accent); }
@@ -2317,15 +2317,6 @@ button[aria-pressed="true"] { color:var(--_vv-accent); background:var(--_vv-acce
2317
2317
  .group-label { font-size:10px; line-height:12px; color:var(--_vv-muted); text-align:center; }
2318
2318
  .ribbon-actions { display:flex; align-items:stretch; gap:2px; flex:1; }
2319
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; }
2320
- .command-icon { display:grid; place-items:center; width:26px; height:26px; color:var(--_vv-accent); }
2321
- .command-icon::before { content:""; width:24px; height:24px; background:currentColor; mask:var(--command-symbol) center / contain no-repeat; }
2322
- [data-icon="selection"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m5%203%2013%209-6%201-3%206z%22%2F%3E%3C%2Fsvg%3E"); }
2323
- [data-icon="edit"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%205h16M12%205v14M8%2019h8%22%2F%3E%3C%2Fsvg%3E"); }
2324
- [data-icon="layers"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m3%208%209-5%209%205-9%205zM3%2012l9%205%209-5M3%2016l9%205%209-5%22%2F%3E%3C%2Fsvg%3E"); }
2325
- [data-icon="pages"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%225%22%20y%3D%223%22%20width%3D%2214%22%20height%3D%2218%22%20rx%3D%221%22%2F%3E%3Cpath%20d%3D%22M8%208h8M8%2012h8M8%2016h5%22%2F%3E%3C%2Fsvg%3E"); }
2326
- [data-icon="inspector"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%224%22%20width%3D%2218%22%20height%3D%2216%22%20rx%3D%221%22%2F%3E%3Cpath%20d%3D%22M14%204v16M17%208h1M17%2012h1M17%2016h1%22%2F%3E%3C%2Fsvg%3E"); }
2327
- [data-icon="notes"] { --command-symbol:url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%204h16v12H9l-5%204z%22%2F%3E%3Cpath%20d%3D%22M8%208h8M8%2012h5%22%2F%3E%3C%2Fsvg%3E"); }
2328
- .ribbon-command[data-chrome="edit"] .command-icon { font-family:ui-serif,Georgia,serif; text-decoration:underline; text-decoration-thickness:1px; text-underline-offset:3px; }
2329
2320
  .ribbon-hint { align-self:center; flex:none; margin:0; padding:0 16px; font-size:11px; line-height:18px; color:var(--_vv-muted); }
2330
2321
  .search-group { width:330px; }
2331
2322
  .search-controls { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
@@ -2397,11 +2388,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2397
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; }
2398
2389
  .status-message { display:flex; gap:16px; min-width:0; }
2399
2390
  .status-message span { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
2400
- .zoom-controls { display:flex; flex:none; align-items:center; gap:3px; }
2401
- .zoom-controls button { min-height:24px; height:24px; padding:1px 6px; border-color:transparent; background:transparent; font-size:10px; }
2402
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:24px; padding:0; font-size:17px; line-height:20px; }
2403
- .zoom-divider { width:1px; height:14px; background:var(--_vv-border); margin:0 3px; }
2404
- .zoom { min-width:40px; font-size:10px; text-align:center; font-variant-numeric:tabular-nums; }
2405
2391
  .empty { max-width:360px; margin:60px auto; padding:16px; text-align:center; color:var(--_vv-muted); font-size:12px; }
2406
2392
  .empty strong { display:block; color:var(--_vv-ink); font-size:18px; font-weight:500; margin-bottom:8px; }
2407
2393
  @media(max-width:1100px) { .ribbon-hint { display:none; } [data-diagnostics] { display:none; } }
@@ -2409,11 +2395,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2409
2395
  button,select,input,textarea { min-height:44px; }
2410
2396
  button { min-width:44px; }
2411
2397
  summary { min-height:44px; line-height:44px; }
2412
- .ribbon-primary { height:52px; padding:4px 8px; }
2413
- .page-picker { flex:1; }
2414
- .page-picker select { width:100%; max-width:none; min-height:44px; height:44px; font-size:12px; }
2415
- .page-stepper button { width:44px; min-height:44px; height:44px; }
2416
- .local-label,.ribbon-primary>.spacer { display:none; }
2417
2398
  .ribbon-tabs { height:44px; }
2418
2399
  .ribbon-tabs button { min-height:44px; padding:8px 16px; }
2419
2400
  .ribbon-content { min-height:86px; }
@@ -2425,7 +2406,7 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2425
2406
  .search-controls button { min-width:44px; font-size:0; padding:3px; }
2426
2407
  .search-controls [data-action="search-previous"]::before { content:"‹"; font-size:22px; }
2427
2408
  .search-controls [data-action="search-next"]::before { content:"›"; font-size:22px; }
2428
- #home-panel>.ribbon-group:not(.search-group) { display:none; }
2409
+ #home-panel>.ribbon-group:not(.search-group):not(.undo-group) { display:none; }
2429
2410
  .ribbon-command { min-width:90px; }
2430
2411
  .workspace { display:block; overflow:auto; }
2431
2412
  .page-rail { width:100%; max-height:190px; border-right:0; border-bottom:1px solid var(--_vv-border); }
@@ -2442,35 +2423,22 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2442
2423
  .notes-strip button { min-height:44px; }
2443
2424
  .status { flex-wrap:wrap; gap:0; padding:4px 8px; }
2444
2425
  .status-message { width:100%; min-height:18px; }
2445
- .zoom-controls { justify-content:flex-end; width:100%; }
2446
- .zoom-controls button { min-height:44px; height:44px; min-width:44px; font-size:11px; }
2447
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; font-size:20px; }
2448
- .zoom { font-size:11px; }
2449
2426
  }
2450
2427
  @media(pointer:coarse) {
2451
2428
  button,select,input,textarea { min-height:44px; }
2452
2429
  button { min-width:44px; }
2453
2430
  summary,.layer-controls label { min-height:44px; line-height:44px; }
2454
- .ribbon-primary { min-height:52px; height:52px; }
2455
- .page-picker select,.page-stepper button { min-height:44px; height:44px; }
2456
- .page-stepper button { min-width:44px; }
2457
2431
  .ribbon-tabs { min-height:44px; height:44px; }
2458
2432
  .ribbon-tabs button { min-height:44px; }
2459
2433
  .ribbon-content { min-height:96px; }
2460
2434
  .search-group { width:380px; max-width:100%; }
2461
- .zoom-controls button,.notes-strip button { min-height:44px; height:44px; }
2462
- .zoom-controls [data-action="in"],.zoom-controls [data-action="out"] { min-width:44px; }
2435
+ .notes-strip button { min-height:44px; height:44px; }
2463
2436
  .layer-controls input { min-height:20px; }
2464
2437
  }
2465
- /* Suite ribbon: tabs lead directly into command groups, with page navigation inside. */
2438
+ /* Suite ribbon: tabs lead directly into Visio command groups. */
2466
2439
  .ribbon-tabs { height:40px; }
2467
2440
  .ribbon-tabs button { min-height:39px; padding:8px 16px; }
2468
2441
  .ribbon-content { min-height:104px; padding:4px 4px; }
2469
- .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; }
2470
- .ribbon-primary .page-picker { flex-direction:column; align-items:start; gap:5px; }
2471
- .ribbon-primary .page-picker select { width:160px; max-width:100%; }
2472
- .ribbon-primary .spacer { display:none; }
2473
- .ribbon-primary .local-label { position:absolute; bottom:4px; left:0; width:100%; text-align:center; }
2474
2442
  .search-group { order:2; width:320px; }
2475
2443
  .ribbon-hint { order:3; padding:10px 18px; }
2476
2444
  .ribbon-command { min-width:82px; }
@@ -2479,13 +2447,9 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2479
2447
  .ribbon-tabs { height:44px; }
2480
2448
  .ribbon-tabs button { min-height:44px; }
2481
2449
  .ribbon-content { min-height:54px; padding:4px 0; }
2482
- .ribbon-primary { min-height:44px; height:44px; width:100%; padding:0 8px; border:0; flex-wrap:nowrap; gap:4px; order:0; }
2483
- .ribbon-primary .page-picker { flex-direction:row; flex:1; align-items:center; gap:8px; }
2484
- .ribbon-primary .page-picker select { flex:1; width:100%; min-width:0; }
2485
2450
  .search-group { flex:0 0 100%; width:100%; min-height:54px; }
2486
2451
  .search-feedback,.group-label { display:none; }
2487
2452
  .ribbon-content { overflow-x:auto; scroll-snap-type:x mandatory; }
2488
- .ribbon-primary,.search-group { scroll-snap-align:start; }
2489
2453
  .workspace { display:flex; position:relative; overflow:hidden; }
2490
2454
  .viewport { flex:1; width:100%; height:100%; min-height:0; padding:16px 4px; }
2491
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; }
@@ -2514,9 +2478,6 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2514
2478
  .status-message { flex:1; }
2515
2479
  @media(max-width:760px) {
2516
2480
  #home-panel { overflow:visible; min-height:54px; padding:4px; }
2517
- .ribbon-primary { width:calc(100% - 92px); min-width:0; flex:1; padding:0 4px; }
2518
- .ribbon-primary .page-picker { font-size:0; gap:0; }
2519
- .ribbon-primary .page-picker select { font-size:12px; }
2520
2481
  .ribbon-tools { display:block; flex:0 0 84px; width:84px; order:1; }
2521
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; }
2522
2483
  .ribbon-tools>summary::-webkit-details-marker { display:none; }
@@ -2528,14 +2489,347 @@ summary::marker { color:var(--_vv-muted); font-size:10px; }
2528
2489
  .ribbon-tools .search-group { order:1; flex:auto; }
2529
2490
  .ribbon-tools .search-feedback { display:block; }
2530
2491
  .status { flex-wrap:nowrap; gap:4px; min-height:48px; }
2531
- .zoom-controls { width:auto; flex:1; gap:0; }
2532
- .zoom-divider { margin:0; }
2533
2492
  .status .notes-strip { display:none; }
2534
2493
  }
2535
2494
  @media(pointer:coarse) { .ribbon-tabs {height:45px;min-height:45px;} .ribbon-tabs button {min-height:44px;height:44px;} }
2536
2495
  @media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; } }
2537
2496
  `;
2538
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 = [":host{--office-foreground:var(--_vv-ink);--office-muted-foreground:var(--_vv-muted);--office-background:var(--_vv-surface);--office-surface:var(--_vv-accent-soft);--office-border:var(--_vv-border);--office-accent:var(--_vv-accent);--office-accent-foreground:var(--vv-accent-ink,#fff);--office-ring:var(--vv-focus,var(--_vv-accent));--office-font:\"Segoe UI\", Arial, sans-serif}.ribbon-row{background:0 0;border:0;flex-wrap:nowrap;align-items:stretch;gap:0;padding:0;display:flex}.ribbon-row office-ui-ribbon-group{min-width:0}.ribbon-row office-ui-button{--office-target-size:28px}.ribbon-row office-ui-button[variant=stacked]::part(button){min-width:64px;min-height:62px;font-size:12px}office-ui-button[disabled]::part(button){opacity:.45}.page-tabs{flex:none}office-ui-status-bar.status{background:var(--_vv-surface);min-height:29px;color:var(--_vv-muted);gap:16px;padding:0 8px 0 12px;font-size:11px}office-ui-status-item[value=\"\"]{display:none}office-ui-status-item[data-page-status]{color:var(--_vv-ink)}.status-message{flex:1;min-width:0}.viewport[data-tool=rectangle],.viewport[data-tool=rectangle] .paper [data-shape-id]{cursor:crosshair}.viewport[data-tool=rectangle]{touch-action:none}.draw-preview{fill:var(--_vv-accent-soft);stroke:var(--_vv-accent);stroke-width:1.5px;stroke-dasharray:4 3;pointer-events:none}.viewport[data-grid=true] .paper{--_vv-grid-minor:#00000012;--_vv-grid-major:#00000029;background-image:linear-gradient(to right, var(--_vv-grid-major) 1px, transparent 1px), linear-gradient(to top, var(--_vv-grid-major) 1px, transparent 1px), linear-gradient(to right, var(--_vv-grid-minor) 1px, transparent 1px), linear-gradient(to top, var(--_vv-grid-minor) 1px, transparent 1px);background-size:var(--vv-inch,96px) var(--vv-inch,96px), var(--vv-inch,96px) var(--vv-inch,96px), calc(var(--vv-inch,96px) / 4) calc(var(--vv-inch,96px) / 4), calc(var(--vv-inch,96px) / 4) calc(var(--vv-inch,96px) / 4);background-position:0 100%}:host(:fullscreen){width:100vw;height:100vh}@media (width<=760px){.ribbon-row{flex:1;min-width:0}.undo-group{flex:none}.ribbon-row office-ui-button[variant=stacked]::part(button){min-width:52px;min-height:44px}.ribbon-tools .tools-content office-ui-ribbon-group{width:100%}.search-group{scroll-snap-align:start}office-ui-status-bar.status{gap:4px;min-height:48px}office-ui-status-bar .notes-strip,office-ui-status-bar .status-message,office-ui-status-item[data-shape-status],office-ui-status-item[data-page-status]{display:none}.zoom-controls{--office-zoom-slider-width:0;flex:1;min-width:0}}", ".paper [data-shape-id]:focus,.paper [data-shape-id]:focus-visible{outline:none}.paper [data-shape-id]:focus-visible>[data-geometry]{stroke-dasharray:.08 .05;stroke:var(--vv-focus,var(--_vv-accent))!important;stroke-width:.04px!important}"].join("\n");
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
2539
2833
  //#region src/edit-error.ts
2540
2834
  /** Core error codes remain visible for a refused transaction; document content is plain text. */
2541
2835
  function editErrorMessage(error) {
@@ -2823,59 +3117,13 @@ var ViewerEditControls = class {
2823
3117
  }
2824
3118
  };
2825
3119
  //#endregion
2826
- //#region src/viewer-search.ts
2827
- 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>`;
2828
- function searchControls(root) {
2829
- return {
2830
- input: root.querySelector("input[type=\"search\"]"),
2831
- status: root.querySelector("#search-status"),
2832
- previous: root.querySelector("[data-action=\"search-previous\"]"),
2833
- next: root.querySelector("[data-action=\"search-next\"]")
2834
- };
2835
- }
2836
- function renderSearchControls(controls, state) {
2837
- if (controls.input.value !== state.search.query) controls.input.value = state.search.query;
2838
- controls.input.disabled = !state.document?.pages.length;
2839
- controls.previous.disabled = controls.next.disabled = !state.search.results.length;
2840
- controls.status.textContent = state.document ? textSearchStatus(state.search) : "Open a diagram to search";
2841
- const result = state.search.results[state.search.activeIndex];
2842
- controls.status.title = result ? `${result.pageName} · ${result.shapeName}: ${result.preview}` : "";
2843
- }
2844
- //#endregion
2845
3120
  //#region src/viewer-chrome.ts
2846
- /** Only static application markup belongs here. All document labels are inserted as text. */
2847
- var viewerChromeTemplate = `
2848
- <div class="toolbar" role="group" aria-label="Diagram controls">
2849
-
2850
- <div class="ribbon-tabs" role="tablist" aria-label="Ribbon">
2851
- <button type="button" id="home-tab" role="tab" aria-selected="true" aria-controls="home-panel" data-tab="home">Home</button>
2852
- <button type="button" id="view-tab" role="tab" aria-selected="false" aria-controls="view-panel" tabindex="-1" data-tab="view">View</button>
2853
- </div>
2854
- <div class="ribbon-content" id="home-panel" role="tabpanel" aria-labelledby="home-tab">
2855
- <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>
2856
- <div class="ribbon-group"><div class="ribbon-actions">
2857
- <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>
2858
- <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>
2859
- <button class="ribbon-command" type="button" data-chrome="layers"><span class="command-icon" data-icon="layers" aria-hidden="true"></span><span>Layers</span></button>
2860
- </div><span class="group-label">Inspect and edit</span></div>
2861
- </div></details>
2862
- <div class="ribbon-primary">
2863
- <label class="page-picker">Page <select aria-label="Page"></select></label>
2864
- <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>
2865
- <span class="spacer"></span><span class="local-label">Local workspace</span>
2866
- </div>
2867
- <p class="ribbon-hint">Select a shape to inspect its details.<br>Text editing is experimental.</p>
2868
- </div>
2869
- <div class="ribbon-content" id="view-panel" role="tabpanel" aria-labelledby="view-tab" hidden>
2870
- <div class="ribbon-group"><div class="ribbon-actions">
2871
- <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>
2872
- <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>
2873
- <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>
2874
- </div><span class="group-label">Workspace panes</span></div>
2875
- <p class="ribbon-hint">Canvas shortcuts: + / − to zoom, 0 to fit.<br>Arrow keys move between focused shapes.</p>
2876
- </div>
2877
- </div>
2878
- <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">
2879
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>
2880
3128
  <div class="viewport" tabindex="0" role="region" aria-label="Diagram canvas"></div>
2881
3129
  <aside id="inspector-pane" class="inspector-pane" aria-label="Drawing inspector">
@@ -2888,14 +3136,15 @@ var viewerChromeTemplate = `
2888
3136
  <details class="notes inspector-card"><summary>Compatibility notes</summary><ul></ul></details>
2889
3137
  </div>
2890
3138
  </aside>
2891
- </div>
2892
- <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>`;
2893
3140
  /** Presentation state stays local; page navigation uses the same controller as every binding. */
2894
3141
  var ViewerChrome = class {
2895
3142
  #root;
2896
3143
  #controller;
2897
3144
  #document;
2898
3145
  #pageList;
3146
+ #pageTabs;
3147
+ #tools;
2899
3148
  #rail;
2900
3149
  #inspector;
2901
3150
  #notes;
@@ -2907,6 +3156,8 @@ var ViewerChrome = class {
2907
3156
  this.#root = root;
2908
3157
  this.#controller = controller;
2909
3158
  this.#pageList = root.querySelector(".page-list");
3159
+ this.#pageTabs = root.querySelector("office-ui-tab-strip");
3160
+ this.#tools = root.querySelector(".ribbon-tools");
2910
3161
  this.#rail = root.querySelector(".page-rail");
2911
3162
  this.#inspector = root.querySelector(".inspector-pane");
2912
3163
  this.#notes = root.querySelector(".notes");
@@ -2917,7 +3168,7 @@ var ViewerChrome = class {
2917
3168
  this.#responsive = view?.matchMedia?.("(max-width: 980px)");
2918
3169
  const compact = view?.matchMedia?.("(max-width: 760px)");
2919
3170
  this.#compact = compact;
2920
- const tools = this.#root.querySelector(".ribbon-tools");
3171
+ const tools = this.#tools;
2921
3172
  const toolLayout = () => {
2922
3173
  tools.open = !compact?.matches;
2923
3174
  };
@@ -2944,22 +3195,16 @@ var ViewerChrome = class {
2944
3195
  this.#root.addEventListener("click", (event) => {
2945
3196
  const button = event.target?.closest?.("button");
2946
3197
  if (!button || button.disabled) return;
2947
- if (compact?.matches && button.dataset.chrome) tools.open = false;
2948
3198
  const tab = button.dataset.tab;
2949
- if (tab === "home" || tab === "view") this.#showTab(tab);
3199
+ if (tab === "home" || tab === "view") this.showTab(tab);
2950
3200
  if (button.dataset.pageIndex !== void 0) this.#controller.setPage(Number(button.dataset.pageIndex));
2951
3201
  const action = button.dataset.chrome;
2952
- if (action === "previous-page") this.#controller.setPage(this.#controller.state.pageIndex - 1);
2953
- if (action === "next-page") this.#controller.setPage(this.#controller.state.pageIndex + 1);
2954
- if (action === "pages") {
2955
- this.#pagesManuallyToggled = true;
2956
- this.#setPane("pages", this.#rail.hidden);
2957
- }
2958
- if (action === "inspector") {
2959
- this.#inspectorManuallyToggled = true;
2960
- this.#setPane("inspector", this.#inspector.hidden);
2961
- }
2962
- 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);
2963
3208
  }, options);
2964
3209
  this.#root.addEventListener("keydown", (event) => {
2965
3210
  const key = event;
@@ -2976,11 +3221,11 @@ var ViewerChrome = class {
2976
3221
  if (key.key === "ArrowUp" || key.key === "ArrowDown") return;
2977
3222
  key.preventDefault();
2978
3223
  const tab = key.key === "Home" ? "home" : key.key === "End" ? "view" : button.dataset.tab === "home" ? "view" : "home";
2979
- this.#showTab(tab);
3224
+ this.showTab(tab);
2980
3225
  this.#root.querySelector(`[data-tab="${tab}"]`).focus();
2981
3226
  } else if (button.dataset.pageIndex !== void 0) {
2982
3227
  key.preventDefault();
2983
- const pages = [...this.#pageList.querySelectorAll("button")];
3228
+ const pages = [...this.#pageList.querySelectorAll("[data-page-index]")];
2984
3229
  const current = Number(button.dataset.pageIndex);
2985
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)));
2986
3231
  this.#controller.setPage(next);
@@ -2990,7 +3235,7 @@ var ViewerChrome = class {
2990
3235
  this.#notes.addEventListener("toggle", () => this.#syncNotes(), options);
2991
3236
  return () => events.abort();
2992
3237
  }
2993
- #showTab(tab) {
3238
+ showTab(tab) {
2994
3239
  for (const button of this.#root.querySelectorAll("[data-tab]")) {
2995
3240
  const selected = button.dataset.tab === tab;
2996
3241
  button.setAttribute("aria-selected", String(selected));
@@ -2998,27 +3243,41 @@ var ViewerChrome = class {
2998
3243
  }
2999
3244
  for (const panel of this.#root.querySelectorAll("[role=\"tabpanel\"]")) panel.hidden = panel.id !== `${tab}-panel`;
3000
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
+ }
3001
3256
  #setPane(pane, visible) {
3002
3257
  if (visible && this.#compact?.matches) {
3003
3258
  const other = pane === "pages" ? "inspector" : "pages";
3004
3259
  (other === "pages" ? this.#rail : this.#inspector).hidden = true;
3005
- this.#root.querySelector(`[data-chrome="${other}"]`).setAttribute("aria-pressed", "false");
3260
+ this.#command(other).setAttribute("pressed", "false");
3006
3261
  }
3007
3262
  (pane === "pages" ? this.#rail : this.#inspector).hidden = !visible;
3008
- this.#root.querySelector(`[data-chrome="${pane}"]`).setAttribute("aria-pressed", String(visible));
3263
+ this.#command(pane).setAttribute("pressed", String(visible));
3009
3264
  this.#syncNotes();
3010
3265
  }
3011
3266
  #syncNotes() {
3012
3267
  this.#root.querySelector(".notes-strip button").setAttribute("aria-expanded", String(!this.#inspector.hidden && this.#notes.open));
3013
3268
  }
3014
- #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;
3015
3272
  this.#inspectorManuallyToggled = true;
3016
3273
  this.#setPane("inspector", true);
3017
3274
  const selector = kind === "notes" ? ".notes" : kind === "edit" ? ".edit-controls" : kind === "layers" ? ".layer-controls" : ".shape-inspector";
3018
3275
  const panel = this.#root.querySelector(selector);
3019
3276
  if (panel.hidden) return;
3020
3277
  panel.open = true;
3021
- panel.querySelector("summary").focus();
3278
+ const text = panel.querySelector("textarea");
3279
+ if (focusText && text && !text.disabled) text.focus();
3280
+ else panel.querySelector("summary").focus();
3022
3281
  panel.scrollIntoView?.({
3023
3282
  block: "nearest",
3024
3283
  inline: "nearest"
@@ -3059,14 +3318,21 @@ var ViewerChrome = class {
3059
3318
  fragment.append(item);
3060
3319
  }
3061
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
+ }));
3062
3326
  if (restoreFocus !== void 0) [...this.#pageList.querySelectorAll("button")].find((button) => button.dataset.pageIndex === restoreFocus)?.focus();
3063
3327
  }
3328
+ this.#pageTabs.selected = page ? String(state.pageIndex) : "";
3064
3329
  for (const button of this.#pageList.querySelectorAll("button")) {
3065
3330
  const selected = Number(button.dataset.pageIndex) === state.pageIndex;
3066
3331
  if (selected) button.setAttribute("aria-current", "page");
3067
3332
  else button.removeAttribute("aria-current");
3068
3333
  button.tabIndex = selected ? 0 : -1;
3069
3334
  }
3335
+ this.#root.querySelector("[data-page-status]").setAttribute("value", page ? `Page ${state.pageIndex + 1} of ${state.document.pages.length}` : "");
3070
3336
  const count = state.document?.pages.length ?? 0;
3071
3337
  this.#root.querySelector("[data-page-count]").textContent = String(count);
3072
3338
  this.#root.querySelector(".page-empty").hidden = count > 0;
@@ -3074,17 +3340,405 @@ var ViewerChrome = class {
3074
3340
  this.#root.querySelector("[data-page-size]").textContent = page ? `${page.width} × ${page.height} in` : "No page";
3075
3341
  this.#root.querySelector("[data-page-shapes]").textContent = page ? `${page.shapes.length} top-level` : "No shapes";
3076
3342
  this.#root.querySelector(".selection-hint").hidden = !!state.selectedShape;
3077
- this.#root.querySelector("[data-chrome=\"previous-page\"]").disabled = !page || state.pageIndex === 0;
3078
- this.#root.querySelector("[data-chrome=\"next-page\"]").disabled = !page || state.pageIndex >= count - 1;
3079
- this.#root.querySelector("[data-chrome=\"selection\"]").disabled = !state.selectedShape;
3080
- this.#root.querySelector("[data-chrome=\"edit\"]").disabled = !page;
3081
- this.#root.querySelector("[data-chrome=\"layers\"]").disabled = this.#root.querySelector(".layer-controls").hidden;
3082
- 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;
3083
3348
  this.#root.querySelector("[data-note-count]").textContent = noteCount ? ` · ${noteCount}` : "";
3084
3349
  this.#syncNotes();
3085
3350
  }
3086
3351
  };
3087
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
3088
3742
  //#region src/export-svg.ts
3089
3743
  var MAX_SVG_EXPORT_BYTES = 16777216;
3090
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.";
@@ -3695,8 +4349,10 @@ var VisioViewerElement = class extends BaseElement {
3695
4349
  #root;
3696
4350
  #disposeInputs;
3697
4351
  #viewport;
3698
- #pageSelect;
3699
- #zoomLabel;
4352
+ #zoomSlider;
4353
+ #shapeStatus;
4354
+ #announcement;
4355
+ #commands;
3700
4356
  #status;
3701
4357
  #diagnostics;
3702
4358
  #toolbar;
@@ -3729,10 +4385,14 @@ var VisioViewerElement = class extends BaseElement {
3729
4385
  constructor() {
3730
4386
  super();
3731
4387
  this.#root = this.attachShadow({ mode: "open" });
3732
- 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));
3733
4393
  this.#viewport = this.#root.querySelector(".viewport");
3734
- this.#pageSelect = this.#root.querySelector("select");
3735
- 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]");
3736
4396
  this.#status = this.#root.querySelector("[data-status]");
3737
4397
  this.#diagnostics = this.#root.querySelector("[data-diagnostics]");
3738
4398
  this.#toolbar = this.#root.querySelector(".toolbar");
@@ -3743,6 +4403,23 @@ var VisioViewerElement = class extends BaseElement {
3743
4403
  this.#notesPanel = this.#root.querySelector(".notes");
3744
4404
  this.#inspector = this.#root.querySelector(".shape-inspector");
3745
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
+ });
3746
4423
  this.#disposeInputs = this.#wireInputs();
3747
4424
  this.#unsubscribe = this.controller.subscribe((state) => this.#render(state));
3748
4425
  this.#eventUnsubscribe = this.controller.onEvent((name, detail) => {
@@ -3816,13 +4493,24 @@ var VisioViewerElement = class extends BaseElement {
3816
4493
  }
3817
4494
  fit() {
3818
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) {
3819
4500
  const page = this.document?.pages[this.pageIndex];
3820
4501
  if (!page) return;
3821
4502
  const style = this.ownerDocument.defaultView?.getComputedStyle(this.#viewport);
3822
4503
  const padding = (value) => Number.parseFloat(value ?? "") || 0;
3823
4504
  const width = Math.max(1, this.#viewport.clientWidth - padding(style?.paddingLeft) - padding(style?.paddingRight));
3824
4505
  const height = Math.max(1, this.#viewport.clientHeight - padding(style?.paddingTop) - padding(style?.paddingBottom));
3825
- 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);
3826
4514
  }
3827
4515
  setLayerVisibility(pageId, layerId, visible) {
3828
4516
  this.#assertAlive();
@@ -3892,16 +4580,18 @@ var VisioViewerElement = class extends BaseElement {
3892
4580
  }
3893
4581
  #wireInputs() {
3894
4582
  const disposeChrome = this.#chrome.wire();
4583
+ const disposeCommands = this.#commands.wire();
3895
4584
  const disposeEdit = this.#edit.wire();
3896
4585
  const disposeLayers = wireLayerControls(this.#layers, this.controller);
3897
4586
  const disposeInputs = wireViewerInputs({
3898
4587
  viewport: this.#viewport,
3899
4588
  commandRoot: this.#root,
3900
- pageSelect: this.#pageSelect,
4589
+ zoomSlider: this.#zoomSlider,
3901
4590
  searchInput: this.#search.input
3902
- }, this.controller, () => this.fit());
4591
+ }, this.controller, (mode) => this.#fit(mode));
3903
4592
  return () => {
3904
4593
  disposeChrome();
4594
+ disposeCommands();
3905
4595
  disposeInputs();
3906
4596
  disposeLayers();
3907
4597
  disposeEdit();
@@ -3920,14 +4610,7 @@ var VisioViewerElement = class extends BaseElement {
3920
4610
  this.#document = state.document;
3921
4611
  this.#renderedPage = state.pageIndex;
3922
4612
  this.#renderedLayerOverrides = state.layerVisibilityOverrides;
3923
- this.#pageSelect.replaceChildren();
3924
- for (const [index, candidate] of (state.document?.pages ?? []).entries()) {
3925
- const option = document.createElement("option");
3926
- option.value = String(index);
3927
- option.textContent = candidate.name + (candidate.isBackground ? " (background)" : "");
3928
- this.#pageSelect.append(option);
3929
- }
3930
- this.#pageSelect.value = String(state.pageIndex);
4613
+ this.#announcement = void 0;
3931
4614
  this.#renderWarnings = [];
3932
4615
  if (state.document && page) {
3933
4616
  const result = renderPage(state.document, page, { layerVisibilityOverrides: state.layerVisibilityOverrides });
@@ -3939,7 +4622,7 @@ var VisioViewerElement = class extends BaseElement {
3939
4622
  empty.className = "empty";
3940
4623
  const heading = document.createElement("strong");
3941
4624
  heading.textContent = "Open a Visio drawing";
3942
- empty.append(heading, "Open a .vsdx file to start. Files stay in this browser.");
4625
+ empty.append(heading, "Open a .vsdx or supported .vsd file to start. Files stay in this browser.");
3943
4626
  this.#viewport.replaceChildren(empty);
3944
4627
  }
3945
4628
  const notes = compatibilityNotes(state.document?.diagnostics ?? [], this.#renderWarnings);
@@ -3954,6 +4637,7 @@ var VisioViewerElement = class extends BaseElement {
3954
4637
  if (svg && page) {
3955
4638
  svg.style.width = `${page.width * 96 * state.zoom}px`;
3956
4639
  svg.style.height = `${page.height * 96 * state.zoom}px`;
4640
+ svg.style.setProperty("--vv-inch", `${96 * state.zoom}px`);
3957
4641
  }
3958
4642
  const searchResult = state.search.results[state.search.activeIndex];
3959
4643
  const selection = state.selectedShape;
@@ -3981,16 +4665,20 @@ var VisioViewerElement = class extends BaseElement {
3981
4665
  this.#renderedSelection = selection ? { ...selection } : null;
3982
4666
  }
3983
4667
  if (!searchResult) this.#revealedSearchResult = void 0;
3984
- this.#zoomLabel.value = `${Math.round(state.zoom * 100)}%`;
3985
- 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)}` : "");
3986
4673
  for (const button of this.#root.querySelectorAll("[data-action]")) button.disabled = !page;
3987
4674
  renderSearchControls(this.#search, state);
3988
4675
  this.#edit.render(state);
3989
4676
  if (changed) renderLayerControls(this.#layers, state);
3990
4677
  else this.#layers.querySelector("[data-layer-reset=\"all\"]").disabled = state.layerVisibilityOverrides.length === 0;
3991
4678
  this.#chrome.render(state, this.#notes.children.length);
4679
+ this.#commands.render(state);
3992
4680
  this.#viewport.setAttribute("aria-busy", String(state.loading || state.edit.busy));
3993
- 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");
3994
4682
  const warnings = (state.document?.diagnostics.length ?? 0) + this.#renderWarnings.length;
3995
4683
  this.#diagnostics.textContent = warnings ? `${this.#notes.children.length} compatibility notes` : "Local-only viewing";
3996
4684
  }
@@ -4200,6 +4888,23 @@ function viewerHandle(current) {
4200
4888
  }
4201
4889
  };
4202
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
+ }
4203
4908
  //#endregion
4204
4909
  //#region packages/bindings/src/solid.tsx
4205
4910
  var _tmpl$ = [
@@ -4243,5 +4948,19 @@ function VisioViewer(props) {
4243
4948
  });
4244
4949
  return ssr(_tmpl$, ssrHydrationKey() + ssrAttribute("class", escape(props.class, true), false), ssrStyle(props.style));
4245
4950
  }
4951
+ /**
4952
+ * Reactive viewer state as a Solid accessor for a handle accessor (for example a signal set by
4953
+ * `viewerRef`). Follows handle changes and cleans up with the owning scope.
4954
+ */
4955
+ function createVisioViewerState(handle) {
4956
+ const [state, setState] = createSignal(null);
4957
+ createEffect(() => {
4958
+ const current = handle();
4959
+ const source = viewerStateSource(() => current);
4960
+ setState(() => source.getSnapshot());
4961
+ onCleanup(source.subscribe(() => setState(() => source.getSnapshot())));
4962
+ });
4963
+ return state;
4964
+ }
4246
4965
  //#endregion
4247
- 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 };
4966
+ 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 };