@teipublisher/pb-components 2.26.1-next.3 → 3.0.0-next-4.2

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.
Files changed (262) hide show
  1. package/.github/workflows/docker-cypress.yml +53 -0
  2. package/.github/workflows/node.js.yml +70 -21
  3. package/.releaserc.json +7 -2
  4. package/CHANGELOG.md +363 -11
  5. package/Dockerfile +78 -70
  6. package/README.md +112 -4
  7. package/css/components.css +5 -5
  8. package/css/gridjs/mermaid.min.css +1 -1
  9. package/css/leaflet/Control.Geocoder.css +1 -126
  10. package/css/leaflet/images/layers.png +0 -0
  11. package/css/tify/tify.css +6 -5
  12. package/css/tom-select/tom-select.bootstrap4.min.css +1 -1
  13. package/css/tom-select/tom-select.bootstrap5.min.css +1 -1
  14. package/css/tom-select/tom-select.default.min.css +1 -1
  15. package/css/tom-select/tom-select.default.min.css.map +1 -0
  16. package/css/tom-select/tom-select.min.css +1 -1
  17. package/cypress.config.js +84 -0
  18. package/dist/api.html +1 -1
  19. package/dist/css/design-system.css +607 -0
  20. package/dist/demo/bundle-test.html +4 -3
  21. package/dist/demo/components.css +46 -1
  22. package/dist/demo/design-system.html +710 -0
  23. package/dist/demo/dts-client.html +2 -2
  24. package/dist/demo/pb-autocomplete.html +23 -11
  25. package/dist/demo/pb-autocomplete2.html +66 -55
  26. package/dist/demo/pb-autocomplete3.html +17 -8
  27. package/dist/demo/pb-blacklab-highlight.html +28 -11
  28. package/dist/demo/pb-blacklab-results.html +3 -2
  29. package/dist/demo/pb-browse-docs.html +24 -24
  30. package/dist/demo/pb-browse-docs2.html +3 -3
  31. package/dist/demo/pb-clipboard.html +32 -28
  32. package/dist/demo/pb-code-editor.html +6 -6
  33. package/dist/demo/pb-code-highlight.html +63 -63
  34. package/dist/demo/pb-codepen.html +1 -1
  35. package/dist/demo/pb-collapse.html +1 -1
  36. package/dist/demo/pb-collapse2.html +2 -2
  37. package/dist/demo/pb-combo-box.html +135 -130
  38. package/dist/demo/pb-custom-form.html +64 -55
  39. package/dist/demo/pb-dialog.html +12 -6
  40. package/dist/demo/pb-document.html +1 -1
  41. package/dist/demo/pb-download.html +68 -59
  42. package/dist/demo/pb-drawer.html +67 -46
  43. package/dist/demo/pb-drawer2.html +65 -58
  44. package/dist/demo/pb-edit-app.html +2 -2
  45. package/dist/demo/pb-edit-xml.html +1 -1
  46. package/dist/demo/pb-facsimile-2.html +26 -11
  47. package/dist/demo/pb-facsimile-3.html +25 -10
  48. package/dist/demo/pb-facsimile-dedup-test-2.html +48 -0
  49. package/dist/demo/pb-facsimile-dedup-test.html +48 -0
  50. package/dist/demo/pb-facsimile.html +4 -4
  51. package/dist/demo/pb-formula.html +1 -1
  52. package/dist/demo/pb-grid.html +22 -8
  53. package/dist/demo/pb-highlight.html +2 -2
  54. package/dist/demo/pb-i18n-simple.html +1 -0
  55. package/dist/demo/pb-i18n.html +15 -5
  56. package/dist/demo/pb-image-strip-standalone.html +2 -2
  57. package/dist/demo/pb-image-strip-view.html +2 -2
  58. package/dist/demo/pb-leaflet-map.html +3 -3
  59. package/dist/demo/pb-leaflet-map2.html +2 -2
  60. package/dist/demo/pb-leaflet-map3.html +3 -3
  61. package/dist/demo/pb-link.html +1 -1
  62. package/dist/demo/pb-load.html +2 -6
  63. package/dist/demo/pb-login.html +1 -3
  64. package/dist/demo/pb-manage-odds.html +9 -4
  65. package/dist/demo/pb-markdown.html +1 -1
  66. package/dist/demo/pb-media-query.html +2 -2
  67. package/dist/demo/pb-mei.html +2 -2
  68. package/dist/demo/pb-mei2.html +2 -2
  69. package/dist/demo/pb-message.html +2 -3
  70. package/dist/demo/pb-odd-editor.html +54 -52
  71. package/dist/demo/pb-page-header.html +27 -0
  72. package/dist/demo/pb-popover.html +1 -1
  73. package/dist/demo/pb-print-preview.html +2 -2
  74. package/dist/demo/pb-progress.html +4 -4
  75. package/dist/demo/pb-repeat.html +32 -36
  76. package/dist/demo/pb-search.html +16 -5
  77. package/dist/demo/pb-search2.html +4 -4
  78. package/dist/demo/pb-search3.html +3 -3
  79. package/dist/demo/pb-search4.html +3 -3
  80. package/dist/demo/pb-select-feature.html +4 -4
  81. package/dist/demo/pb-select-feature2.html +4 -4
  82. package/dist/demo/pb-select-feature3.html +2 -2
  83. package/dist/demo/pb-select-i18n.html +58 -53
  84. package/dist/demo/pb-select-odd.html +1 -1
  85. package/dist/demo/pb-select.html +190 -75
  86. package/dist/demo/pb-select2.html +91 -37
  87. package/dist/demo/pb-select3.html +109 -41
  88. package/dist/demo/pb-svg.html +1 -1
  89. package/dist/demo/pb-table-grid.html +26 -15
  90. package/dist/demo/pb-tabs.html +15 -7
  91. package/dist/demo/pb-tify.html +7 -7
  92. package/dist/demo/pb-timeline.html +1 -1
  93. package/dist/demo/pb-timeline2.html +1 -1
  94. package/dist/demo/pb-toggle-feature.html +26 -23
  95. package/dist/demo/pb-toggle-feature2.html +4 -4
  96. package/dist/demo/pb-toggle-feature3.html +2 -2
  97. package/dist/demo/pb-toggle-feature4.html +56 -54
  98. package/dist/demo/pb-version.html +2 -2
  99. package/dist/demo/pb-view.html +78 -40
  100. package/dist/demo/pb-view2.html +69 -46
  101. package/dist/demo/pb-view3.html +53 -48
  102. package/dist/demo/pb-view4.html +70 -49
  103. package/dist/demo/pb-zoom.html +2 -2
  104. package/dist/{es-global-bridge-d8ce175d.js → es-global-bridge-D8ZcUcx_.js} +0 -4
  105. package/dist/focus-mixin-VCsFap6b.js +768 -0
  106. package/dist/images/icons.svg +217 -0
  107. package/dist/jinn-codemirror-DETLdm08.js +1 -0
  108. package/dist/lib/openseadragon.min.js +80 -0
  109. package/dist/lib/openseadragon.min.js.map +1 -0
  110. package/dist/pb-code-editor.js +25 -20
  111. package/dist/pb-component-docs.js +414 -3225
  112. package/dist/pb-components-bundle.js +3046 -4402
  113. package/dist/pb-dialog-tklYGWfc.js +121 -0
  114. package/dist/pb-edit-app.js +208 -107
  115. package/dist/pb-elements.json +716 -249
  116. package/dist/pb-facsimile.js +46 -0
  117. package/dist/pb-i18n-C0NDma4h.js +1 -0
  118. package/dist/pb-leaflet-map.js +23 -23
  119. package/dist/pb-mei.js +152 -134
  120. package/dist/pb-mixin-DHoWQheB.js +1 -0
  121. package/dist/pb-odd-editor.js +1671 -1231
  122. package/dist/pb-tify.js +1 -27
  123. package/dist/unsafe-html-D5VGo9Oq.js +1 -0
  124. package/dist/urls-BEONu_g4.js +1 -0
  125. package/eslint.config.mjs +92 -0
  126. package/gh-pages.js +5 -3
  127. package/i18n/common/en.json +6 -0
  128. package/i18n/common/pl.json +2 -2
  129. package/images/icons.svg +217 -0
  130. package/index.html +0 -5
  131. package/lib/leaflet-src.js.map +1 -0
  132. package/lib/leaflet.markercluster-src.js.map +1 -0
  133. package/lib/openseadragon.min.js +6 -6
  134. package/package.json +56 -81
  135. package/pb-elements.json +716 -249
  136. package/rollup.config.mjs +312 -0
  137. package/src/assets/components.css +5 -5
  138. package/src/assets/design-system.css +607 -0
  139. package/src/authority/airtable.js +20 -21
  140. package/src/authority/anton.js +129 -129
  141. package/src/authority/custom.js +70 -27
  142. package/src/authority/geonames.js +38 -32
  143. package/src/authority/gnd.js +50 -42
  144. package/src/authority/kbga.js +136 -134
  145. package/src/authority/metagrid.js +44 -46
  146. package/src/authority/reconciliation.js +66 -68
  147. package/src/authority/registry.js +4 -4
  148. package/src/docs/demo-utils.js +91 -0
  149. package/src/docs/pb-component-docs.js +287 -147
  150. package/src/docs/pb-component-view.js +380 -273
  151. package/src/docs/pb-components-list.js +115 -51
  152. package/src/docs/pb-demo-snippet.js +199 -174
  153. package/src/dts-client.js +306 -303
  154. package/src/dts-select-endpoint.js +125 -85
  155. package/src/parse-date-service.js +184 -135
  156. package/src/pb-ajax.js +175 -173
  157. package/src/pb-authority-lookup.js +198 -158
  158. package/src/pb-autocomplete.js +731 -313
  159. package/src/pb-blacklab-highlight.js +266 -260
  160. package/src/pb-blacklab-results.js +230 -225
  161. package/src/pb-browse-docs.js +601 -484
  162. package/src/pb-browse.js +68 -65
  163. package/src/pb-clipboard.js +97 -76
  164. package/src/pb-code-editor.js +111 -103
  165. package/src/pb-code-highlight.js +234 -204
  166. package/src/pb-codepen.js +81 -73
  167. package/src/pb-collapse.js +265 -152
  168. package/src/pb-combo-box.js +191 -191
  169. package/src/pb-components-bundle.js +1 -7
  170. package/src/pb-components.js +2 -6
  171. package/src/pb-custom-form.js +230 -141
  172. package/src/pb-dialog.js +99 -63
  173. package/src/pb-document.js +118 -91
  174. package/src/pb-download.js +214 -198
  175. package/src/pb-drawer.js +146 -149
  176. package/src/pb-edit-app.js +471 -240
  177. package/src/pb-edit-xml.js +101 -98
  178. package/src/pb-events.js +126 -107
  179. package/src/pb-facs-link.js +130 -101
  180. package/src/pb-facsimile.js +494 -410
  181. package/src/pb-fetch.js +389 -0
  182. package/src/pb-formula.js +152 -154
  183. package/src/pb-geolocation.js +130 -132
  184. package/src/pb-grid-action.js +59 -56
  185. package/src/pb-grid.js +388 -228
  186. package/src/pb-highlight.js +142 -142
  187. package/src/pb-hotkeys.js +40 -42
  188. package/src/pb-i18n.js +115 -127
  189. package/src/pb-icon-button.js +108 -0
  190. package/src/pb-icon.js +283 -0
  191. package/src/pb-image-strip.js +85 -79
  192. package/src/pb-lang.js +142 -57
  193. package/src/pb-leaflet-map.js +551 -483
  194. package/src/pb-link.js +132 -126
  195. package/src/pb-load.js +495 -428
  196. package/src/pb-login.js +303 -248
  197. package/src/pb-manage-odds.js +384 -338
  198. package/src/pb-map-icon.js +90 -90
  199. package/src/pb-map-layer.js +86 -86
  200. package/src/pb-markdown.js +107 -110
  201. package/src/pb-media-query.js +75 -73
  202. package/src/pb-mei.js +523 -303
  203. package/src/pb-message.js +144 -98
  204. package/src/pb-mixin.js +268 -265
  205. package/src/pb-navigation.js +83 -96
  206. package/src/pb-observable.js +39 -39
  207. package/src/pb-odd-editor.js +1209 -948
  208. package/src/pb-odd-elementspec-editor.js +375 -310
  209. package/src/pb-odd-model-editor.js +1189 -941
  210. package/src/pb-odd-parameter-editor.js +269 -170
  211. package/src/pb-odd-rendition-editor.js +184 -131
  212. package/src/pb-page.js +451 -422
  213. package/src/pb-paginate.js +260 -178
  214. package/src/pb-panel.js +217 -183
  215. package/src/pb-popover-themes.js +16 -9
  216. package/src/pb-popover.js +297 -288
  217. package/src/pb-print-preview.js +128 -128
  218. package/src/pb-progress.js +52 -52
  219. package/src/pb-repeat.js +141 -108
  220. package/src/pb-restricted.js +85 -78
  221. package/src/pb-search.js +258 -230
  222. package/src/pb-select-feature.js +210 -126
  223. package/src/pb-select-odd.js +184 -118
  224. package/src/pb-select-template.js +113 -78
  225. package/src/pb-select.js +330 -229
  226. package/src/pb-split-list.js +181 -176
  227. package/src/pb-svg.js +81 -80
  228. package/src/pb-table-column.js +55 -55
  229. package/src/pb-table-grid.js +334 -205
  230. package/src/pb-tabs.js +238 -61
  231. package/src/pb-tify.js +3331 -126
  232. package/src/pb-timeline.js +394 -255
  233. package/src/pb-toggle-feature.js +196 -188
  234. package/src/pb-upload.js +201 -176
  235. package/src/pb-version.js +22 -34
  236. package/src/pb-view-annotate.js +138 -102
  237. package/src/pb-view.js +1722 -1272
  238. package/src/pb-zoom.js +144 -46
  239. package/src/search-result-service.js +256 -223
  240. package/src/seed-element.js +14 -22
  241. package/src/settings.js +4 -4
  242. package/src/theming.js +98 -91
  243. package/src/urls.js +403 -289
  244. package/src/utils.js +53 -51
  245. package/vite.config.js +86 -0
  246. package/.github/workflows/main.yml +0 -24
  247. package/.github/workflows/release.js.yml +0 -34
  248. package/css/pb-styles.css +0 -51
  249. package/dist/iron-form-3b8dcaa7.js +0 -210
  250. package/dist/jinn-codemirror-da0e2d1f.js +0 -1
  251. package/dist/paper-checkbox-515a5284.js +0 -1597
  252. package/dist/paper-icon-button-b1d31571.js +0 -398
  253. package/dist/paper-listbox-a3b7175c.js +0 -1265
  254. package/dist/pb-i18n-0611135a.js +0 -1
  255. package/dist/pb-mixin-b1caa22e.js +0 -158
  256. package/dist/polymer-hack.js +0 -1
  257. package/dist/vaadin-element-mixin-fe4a4883.js +0 -527
  258. package/lib/Control.Geocoder.min.js +0 -2
  259. package/lib/Control.Geocoder.min.js.map +0 -1
  260. package/src/assets/pb-styles.css +0 -51
  261. package/src/pb-light-dom.js +0 -41
  262. package/src/polymer-hack.js +0 -6
package/src/pb-tify.js CHANGED
@@ -1,18 +1,19 @@
1
- import { LitElement } from 'lit-element';
2
- import "tify";
3
- import { pbMixin, waitOnce } from './pb-mixin.js';
1
+ import { LitElement } from 'lit';
2
+ import 'tify';
3
+ import { pbMixin, waitOnce, defaultChannel, getEmittedChannels } from './pb-mixin.js';
4
4
  import { resolveURL } from './utils.js';
5
+ import { registry } from './urls.js';
5
6
 
6
7
  function _injectStylesheet(path) {
7
- const style = document.querySelector(`link#pb-tify`);
8
- if (!style) {
9
- const elem = document.createElement('link');
10
- elem.type = 'text/css';
11
- elem.rel = 'stylesheet';
12
- elem.id = `pb-tify`;
13
- elem.href = `${resolveURL(path)}/tify.css`;
14
- document.head.appendChild(elem);
15
- }
8
+ const style = document.querySelector(`link#pb-tify`);
9
+ if (!style) {
10
+ const elem = document.createElement('link');
11
+ elem.type = 'text/css';
12
+ elem.rel = 'stylesheet';
13
+ elem.id = `pb-tify`;
14
+ elem.href = `${resolveURL(path)}/tify.css`;
15
+ document.head.appendChild(elem);
16
+ }
16
17
  }
17
18
 
18
19
  /**
@@ -26,170 +27,3374 @@ function _injectStylesheet(path) {
26
27
  * `order` property in the event (see `pb-facs-link`). Page counts start at 1.
27
28
  */
28
29
  export class PbTify extends pbMixin(LitElement) {
29
- static get properties() {
30
- return {
31
- /**
32
- * URL pointing to a IIIF presentation manifest. Relative paths
33
- * are interpreted relative to the API endpoint.
34
- */
35
- manifest: {
36
- type: String
37
- },
38
- ...super.properties
39
- };
30
+ static get properties() {
31
+ return {
32
+ /**
33
+ * URL pointing to a IIIF presentation manifest. Relative paths
34
+ * are interpreted relative to the API endpoint.
35
+ */
36
+ manifest: {
37
+ type: String,
38
+ },
39
+ /**
40
+ * If true, pb-tify will respond to pb-navigate events to synchronize
41
+ * with navigation buttons. Defaults to true.
42
+ */
43
+ enableNavigation: {
44
+ type: Boolean,
45
+ attribute: 'enable-navigation',
46
+ reflect: true,
47
+ },
48
+ /**
49
+ * The initial view to display. Can be 'toc' (table of contents),
50
+ * 'thumbnails', 'text', 'info', 'help', 'export', or null for media only.
51
+ * Defaults to null (media view only).
52
+ */
53
+ initialView: {
54
+ type: String,
55
+ attribute: 'initial-view',
56
+ reflect: true,
57
+ },
58
+ /**
59
+ * If set, do not update browser history when navigating.
60
+ * Defaults to false (history is enabled).
61
+ */
62
+ disableHistory: {
63
+ type: Boolean,
64
+ attribute: 'disable-history',
65
+ reflect: true,
66
+ },
67
+ ...super.properties,
68
+ };
69
+ }
70
+
71
+ constructor() {
72
+ super();
73
+ this.cssPath = '../css/tify';
74
+ this._initialPages = null;
75
+ this._currentPage = null;
76
+ this.enableNavigation = true;
77
+ this.disableHistory = false;
78
+ this._pendingNavigation = null; // Queue for navigation when viewer isn't ready
79
+ this._pendingPage = null; // Queue for page navigation when canvases aren't ready
80
+ this._canvasWaitStart = null; // Track when we started waiting for canvases
81
+ this._manifestUrl = null; // Store the manifest URL for fallback fetching
82
+ this._cachedManifest = null; // Cache the fetched manifest
83
+ this._lastSwitchPageTime = 0; // Track when we last called _switchPage to prevent rapid-fire events
84
+ this._lastSwitchPageCanvasId = null; // Track the last canvas we switched to
85
+ // Simplified state management - single object replaces multiple flags
86
+ this._navigationState = null; // { source, targetPage, targetId, timestamp, isActive }
87
+
88
+ // Initial load flag - still needed for initial page load sequence
89
+ this._initialLoadComplete = false;
90
+ this._lastSetPage = null; // Track last page set to prevent duplicates
91
+ this._lastSetPageTime = 0; // Track when we last set a page
92
+ this._pageToRootMap = null; // Cache: page number (e.g., "011") -> root (e.g., "3.5.6.1")
93
+ this._fetchingPageToRootMap = false; // Flag to prevent concurrent fetches
94
+ }
95
+ /**
96
+ * Ensure a mount container exists and is attached.
97
+ * Idempotent: safe to call multiple times.
98
+ */
99
+ _ensureContainer() {
100
+ if (!this._container) {
101
+ this._container = document.createElement('div');
102
+ this._container.style.height = '100%';
103
+ this._container.style.width = '100%';
104
+ this.appendChild(this._container);
40
105
  }
106
+ }
107
+ attributeChangedCallback(name, oldVal, newVal) {
108
+ super.attributeChangedCallback(name, oldVal, newVal);
41
109
 
42
- constructor() {
43
- super();
44
- this.cssPath = '../css/tify';
45
- this._initialPages = null;
46
- this._currentPage = null;
110
+ if (name === 'manifest' && newVal) {
111
+ this.manifest = newVal;
112
+ this._initViewer();
47
113
  }
114
+ }
48
115
 
49
- attributeChangedCallback(name, oldVal, newVal) {
50
- super.attributeChangedCallback(name, oldVal, newVal);
116
+ async connectedCallback() {
117
+ super.connectedCallback();
118
+
119
+ _injectStylesheet(this.cssPath);
120
+
121
+ // Ensure container exists even if _initViewer ran early
122
+ this._ensureContainer();
123
+
124
+ // Read initial URL state early (like pb-view does)
125
+ // This ensures we know what page to load before Tify initializes
126
+ if (!this.disableHistory) {
127
+ const state = registry.getState(this);
128
+ // Store whether URL has a page (id or root parameter)
129
+ // We'll resolve the actual page number when Tify is ready
130
+ this._hasInitialUrlPage = !!(state.id || state.root);
131
+ }
132
+
133
+ this.subscribeTo('pb-show-annotation', ev => {
134
+ if (ev.detail) {
135
+ const order = ev.detail.order ? Number(ev.detail.order) : Number.POSITIVE_INFINITY;
136
+ const pageOrder = order === Number.POSITIVE_INFINITY ? 1 : order;
51
137
 
52
- if (name === 'manifest' && newVal) {
53
- this.manifest = newVal;
54
- this._initViewer();
138
+ // CRITICAL FIX: Filter out auto-triggered pb-show-annotation events from pb-facs-link elements
139
+ // that don't match the current registry state. This prevents newly loaded content from
140
+ // triggering unwanted navigation. BUT: Always allow navigation if Tify is on a different page
141
+ // (user might be navigating) or if the event matches the registry state.
142
+ if (!this.disableHistory && this._initialLoadComplete && this._tify && this._tify.viewer) {
143
+ const registryState = registry.getState(this);
144
+
145
+ // Check what page Tify is currently showing
146
+ let currentTifyPage = null;
147
+ try {
148
+ if (typeof this._tify.viewer.currentPage === 'function') {
149
+ const page = this._tify.viewer.currentPage();
150
+ if (typeof page === 'number') {
151
+ currentTifyPage = page + 1;
152
+ }
153
+ }
154
+ } catch (e) {
155
+ // Ignore
156
+ }
157
+
158
+ // If Tify is already on the target page, ignore the event (no need to navigate)
159
+ if (currentTifyPage === pageOrder) {
160
+ return;
161
+ }
162
+
163
+ // Try to get the page ID for this order number to compare with registry
164
+ const root = this._getRootFromApp();
165
+ if (root && registryState.id) {
166
+ const canvases = this._getCanvases(root);
167
+ if (canvases.length > 0 && pageOrder >= 1 && pageOrder <= canvases.length) {
168
+ const canvas = canvases[pageOrder - 1];
169
+
170
+ // Extract page ID from canvas (label or rendering URL)
171
+ let pageId = null;
172
+
173
+ // Try to get ID from canvas label (e.g., "003" -> "A-N-38_003.jpg")
174
+ if (canvas.label) {
175
+ const label = canvas.label.none || canvas.label.en || canvas.label;
176
+ const labelStr = Array.isArray(label) ? label[0] : label;
177
+ if (labelStr) {
178
+ const pathParts = window.location.pathname.split('/');
179
+ let docId = null;
180
+ for (let i = pathParts.length - 1; i >= 0; i--) {
181
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
182
+ docId = pathParts[i];
183
+ break;
184
+ }
185
+ }
186
+ if (docId) {
187
+ const pageNum = String(labelStr).padStart(3, '0');
188
+ pageId = `${docId}_${pageNum}.jpg`;
189
+ }
190
+ }
191
+ }
192
+
193
+ // Try to get ID from rendering URL
194
+ if (!pageId && canvas.rendering && canvas.rendering.length > 0) {
195
+ const renderingId = canvas.rendering[0]['@id'] || canvas.rendering[0].id;
196
+ if (renderingId) {
197
+ try {
198
+ const url = new URL(renderingId, window.location.origin);
199
+ pageId = url.searchParams.get('id');
200
+ } catch (e) {
201
+ // Ignore
202
+ }
203
+ }
204
+ }
205
+
206
+ // Only block if: event page ID doesn't match registry AND Tify is on registry page
207
+ // This means it's a stale auto-trigger from content loading
208
+ // If Tify is on a different page, allow it (user might be navigating)
209
+ if (pageId && pageId !== registryState.id) {
210
+ // Find what page order corresponds to the registry ID
211
+ let registryPageOrder = null;
212
+ for (let i = 0; i < canvases.length; i++) {
213
+ const c = canvases[i];
214
+ let cId = null;
215
+ if (c.label) {
216
+ const label = c.label.none || c.label.en || c.label;
217
+ const labelStr = Array.isArray(label) ? label[0] : label;
218
+ if (labelStr) {
219
+ const pathParts = window.location.pathname.split('/');
220
+ let docId = null;
221
+ for (let j = pathParts.length - 1; j >= 0; j--) {
222
+ if (pathParts[j] && pathParts[j] !== 'apps' && pathParts[j] !== 'exist') {
223
+ docId = pathParts[j];
224
+ break;
225
+ }
226
+ }
227
+ if (docId) {
228
+ const pageNum = String(labelStr).padStart(3, '0');
229
+ cId = `${docId}_${pageNum}.jpg`;
230
+ }
231
+ }
232
+ }
233
+ if (cId === registryState.id) {
234
+ registryPageOrder = i + 1;
235
+ break;
236
+ }
237
+ }
238
+
239
+ // Only ignore if Tify is on the registry page (stale auto-trigger)
240
+ // If Tify is on a different page, allow navigation (user-initiated)
241
+ if (currentTifyPage === registryPageOrder) {
242
+ return;
243
+ }
244
+ // Otherwise allow it - Tify is on a different page, might be user navigation
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ this._initialPages = pageOrder;
251
+ const url = ev.detail.file || ev.detail.url;
252
+ if (url && url !== this.manifest) {
253
+ this.manifest = ev.detail.file;
254
+ this._initViewer();
255
+ // check if tify is already initialized
256
+ } else if (this._setPage) {
257
+ // Convert to array format that Tify expects
258
+ const pagesArray = Array.isArray(this._initialPages) ? this._initialPages : [this._initialPages];
259
+ this._setPage(pagesArray);
260
+ }
261
+
262
+ if (ev.detail.coordinates) {
263
+ this._addOverlay(ev.detail.coordinates);
264
+ }
265
+ }
266
+ });
267
+
268
+ // Subscribe to registry for URL changes (back/forward navigation)
269
+ if (!this.disableHistory) {
270
+ registry.subscribe(this, (state) => {
271
+ // Only handle URL changes after Tify is fully ready
272
+ // Initial load is handled in setInitialPage
273
+ if (this._initialLoadComplete) {
274
+ this._handleUrlChange(state);
55
275
  }
276
+ });
277
+
278
+ // Initial URL state will be handled in _onTifyFullyReady
279
+ // to ensure Tify is fully initialized before loading from URL
56
280
  }
57
281
 
58
- async connectedCallback() {
59
- super.connectedCallback();
282
+ // Subscribe to pb-navigate events if navigation is enabled
283
+ if (this.enableNavigation !== false) {
284
+ this.subscribeTo('pb-navigate', ev => {
285
+ if (!ev.detail || !ev.detail.direction) {
286
+ return;
287
+ }
288
+
289
+ const direction = ev.detail.direction;
290
+
291
+ // Check if tify is initialized
292
+ if (!this._tify || !this._tify.app) {
293
+ return;
294
+ }
295
+
296
+ // Wait for tify to be ready if it's not yet
297
+ const root = this._getRootFromApp();
298
+ if (!root) {
299
+ this._pendingNavigation = direction;
300
+ if (this._tify.ready) {
301
+ this._tify.ready.then(() => {
302
+ // Wait a bit more for root to be available
303
+ const waitForRoot = () => {
304
+ const root = this._getRootFromApp();
305
+ if (root) {
306
+ if (this._pendingNavigation) {
307
+ const queuedDirection = this._pendingNavigation;
308
+ this._pendingNavigation = null;
309
+ this._handleNavigate(queuedDirection);
310
+ }
311
+ } else {
312
+ // Retry after a short delay (max 10 seconds)
313
+ if (!this._navWaitStart) {
314
+ this._navWaitStart = Date.now();
315
+ }
316
+ const elapsed = Date.now() - this._navWaitStart;
317
+ if (elapsed < 10000) {
318
+ setTimeout(waitForRoot, 50);
319
+ } else {
320
+ // Try to proceed anyway
321
+ if (this._pendingNavigation) {
322
+ const queuedDirection = this._pendingNavigation;
323
+ this._pendingNavigation = null;
324
+ this._handleNavigate(queuedDirection);
325
+ }
326
+ }
327
+ }
328
+ };
329
+ waitForRoot();
330
+ });
331
+ }
332
+ return;
333
+ }
334
+
335
+ this._handleNavigate(direction);
336
+ });
337
+ }
338
+
339
+ this.signalReady();
340
+ }
60
341
 
61
- _injectStylesheet(this.cssPath);
342
+ disconnectedCallback() {
343
+ super.disconnectedCallback();
344
+
345
+ // Clean up Vue store watcher if it exists
346
+ if (this._vueStoreWatcher && typeof this._vueStoreWatcher === 'function') {
347
+ this._vueStoreWatcher();
348
+ this._vueStoreWatcher = null;
349
+ }
350
+
351
+ // Clean up polling interval if it exists
352
+ if (this._vueStoreWatcherInterval) {
353
+ clearInterval(this._vueStoreWatcherInterval);
354
+ this._vueStoreWatcherInterval = null;
355
+ }
356
+
357
+ // Clean up Tify error watcher if it exists
358
+ if (this._tifyErrorWatcherInterval) {
359
+ clearInterval(this._tifyErrorWatcherInterval);
360
+ this._tifyErrorWatcherInterval = null;
361
+ }
362
+
363
+ // Cleanup is handled by registry automatically
364
+ }
365
+
366
+ firstUpdated() {
367
+ super.firstUpdated();
368
+
369
+ waitOnce('pb-page-ready', () => {
370
+ this._initViewer();
371
+ });
372
+ }
62
373
 
63
- this._container = document.createElement('div');
64
- this._container.style.height = '100%';
65
- this._container.style.width = '100%';
66
- this.appendChild(this._container);
374
+ _initViewer() {
375
+ // Make sure a mount point exists even if called before connectedCallback
376
+ this._ensureContainer();
377
+
378
+ // Don't initialize if no manifest is provided
379
+ if (!this.manifest || this.manifest.trim() === '') {
380
+ return;
381
+ }
382
+
383
+ // Clean up existing Tify instance and watchers before creating a new one
384
+ if (this._tify) {
385
+ this._tify.destroy();
386
+ }
387
+
388
+ // Clean up Vue store watcher if it exists (prevents duplicate polling)
389
+ if (this._vueStoreWatcherInterval) {
390
+ clearInterval(this._vueStoreWatcherInterval);
391
+ this._vueStoreWatcherInterval = null;
392
+ }
393
+ if (this._vueStoreWatcher && typeof this._vueStoreWatcher === 'function') {
394
+ this._vueStoreWatcher();
395
+ this._vueStoreWatcher = null;
396
+ }
397
+
398
+ // Clean up Tify error watcher if it exists
399
+ if (this._tifyErrorWatcherInterval) {
400
+ clearInterval(this._tifyErrorWatcherInterval);
401
+ this._tifyErrorWatcherInterval = null;
402
+ }
403
+
404
+ try {
405
+ const endpoint = this.getEndpoint();
406
+
407
+ // In component test environments, endpoint might be undefined
408
+ // Use a default endpoint for testing purposes
409
+ const effectiveEndpoint = endpoint || 'http://localhost:5173';
410
+
411
+ const manifestUrl = this.toAbsoluteURL(this.manifest, effectiveEndpoint);
412
+
413
+ // Only validate that we have a manifest URL - let Tify handle invalid URLs
414
+ if (!manifestUrl || manifestUrl.trim() === '') {
415
+ console.warn('<pb-tify> Invalid manifest URL:', this.manifest);
416
+ return;
417
+ }
418
+
419
+ // Store manifest URL for fallback fetching
420
+ this._manifestUrl = manifestUrl;
421
+
422
+ // Check URL for initial page BEFORE creating Tify
423
+ // This allows us to set the page immediately when Tify is ready
424
+ const state = registry.getState(this);
425
+ this._initialUrlPage = null;
426
+ if (state.id || state.root) {
427
+ // Store that we have a URL page - will resolve actual page number after Tify is ready
428
+ this._hasInitialUrlPage = true;
429
+ }
430
+
431
+ // Build tify options
432
+ const tifyOptions = {
433
+ manifestUrl: manifestUrl,
434
+ };
435
+
436
+ // Set initial view if specified (e.g., 'toc' to show table of contents with double pages)
437
+ if (this.initialView) {
438
+ tifyOptions.view = this.initialView;
439
+ }
440
+
441
+ this._tify = new Tify(tifyOptions);
442
+
443
+ // Try to listen to Tify's internal events if available
444
+ // Some versions of Tify might emit custom events when pages change
445
+ if (this._tify && this._tify.viewer && this._tify.viewer.$el) {
446
+ // Tify events are now handled via registry subscription
447
+ // No need to listen to individual Tify events
448
+ }
449
+
450
+ // Wait for ready, then try to get root, but proceed either way
451
+ // The viewer should display even without root - we only need root for navigation
452
+ if (this._tify && this._tify.ready) {
453
+ this._tify.ready.then(() => {
454
+ // Try to get root, but don't wait too long
455
+ const waitForRoot = () => {
456
+ if (this._tify && this._tify.app) {
457
+ // Try different ways to access the manifest/root data
458
+ const root = this._tify.app.$root || this._tify.app.root || this._tify.app.manifest || (this._tify.app.$data && this._tify.app.$data.root);
459
+ if (root) {
460
+ this._onTifyFullyReady();
461
+ return;
462
+ }
463
+ }
464
+
465
+ // Retry after a short delay (max 2 seconds - don't wait too long)
466
+ if (!this._rootWaitStart) {
467
+ this._rootWaitStart = Date.now();
468
+ }
469
+ const elapsed = Date.now() - this._rootWaitStart;
470
+ if (elapsed < 2000) {
471
+ setTimeout(waitForRoot, 50);
472
+ } else {
473
+ // Try to fetch manifest directly as fallback
474
+ this._fetchAndCacheManifest().then(() => {
475
+ // Proceed anyway - Tify should still work, we'll handle navigation differently
476
+ this._onTifyFullyReady();
477
+ }).catch(() => {
478
+ // Proceed even if fetch fails
479
+ this._onTifyFullyReady();
480
+ });
481
+ }
482
+ };
483
+ waitForRoot();
484
+ }).catch(error => {
485
+ // Tify's ready promise rejects when manifest loading fails
486
+ // This is the proper way to detect manifest errors
487
+ console.error('<pb-tify> Tify ready promise rejected:', error);
488
+ this._handleManifestError(error);
489
+ });
490
+ } else {
491
+ // If ready promise not available, proceed anyway after a short delay
492
+ setTimeout(() => {
493
+ this._onTifyFullyReady();
494
+ }, 500);
495
+ }
496
+
497
+ // Also watch Tify's error store for errors that might not reject the ready promise
498
+ // This provides additional error detection
499
+ if (this._tify && this._tify.app) {
500
+ // Wait a bit for Tify to initialize, then set up error watcher
501
+ setTimeout(() => {
502
+ this._setupTifyErrorWatcher();
503
+ }, 100);
504
+ }
505
+
506
+ // Mount Tify to the container
507
+ this._tify.mount(this._container);
508
+ } catch (error) {
509
+ console.error('<pb-tify> Failed to initialize Tify:', error);
510
+ this._handleManifestError(error);
511
+ }
512
+ }
513
+
514
+ async _onTifyFullyReady() {
515
+ // Clear any previous error messages
516
+ this._clearError();
517
+
518
+ // extend tify's setPage function to allow emitting an event
519
+ const { app } = this._tify;
520
+
521
+ // Check if app exists and has the necessary structure
522
+ if (!app) {
523
+ console.error('<pb-tify> Tify app is not available');
524
+ return;
525
+ }
526
+
527
+ // Check if app.pages exists - it may not be available in all Tify versions
528
+ const hasAppPages = app.pages !== undefined && app.pages !== null;
529
+
530
+ // Track the last known canvas ID for change detection
531
+ this._lastCanvasId = null;
532
+
533
+ // Also listen for clicks on thumbnail elements specifically
534
+ if (this._tify && this._tify.viewer) {
535
+ const viewer = this._tify.viewer;
536
+
537
+ // Wait a bit for the viewer to be fully rendered
538
+ setTimeout(() => {
539
+ // Find thumbnail container - Tify typically uses classes like 'tify-thumbnails' or similar
540
+ const thumbnailContainer = viewer.$el?.querySelector('.tify-thumbnails') ||
541
+ viewer.$el?.querySelector('[class*="thumbnail"]') ||
542
+ this._container?.querySelector('.tify-thumbnails') ||
543
+ this._container?.querySelector('[class*="thumbnail"]');
67
544
 
68
- this.subscribeTo('pb-show-annotation', (ev) => {
69
- if (ev.detail) {
70
- this._initialPages = ev.detail.order ? Number(ev.detail.order) : Number.POSITIVE_INFINITY;
71
- if (this._initialPages === Number.POSITIVE_INFINITY) {
72
- this._initialPages = 1;
545
+ if (thumbnailContainer) {
546
+ // Helper function to find thumbnail index from click event
547
+ const findThumbnailIndex = (ev) => {
548
+ // First, try to extract from image URL - this is the most reliable method
549
+ if (ev.target.tagName === 'IMG' && ev.target.src) {
550
+ const src = ev.target.src;
551
+ // Try to match page number in URL (e.g., A-N-38_003.jpg -> page 3 -> index 2)
552
+ const match = src.match(/_(\d+)\.jpg/);
553
+ if (match) {
554
+ const pageNum = parseInt(match[1], 10);
555
+ if (!isNaN(pageNum) && pageNum > 0) {
556
+ // Page numbers in URLs are usually 1-indexed, convert to 0-indexed
557
+ return pageNum - 1;
558
+ }
559
+ }
560
+ }
561
+
562
+ // Fallback: find the actual list item that contains the clicked element
563
+ // Walk up from the clicked element to find the <li> parent
564
+ let element = ev.target;
565
+ let listItem = null;
566
+
567
+ while (element && element !== thumbnailContainer) {
568
+ if (element.tagName === 'LI') {
569
+ listItem = element;
570
+ break;
571
+ }
572
+ element = element.parentElement;
573
+ }
574
+
575
+ if (listItem) {
576
+ // Get all list items in the container (not the container itself)
577
+ const allListItems = Array.from(thumbnailContainer.querySelectorAll('li'));
578
+ const index = allListItems.indexOf(listItem);
579
+ if (index >= 0) {
580
+ return index;
581
+ }
582
+ }
583
+
584
+ // Last resort: try to find by data attribute on any parent
585
+ element = ev.target;
586
+ while (element && element !== thumbnailContainer) {
587
+ const dataIndex = element.getAttribute('data-index') ||
588
+ element.getAttribute('data-page') ||
589
+ element.getAttribute('data-order');
590
+ if (dataIndex !== null) {
591
+ const parsed = parseInt(dataIndex, 10);
592
+ if (!isNaN(parsed) && parsed >= 0) {
593
+ return parsed;
594
+ }
595
+ }
596
+ element = element.parentElement;
597
+ }
598
+
599
+ return -1;
600
+ };
601
+
602
+ // Listen for clicks on thumbnail items
603
+ // Use bubble phase (not capture) so Tify handles the click first
604
+ thumbnailContainer.addEventListener('click', (ev) => {
605
+ const thumbnailIndex = findThumbnailIndex(ev);
606
+
607
+ if (thumbnailIndex >= 0) {
608
+ // Get the root and canvases
609
+ const root = this._getRootFromApp();
610
+ if (root) {
611
+ const canvases = this._getCanvases(root);
612
+ if (thumbnailIndex < canvases.length) {
613
+ const newPage = thumbnailIndex + 1; // Convert to 1-indexed
614
+ const canvas = canvases[thumbnailIndex];
615
+
616
+ if (canvas) {
617
+ // Extract target page ID
618
+ const { rendering } = canvas;
619
+ let targetId = null;
620
+ if (rendering && rendering.length > 0) {
621
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
622
+ if (renderingId) {
623
+ try {
624
+ const url = new URL(renderingId);
625
+ targetId = url.searchParams.get('id');
626
+ } catch (e) {
627
+ // Ignore
628
+ }
629
+ }
630
+ }
631
+ if (!targetId && canvas.label) {
632
+ const label = canvas.label.none || canvas.label.en || canvas.label;
633
+ const labelStr = Array.isArray(label) ? label[0] : label;
634
+ if (labelStr) {
635
+ const pathParts = window.location.pathname.split('/');
636
+ let docId = null;
637
+ for (let i = pathParts.length - 1; i >= 0; i--) {
638
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
639
+ docId = pathParts[i];
640
+ break;
641
+ }
642
+ }
643
+ if (docId) {
644
+ const pageNum = String(labelStr).padStart(3, '0');
645
+ targetId = `${docId}_${pageNum}.jpg`;
646
+ }
647
+ }
648
+ }
649
+
650
+ // Set navigation state for thumbnail navigation
651
+ this._setNavigationState('thumbnail', newPage, targetId);
652
+
653
+ // Let Tify handle the click first, then wait for it to actually change pages
654
+ // BEFORE committing to registry. This prevents checkPageChange from seeing a mismatch.
655
+ let attempts = 0;
656
+ const maxAttempts = 10;
657
+ const waitForTifyThenUpdate = async () => {
658
+ attempts++;
659
+
660
+ // Try to get the current page from Tify's viewer
661
+ let tifyCurrentPage = null;
662
+ if (this._tify && this._tify.viewer) {
663
+ const viewer = this._tify.viewer;
664
+ if (typeof viewer.currentPage === 'function') {
665
+ try {
666
+ const page = viewer.currentPage();
667
+ if (typeof page === 'number') {
668
+ tifyCurrentPage = page + 1; // Convert to 1-indexed
669
+ }
670
+ } catch (e) {
671
+ // Ignore
672
+ }
673
+ } else if (viewer._sequenceIndex !== undefined) {
674
+ tifyCurrentPage = viewer._sequenceIndex + 1;
675
+ }
676
+ }
677
+
678
+ // Only proceed when Tify has actually changed to the target page
679
+ if (tifyCurrentPage === newPage) {
680
+ // Tify has changed - now it's safe to update registry
681
+ // Get the current canvas
682
+ const root = this._getRootFromApp();
683
+ if (root) {
684
+ const canvases = this._getCanvases(root);
685
+ const targetIndex = tifyCurrentPage - 1;
686
+ if (targetIndex >= 0 && targetIndex < canvases.length) {
687
+ const currentCanvas = canvases[targetIndex];
688
+ if (currentCanvas) {
689
+ // Extract the ID we're about to commit so _handleUrlChange can skip it
690
+ // This must be done BEFORE calling _updateUrlFromPage
691
+ let canvasId = null;
692
+ const { rendering } = currentCanvas;
693
+ if (rendering && rendering.length > 0) {
694
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
695
+ if (renderingId) {
696
+ try {
697
+ const url = new URL(renderingId, window.location.origin);
698
+ canvasId = url.searchParams.get('id');
699
+ } catch (e) {
700
+ // Ignore
701
+ }
702
+ }
703
+ }
704
+ if (!canvasId && currentCanvas.label) {
705
+ const label = currentCanvas.label.none || currentCanvas.label.en || currentCanvas.label;
706
+ const labelStr = Array.isArray(label) ? label[0] : label;
707
+ if (labelStr) {
708
+ const pathParts = window.location.pathname.split('/');
709
+ let docId = null;
710
+ for (let i = pathParts.length - 1; i >= 0; i--) {
711
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
712
+ docId = pathParts[i];
713
+ break;
714
+ }
715
+ }
716
+ if (docId) {
717
+ const pageNum = String(labelStr).padStart(3, '0');
718
+ canvasId = `${docId}_${pageNum}.jpg`;
719
+ }
720
+ }
721
+ }
722
+ // Update navigation state with actual canvas ID
723
+ if (canvasId) {
724
+ this._setNavigationState('thumbnail', tifyCurrentPage, canvasId);
725
+ }
726
+ // Now update registry - Tify is already on the correct page
727
+ // This prevents checkPageChange from seeing a mismatch
728
+ await this._updateUrlFromPage(currentCanvas, true);
729
+ // Also emit refresh explicitly to ensure pb-view gets notified
730
+ this._emitPbRefresh(currentCanvas);
731
+ return;
732
+ }
733
+ }
734
+ }
735
+ // Fallback: use the original canvas
736
+ await this._updateUrlFromPage(canvas, true);
737
+ this._emitPbRefresh(canvas);
738
+ } else if (attempts < maxAttempts) {
739
+ // Tify hasn't changed yet, retry after a short delay
740
+ setTimeout(waitForTifyThenUpdate, 100);
741
+ } else {
742
+ // Give up after max attempts - update anyway
743
+ console.warn('[pb-tify] thumbnail click: max attempts reached, updating registry anyway', {
744
+ tifyCurrentPage,
745
+ newPage,
746
+ attempts,
747
+ maxAttempts
748
+ });
749
+ await this._updateUrlFromPage(canvas, true);
750
+ this._emitPbRefresh(canvas);
751
+ }
752
+ };
753
+
754
+ // Start checking after a short initial delay to let Tify start changing
755
+ setTimeout(waitForTifyThenUpdate, 150);
756
+
757
+ // Re-enable page monitoring after a delay to let Tify settle
758
+ setTimeout(() => {
759
+ // Mark navigation complete for post-navigation protection
760
+ const committedId = this._navigationState?.targetId || targetId;
761
+ if (committedId) {
762
+ this._markNavigationComplete(committedId);
763
+ }
764
+
765
+ // Clear navigation state after a delay
766
+ setTimeout(() => {
767
+ this._clearNavigationState();
768
+ }, 500);
769
+ }, 2000);
770
+ }
73
771
  }
74
- const url = ev.detail.file || ev.detail.url;
75
- if (url && url !== this.manifest) {
76
- this.manifest = ev.detail.file;
77
- this._initViewer();
78
- // check if tify is already initialized
79
- } else if (this._setPage) {
80
- this._setPage(this._initialPages);
772
+ }
773
+ }
774
+ }, false); // Use bubble phase - let Tify handle click first
775
+ }
776
+
777
+ // Monitor Tify's page changes to update registry
778
+ // This catches all navigation methods (buttons, keyboard, etc.)
779
+ let lastPage = null;
780
+ let lastHash = window.location.hash;
781
+ const checkPageChange = async () => {
782
+ // Don't monitor during initial load
783
+ if (!this._initialLoadComplete) {
784
+ return;
785
+ }
786
+
787
+ if (!this._tify || !this._tify.viewer) {
788
+ return;
789
+ }
790
+
791
+ // Check if navigation is active (any source)
792
+ if (this._isNavigationActive()) {
793
+ return; // Navigation in progress, don't interfere
794
+ }
795
+
796
+ // Check if we recently committed (post-navigation protection)
797
+ if (this._hasRecentCommit()) {
798
+ return; // Recently committed, let it settle
799
+ }
800
+
801
+ let currentPage = null;
802
+ if (typeof this._tify.viewer.currentPage === 'function') {
803
+ try {
804
+ const page = this._tify.viewer.currentPage();
805
+ if (typeof page === 'number') {
806
+ currentPage = page + 1; // Convert to 1-indexed
807
+ }
808
+ } catch (e) {
809
+ // Ignore
810
+ }
811
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
812
+ const seqIndex = this._tify.viewer._sequenceIndex;
813
+ if (typeof seqIndex === 'number') {
814
+ currentPage = seqIndex + 1; // Convert to 1-indexed
815
+ }
816
+ }
817
+
818
+ // Also check hash change (Tify might update hash directly)
819
+ const currentHash = window.location.hash;
820
+ const hashChanged = currentHash !== lastHash;
821
+
822
+ // If page changed or hash changed, check if URL already matches before updating
823
+ if ((currentPage && currentPage !== lastPage) || hashChanged) {
824
+ // Check if URL already has this page
825
+ const state = registry.getState(this);
826
+ const root = this._getRootFromApp();
827
+ if (root) {
828
+ const canvases = this._getCanvases(root);
829
+ if (currentPage >= 1 && currentPage <= canvases.length) {
830
+ const canvas = canvases[currentPage - 1];
831
+ if (canvas) {
832
+ // Check if URL already matches this canvas
833
+ let urlMatches = false;
834
+ if (state.id) {
835
+ const { rendering } = canvas;
836
+ if (rendering && rendering.length > 0) {
837
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
838
+ if (renderingId) {
839
+ try {
840
+ const url = new URL(renderingId);
841
+ const canvasId = url.searchParams.get('id');
842
+ if (canvasId === state.id) {
843
+ urlMatches = true;
844
+ }
845
+ } catch (e) {
846
+ // Ignore
847
+ }
848
+ }
849
+ }
850
+ }
851
+
852
+ // Only update if URL doesn't match AND we're not navigating to this page
853
+ // Check if this is the target page we're navigating to
854
+ let isTargetPage = false;
855
+ if (this._targetPageId) {
856
+ const { rendering } = canvas;
857
+ if (rendering && rendering.length > 0) {
858
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
859
+ if (renderingId) {
860
+ try {
861
+ const url = new URL(renderingId);
862
+ const canvasId = url.searchParams.get('id');
863
+ if (canvasId === this._targetPageId) {
864
+ isTargetPage = true;
865
+ }
866
+ } catch (e) {
867
+ // Ignore
868
+ }
869
+ }
870
+ }
871
+ }
872
+
873
+ // Only update if URL doesn't match AND this isn't our target page (prevents resets)
874
+ // Also check if we just committed to a different page - if so, wait for Tify to catch up
875
+ const currentState = registry.getState(this);
876
+ const justCommitted = this._lastCommittedId && currentState.id === this._lastCommittedId;
877
+
878
+ // Also check if we're in the middle of a commit (registry might not have updated yet)
879
+ const isCommitting = this._isCommitting;
880
+
881
+ // Don't update if we have a target page ID set (we're navigating to it)
882
+ const hasTargetPage = !!this._targetPageId;
883
+
884
+ // Don't update if thumbnail navigation is in progress (even if flag hasn't been checked yet)
885
+ const thumbnailNav = this._thumbnailNavigationInProgress;
886
+
887
+ // Don't update if the current page matches what we just committed (we're navigating to it)
888
+ // Check if currentState.id matches _lastCommittedId - if so, we just committed this, don't reset
889
+ const matchesLastCommit = this._lastCommittedId &&
890
+ currentState.id === this._lastCommittedId &&
891
+ currentState.id !== null;
892
+
893
+ // Also check if we're trying to update to a page that matches what we just committed
894
+ // This prevents checkPageChange from resetting when we just committed a page change via interception
895
+ let canvasMatchesLastCommit = false;
896
+ if (this._lastCommittedId && canvas) {
897
+ const { rendering } = canvas;
898
+ if (rendering && rendering.length > 0) {
899
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
900
+ if (renderingId) {
901
+ try {
902
+ const url = new URL(renderingId, window.location.href);
903
+ const canvasId = url.searchParams.get('id');
904
+ if (canvasId === this._lastCommittedId) {
905
+ canvasMatchesLastCommit = true;
906
+ }
907
+ } catch (e) {
908
+ // Ignore
909
+ }
910
+ }
911
+ }
912
+ // Also check by generating pageId from canvas label
913
+ if (!canvasMatchesLastCommit && canvas.label) {
914
+ const label = canvas.label.none || canvas.label.en || canvas.label;
915
+ const labelStr = Array.isArray(label) ? label[0] : label;
916
+ if (labelStr) {
917
+ const pathParts = window.location.pathname.split('/');
918
+ let docId = null;
919
+ for (let i = pathParts.length - 1; i >= 0; i--) {
920
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
921
+ docId = pathParts[i];
922
+ break;
923
+ }
924
+ }
925
+ if (docId) {
926
+ const pageNum = String(labelStr).padStart(3, '0');
927
+ const pageId = `${docId}_${pageNum}.jpg`;
928
+ if (pageId === this._lastCommittedId) {
929
+ canvasMatchesLastCommit = true;
930
+ }
931
+ }
932
+ }
933
+ }
934
+ }
935
+
936
+ // CRITICAL: Don't commit if registry has a newer page than what Tify is showing
937
+ // This prevents checkPageChange from overwriting a recent commit with an older page
938
+ // Extract page numbers to compare
939
+ let registryPageNum = null;
940
+ let canvasPageNum = null;
941
+ if (currentState.id) {
942
+ const match = currentState.id.match(/_(\d{2,3})\./);
943
+ if (match) {
944
+ registryPageNum = parseInt(match[1], 10);
945
+ }
946
+ }
947
+ if (canvas && canvas.label) {
948
+ const label = canvas.label.none || canvas.label.en || canvas.label;
949
+ const labelStr = Array.isArray(label) ? label[0] : label;
950
+ if (labelStr) {
951
+ canvasPageNum = parseInt(labelStr, 10);
952
+ }
953
+ }
954
+ // If registry has a newer page (higher number), don't overwrite it with an older page
955
+ // ALSO: If _lastCommittedId exists and has a newer page, don't overwrite it
956
+ let wouldDowngrade = registryPageNum !== null && canvasPageNum !== null &&
957
+ canvasPageNum < registryPageNum;
958
+
959
+ // Additional check: if we recently committed a newer page, don't overwrite it
960
+ if (!wouldDowngrade && this._lastCommittedId) {
961
+ const lastCommitMatch = this._lastCommittedId.match(/_(\d{2,3})\./);
962
+ if (lastCommitMatch) {
963
+ const lastCommitPageNum = parseInt(lastCommitMatch[1], 10);
964
+ if (canvasPageNum !== null && canvasPageNum < lastCommitPageNum) {
965
+ wouldDowngrade = true;
966
+ }
967
+ }
968
+ }
969
+
970
+ if (!urlMatches && !isTargetPage && !justCommitted && !isCommitting && !hasTargetPage && !thumbnailNav && !matchesLastCommit && !canvasMatchesLastCommit && !wouldDowngrade) {
971
+ // Extract target page ID for Tify button clicks
972
+ const { rendering } = canvas;
973
+ let targetId = null;
974
+ if (rendering && rendering.length > 0) {
975
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
976
+ if (renderingId) {
977
+ try {
978
+ const url = new URL(renderingId);
979
+ targetId = url.searchParams.get('id');
980
+ } catch (e) {
981
+ // Ignore
982
+ }
983
+ }
984
+ }
985
+
986
+ // Set navigation state for button/keyboard navigation
987
+ this._setNavigationState('user', currentPage, targetId);
988
+
989
+ // Force update to bypass registry update check (Tify buttons are user actions)
990
+ await this._updateUrlFromPage(canvas, true);
991
+ // Also emit refresh directly to ensure transcription updates
992
+ this._emitPbRefresh(canvas);
993
+
994
+ // Mark navigation complete and clear after a delay
995
+ setTimeout(() => {
996
+ if (targetId) {
997
+ this._markNavigationComplete(targetId);
998
+ }
999
+ setTimeout(() => {
1000
+ this._clearNavigationState();
1001
+ }, 500);
1002
+ }, 300);
1003
+ }
81
1004
  }
1005
+ }
1006
+ }
1007
+ lastPage = currentPage;
1008
+ lastHash = currentHash;
1009
+ } else if (currentPage) {
1010
+ lastPage = currentPage;
1011
+ lastHash = currentHash;
1012
+ }
1013
+ };
1014
+
1015
+ // Check for page changes more frequently to catch Tify button clicks
1016
+ setInterval(checkPageChange, 200);
1017
+
1018
+ }, 500); // Wait 500ms for viewer to render
1019
+ }
82
1020
 
83
- if (ev.detail.coordinates) {
84
- this._addOverlay(ev.detail.coordinates);
1021
+ // Use tify's setPage method directly, or app.setPage if available
1022
+ // According to Tify API: setPage(pageOrPages) accepts 1-based integer or array
1023
+ // Returns: array of current pages or false if invalid
1024
+ // Note: Actual implementation throws RangeError for invalid pages
1025
+ const tifySetPage = this._tify.setPage;
1026
+ const appSetPage = app.setPage;
1027
+
1028
+ // Helper function to wrap setPage with proper error handling and synchronization
1029
+ const createSetPageWrapper = (originalSetPage, context) => {
1030
+ return async (pageOrPages) => {
1031
+ // Prevent duplicate page setting (within 200ms)
1032
+ const pagesArray = Array.isArray(pageOrPages) ? pageOrPages : [pageOrPages];
1033
+ const page = Number(pagesArray[0]);
1034
+ const now = Date.now();
1035
+
1036
+ if (this._lastSetPage === page && (now - this._lastSetPageTime) < 200) {
1037
+ return; // Skip duplicate setPage call
1038
+ }
1039
+
1040
+ // Get canvases using helper that supports both IIIF 2.0 and 3.0
1041
+ const root = this._getRootFromApp();
1042
+ const canvases = this._getCanvases(root);
1043
+
1044
+ // If canvases aren't available yet, queue the page navigation
1045
+ if (canvases.length === 0) {
1046
+ this._pendingPage = pagesArray;
1047
+ // Wait a bit and try again (max 5 seconds)
1048
+ if (!this._canvasWaitStart) {
1049
+ this._canvasWaitStart = Date.now();
1050
+ }
1051
+ const elapsed = Date.now() - this._canvasWaitStart;
1052
+ if (elapsed < 5000) {
1053
+ setTimeout(() => {
1054
+ if (this._pendingPage && this._setPage) {
1055
+ const queuedPages = this._pendingPage;
1056
+ this._pendingPage = null;
1057
+ this._setPage(queuedPages);
1058
+ }
1059
+ }, 100);
1060
+ } else {
1061
+ this._pendingPage = null;
1062
+ this._canvasWaitStart = null;
85
1063
  }
1064
+ return false; // Return false to indicate failure (matches Tify API)
1065
+ }
1066
+
1067
+ // Reset canvas wait timer if we have canvases now
1068
+ this._canvasWaitStart = null;
1069
+
1070
+ // Call original setPage - let Tify validate the page number
1071
+ // Tify will throw RangeError if invalid, or return array/false per API
1072
+ try {
1073
+ const result = originalSetPage.call(context, pageOrPages);
1074
+
1075
+ // Check return value: Tify API says it returns array or false
1076
+ if (result === false) {
1077
+ // Invalid page - Tify rejected it
1078
+ console.warn('<pb-tify> setPage returned false - invalid page:', pageOrPages);
1079
+ return false;
1080
+ }
1081
+
1082
+ // Success - result is array of current pages
1083
+ if (Array.isArray(result) && result.length > 0) {
1084
+ const actualPage = result[0]; // Get first page from result
1085
+
1086
+ // Check if page actually changed
1087
+ const pageChanged = this._currentPage !== actualPage;
1088
+ this._currentPage = actualPage;
1089
+ this._lastSetPage = actualPage;
1090
+ this._lastSetPageTime = now;
1091
+
1092
+ const canvas = canvases[actualPage - 1];
1093
+ if (canvas) {
1094
+ this._lastCanvasId = canvas.id || canvas['@id'];
1095
+
1096
+ // If not navigation is active for 'url' source, this is a user-initiated navigation
1097
+ // (Tify buttons, keyboard, thumbnails, etc.) - always update registry and emit refresh
1098
+ const isUrlNavigation = this._isNavigationActive('url');
1099
+ if (!isUrlNavigation && pageChanged) {
1100
+ // Update registry and emit refresh (force=true ensures commit)
1101
+ await this._updateUrlFromPage(canvas, true);
1102
+ }
1103
+ // If URL navigation is active, don't update registry (would cause loop)
86
1104
  }
87
- });
1105
+ }
1106
+
1107
+ return result; // Return Tify's result (array or false)
1108
+ } catch (error) {
1109
+ // Tify throws RangeError for invalid pages
1110
+ if (error instanceof RangeError) {
1111
+ console.warn('<pb-tify> setPage RangeError - invalid page:', pageOrPages, error.message);
1112
+ return false; // Return false to match API behavior
1113
+ }
1114
+ // Other errors should be logged
1115
+ console.error('<pb-tify> Error calling setPage:', error);
1116
+ return false;
1117
+ }
1118
+ };
1119
+ };
1120
+
1121
+ // Determine which setPage to use and wrap it
1122
+ if (typeof tifySetPage === 'function') {
1123
+ // Store original function
1124
+ const originalSetPage = tifySetPage.bind(this._tify);
1125
+
1126
+ // Wrap Tify's setPage
1127
+ this._tify.setPage = createSetPageWrapper(originalSetPage, this._tify);
1128
+
1129
+ this._setPage = (pages) => {
1130
+ // By the time _setPage is set up, Tify should already be ready
1131
+ // Call setPage directly - if Tify isn't ready, it will handle the error
1132
+ try {
1133
+ this._tify.setPage(pages);
1134
+ } catch (error) {
1135
+ // If Tify isn't ready yet, wait for it
1136
+ if (this._tify.ready) {
1137
+ this._tify.ready.then(() => {
1138
+ this._tify.setPage(pages);
1139
+ }).catch(() => {
1140
+ // Ignore errors
1141
+ });
1142
+ }
1143
+ }
1144
+ };
1145
+ } else if (typeof appSetPage === 'function') {
1146
+ // Store original function
1147
+ const originalSetPage = appSetPage.bind(app);
1148
+
1149
+ // Wrap app's setPage
1150
+ app.setPage = createSetPageWrapper(originalSetPage, app);
1151
+
1152
+ this._setPage = (pages) => {
1153
+ // By the time _setPage is set up, Tify should already be ready
1154
+ // Call setPage directly - if Tify isn't ready, it will handle the error
1155
+ try {
1156
+ app.setPage(pages);
1157
+ } catch (error) {
1158
+ // If Tify isn't ready yet, wait for it
1159
+ if (this._tify.ready) {
1160
+ this._tify.ready.then(() => {
1161
+ app.setPage(pages);
1162
+ }).catch(() => {
1163
+ // Ignore errors
1164
+ });
1165
+ }
1166
+ }
1167
+ };
1168
+ } else {
1169
+ // Fallback: create a simple wrapper that at least tracks the page
1170
+ this._setPage = (pages) => {
1171
+ const page = Array.isArray(pages) ? pages[0] : pages;
1172
+ this._currentPage = page;
1173
+ };
1174
+ }
1175
+
1176
+ // Set up Vue store watcher for immediate page change detection (Option 1)
1177
+ // This provides event-driven detection instead of relying solely on polling
1178
+ this._setupVueStoreWatcher(app);
1179
+
1180
+ // open initial page if set earlier via pb-load-facsimile event or from URL
1181
+ // Do this after setting up the override so _currentPage gets tracked
1182
+ // Tify expects pages as an array, and we need to ensure manifest is loaded
1183
+ const setInitialPage = () => {
1184
+ const root = this._getRootFromApp();
1185
+ if (!root) {
1186
+ setTimeout(setInitialPage, 100);
1187
+ return;
1188
+ }
1189
+
1190
+ const canvases = this._getCanvases(root);
1191
+ const canvasCount = canvases.length;
1192
+
1193
+ // Don't try to set page if there are no canvases yet
1194
+ if (canvasCount === 0) {
1195
+ // Wait a bit and try again
1196
+ setTimeout(setInitialPage, 100);
1197
+ return;
1198
+ }
1199
+
1200
+ // Read initial page from registry (like pb-view does)
1201
+ const state = registry.getState(this);
1202
+ let initialPage = null;
1203
+
1204
+ // PRIORITY 1: Check hash first (most reliable - e.g., #A-N-38_004.jpg)
1205
+ const hash = window.location.hash;
1206
+ if (hash) {
1207
+ const hashMatch = hash.match(/_(\d{2,3})\./);
1208
+ if (hashMatch) {
1209
+ const pageNum = parseInt(hashMatch[1], 10);
1210
+ if (pageNum >= 1 && pageNum <= canvasCount) {
1211
+ initialPage = pageNum;
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // PRIORITY 2: Try to find page from registry state.id
1217
+ if (!initialPage && state.id) {
1218
+ // Find canvas by id - check both rendering URL id and canvas label
1219
+ for (let i = 0; i < canvases.length; i++) {
1220
+ const canvas = canvases[i];
1221
+
1222
+ // First check rendering URL id parameter
1223
+ const { rendering } = canvas;
1224
+ if (rendering && rendering.length > 0) {
1225
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
1226
+ if (renderingId) {
1227
+ try {
1228
+ const url = new URL(renderingId);
1229
+ const canvasId = url.searchParams.get('id');
1230
+ if (canvasId === state.id) {
1231
+ initialPage = i + 1;
1232
+ break;
1233
+ }
1234
+ } catch (e) {
1235
+ // Ignore
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // Also check canvas label for id match (e.g., A-N-38_002.jpg matches A-N-38_002.jpg)
1241
+ if (canvas.label) {
1242
+ const label = canvas.label.none || canvas.label.en || canvas.label;
1243
+ const labelStr = Array.isArray(label) ? label[0] : label;
1244
+ if (labelStr) {
1245
+ // Check if state.id contains the label (e.g., "A-N-38_002.jpg" contains "002")
1246
+ // Or if label matches the id pattern
1247
+ if (state.id === labelStr || state.id.includes(labelStr) || labelStr.includes(state.id)) {
1248
+ initialPage = i + 1;
1249
+ break;
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ // If not in registry, check if set via pb-load-facsimile event
1257
+ if (!initialPage && this._initialPages) {
1258
+ const pagesArray = Array.isArray(this._initialPages) ? this._initialPages : [this._initialPages];
1259
+ initialPage = Number(pagesArray[0]);
1260
+ if (isNaN(initialPage) || initialPage < 1) {
1261
+ initialPage = 1;
1262
+ }
1263
+ }
1264
+
1265
+ // Default to page 1
1266
+ if (!initialPage) {
1267
+ initialPage = 1;
1268
+ }
1269
+
1270
+ // Ensure page is within range
1271
+ const validPage = Math.max(1, Math.min(initialPage, canvasCount));
1272
+
1273
+ // Check if we found a page from URL (hash or state.id)
1274
+ // If hash is present OR state.id is set, we should commit to normalize the URL
1275
+ // This ensures shareable URLs have id in both query params and hash
1276
+ const hasUrlPage = (hash && initialPage === validPage) || (state.id && initialPage === validPage);
1277
+ const hasHashOrId = (hash && hash.length > 1) || (state.id && state.id.length > 0);
1278
+
1279
+ // Always commit if we have a hash or state.id to normalize the URL
1280
+ // This ensures shareable URLs have id in both query params and hash
1281
+ // We commit even if initialPage doesn't match validPage, as long as we have a hash/id
1282
+ if (hasUrlPage || hasHashOrId) {
1283
+ // Loading from URL - set page and wait for Tify to actually change
1284
+ // Set navigation state for URL-based initial load
1285
+ this._setNavigationState('url', validPage, state.id);
1286
+
1287
+ // Ensure URL has all three components (id, root, hash) for shareable URLs FIRST
1288
+ // Do this BEFORE setting the page so the URL is correct immediately
1289
+ // The registry may have read id from hash, but we need to commit it back
1290
+ // to ensure id is in query params AND hash
1291
+ // Use the page we calculated, or fall back to validPage
1292
+ const targetPage = initialPage > 0 ? initialPage : validPage;
1293
+ const canvas = canvases[targetPage - 1] || canvases[validPage - 1];
1294
+ if (canvas && (state.id || hash)) {
1295
+ // Directly commit to ensure id is in query params (registry will also set hash)
1296
+ // This ensures shareable URLs have id in both query params and hash
1297
+ const idToCommit = state.id || (hash ? hash.substring(1) : null);
1298
+
1299
+ if (idToCommit) {
1300
+ // Build commit state - only include root if it exists (don't set to null)
1301
+ const commitState = { id: idToCommit };
1302
+ if (state.root) {
1303
+ commitState.root = state.root;
1304
+ }
1305
+
1306
+ // Use replace to avoid adding to history during initial load
1307
+ // Do this IMMEDIATELY, not in a setTimeout, so the URL is correct right away
1308
+ registry.replace(this, commitState);
1309
+ }
1310
+ }
1311
+
1312
+ // Now set the page after URL is committed
1313
+ this._setPage([validPage]);
1314
+ this._currentPage = validPage;
1315
+
1316
+ // Also call _updateUrlFromPage to fetch root if missing and ensure all components are present
1317
+ // This will update the URL with root if it wasn't available initially
1318
+ // Use async IIFE to properly await the async function
1319
+ (async () => {
1320
+ await this._updateUrlFromPage(canvas);
1321
+ })();
1322
+
1323
+ // Wait for Tify to actually change pages, then emit refresh
1324
+ const verifyAndEmit = (attempts = 0) => {
1325
+ // CRITICAL: Check if user has already navigated to a different page
1326
+ // If so, skip emitting pb-refresh to prevent overwriting user navigation
1327
+ const currentRegistryState = registry.getState(this);
1328
+ if (currentRegistryState.id) {
1329
+ // Try to extract page number from registry state
1330
+ const registryPageMatch = currentRegistryState.id.match(/_(\d{2,3})\./);
1331
+ if (registryPageMatch) {
1332
+ const registryPageNum = parseInt(registryPageMatch[1], 10);
1333
+ // If registry has a different page than what we're trying to set, user has navigated
1334
+ if (registryPageNum !== validPage) {
1335
+ this._clearNavigationState();
1336
+ this._initialLoadComplete = true;
1337
+ return; // Don't emit - user has already navigated
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ // Check if Tify is on the correct page
1343
+ let tifyPage = null;
1344
+ if (this._tify && this._tify.viewer) {
1345
+ if (typeof this._tify.viewer.currentPage === 'function') {
1346
+ try {
1347
+ const page = this._tify.viewer.currentPage();
1348
+ if (typeof page === 'number') {
1349
+ tifyPage = page + 1;
1350
+ }
1351
+ } catch (e) {
1352
+ // Ignore
1353
+ }
1354
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
1355
+ const seqIndex = this._tify.viewer._sequenceIndex;
1356
+ if (typeof seqIndex === 'number') {
1357
+ tifyPage = seqIndex + 1;
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ if (tifyPage === validPage) {
1363
+ // Tify is on the correct page
1364
+ this._clearNavigationState();
1365
+ this._initialLoadComplete = true;
1366
+ this._emitPbRefresh(canvases[validPage - 1]);
1367
+ } else if (attempts < 20) {
1368
+ // Not yet, retry (up to 20 attempts = 6 seconds)
1369
+ setTimeout(() => verifyAndEmit(attempts + 1), 300);
1370
+ } else {
1371
+ // Give up - but check registry again before emitting
1372
+ const finalRegistryState = registry.getState(this);
1373
+ if (finalRegistryState.id) {
1374
+ const finalRegistryPageMatch = finalRegistryState.id.match(/_(\d{2,3})\./);
1375
+ if (finalRegistryPageMatch) {
1376
+ const finalRegistryPageNum = parseInt(finalRegistryPageMatch[1], 10);
1377
+ if (finalRegistryPageNum !== validPage) {
1378
+ this._clearNavigationState();
1379
+ this._initialLoadComplete = true;
1380
+ return; // Don't emit - user has navigated
1381
+ }
1382
+ }
1383
+ }
1384
+ // Registry matches or no registry state - emit refresh
1385
+ this._clearNavigationState();
1386
+ this._initialLoadComplete = true;
1387
+ this._emitPbRefresh(canvases[validPage - 1]);
1388
+ }
1389
+ };
1390
+
1391
+ // Start verification after a short delay to let Tify process
1392
+ setTimeout(() => verifyAndEmit(), 500);
1393
+ } else {
1394
+ // Not from URL - set page and update registry
1395
+ this._setPage([validPage]);
1396
+ this._currentPage = validPage;
1397
+ // Update registry after a delay
1398
+ setTimeout(async () => {
1399
+ const canvas = canvases[validPage - 1];
1400
+ if (canvas) {
1401
+ await this._updateUrlFromPage(canvas);
1402
+ }
1403
+ this._initialLoadComplete = true;
1404
+ }, 500);
1405
+ }
1406
+ };
1407
+
1408
+ // Try to set initial page (will retry if canvases not ready)
1409
+ // If we have a URL page, use the normal setInitialPage which will load it
1410
+ // The setInitialPage function already checks for URL page first
1411
+ setInitialPage();
1412
+
88
1413
 
89
- this.signalReady();
1414
+ // Process any queued navigation - $root is now available
1415
+ if (this._pendingNavigation) {
1416
+ const queuedDirection = this._pendingNavigation;
1417
+ this._pendingNavigation = null;
1418
+ this._handleNavigate(queuedDirection);
90
1419
  }
1420
+
1421
+ // Process any queued page navigation
1422
+ if (this._pendingPage && this._setPage) {
1423
+ const queuedPages = this._pendingPage;
1424
+ this._pendingPage = null;
1425
+ this._setPage(queuedPages);
1426
+ }
1427
+ }
91
1428
 
92
- firstUpdated() {
93
- super.firstUpdated();
1429
+ /**
1430
+ * Set up Vue store watcher to detect page changes immediately
1431
+ * This provides event-driven detection for Tify's navigation (buttons, keyboard, etc.)
1432
+ * @param {Object} app - Tify's Vue app instance
1433
+ * @param {boolean} isRetry - Internal flag to prevent infinite recursion
1434
+ */
1435
+ _setupVueStoreWatcher(app, isRetry = false) {
1436
+ if (!app) {
1437
+ return;
1438
+ }
1439
+
1440
+ // Prevent duplicate setup - check if watcher is already set up
1441
+ // This prevents multiple intervals from being created if _setupVueStoreWatcher is called multiple times
1442
+ if (this._vueStoreWatcherInterval) {
1443
+ return;
1444
+ }
94
1445
 
95
- waitOnce('pb-page-ready', () => {
96
- this._initViewer();
97
- });
1446
+ // Try to access Vue store - Tify uses Vue 3 with app.config.globalProperties.$store
1447
+ // From Tify's store.js: app.config.globalProperties.$store = new Store(options)
1448
+ let store = null;
1449
+
1450
+ // Try app.config.globalProperties.$store first (Vue 3 - Tify's actual structure)
1451
+ if (app.config && app.config.globalProperties && app.config.globalProperties.$store && app.config.globalProperties.$store.options) {
1452
+ store = app.config.globalProperties.$store;
1453
+ }
1454
+ // Try app.$store (Vue 2 style or fallback)
1455
+ else if (app.$store && app.$store.options) {
1456
+ store = app.$store;
1457
+ }
1458
+ // Try app.$root.$store (fallback)
1459
+ else if (app.$root && app.$root.$store && app.$root.$store.options) {
1460
+ store = app.$root.$store;
98
1461
  }
99
1462
 
100
- _initViewer() {
101
- if (!this.manifest) {
102
- return;
1463
+ if (!store || !store.options) {
1464
+ // Store not available or doesn't have options - try to set up polling anyway
1465
+ // The store might become available later, so we'll poll for it
1466
+ console.warn('[pb-tify] Vue store not available for watcher setup, will retry with polling:', {
1467
+ hasApp: !!app,
1468
+ hasAppConfig: !!(app.config),
1469
+ hasGlobalProperties: !!(app.config && app.config.globalProperties),
1470
+ hasGlobalStore: !!(app.config && app.config.globalProperties && app.config.globalProperties.$store),
1471
+ hasAppStore: !!app.$store,
1472
+ hasAppRoot: !!app.$root,
1473
+ hasAppRootStore: !!(app.$root && app.$root.$store),
1474
+ willRetry: true
1475
+ });
1476
+
1477
+ // Set up polling that will retry to find the store
1478
+ // This handles cases where the store isn't available immediately
1479
+ // BUT: Also set up a fallback polling mechanism that works even without the store
1480
+ // by directly checking Tify's viewer state
1481
+ let retryCount = 0;
1482
+ const maxRetries = 50; // Try for 5 seconds (50 * 100ms)
1483
+ const retryPolling = setInterval(() => {
1484
+ retryCount++;
1485
+
1486
+ // Check if watcher was already set up (e.g., by another call to _setupVueStoreWatcher)
1487
+ if (this._vueStoreWatcherInterval) {
1488
+ clearInterval(retryPolling);
1489
+ return;
1490
+ }
1491
+
1492
+ // Try to find store again
1493
+ let retryStore = null;
1494
+ if (app.config && app.config.globalProperties && app.config.globalProperties.$store && app.config.globalProperties.$store.options) {
1495
+ retryStore = app.config.globalProperties.$store;
1496
+ } else if (app.$store && app.$store.options) {
1497
+ retryStore = app.$store;
1498
+ } else if (app.$root && app.$root.$store && app.$root.$store.options) {
1499
+ retryStore = app.$root.$store;
1500
+ }
1501
+
1502
+ if (retryStore && retryStore.options) {
1503
+ clearInterval(retryPolling);
1504
+ // Recursively call to set up the watcher now that store is available
1505
+ // The guard at the top of _setupVueStoreWatcher will prevent duplicate setup
1506
+ this._setupVueStoreWatcher(app, true);
1507
+ } else if (retryCount >= maxRetries) {
1508
+ console.warn('[pb-tify] Store not found after retries, setting up fallback polling:', {
1509
+ retryCount,
1510
+ maxRetries,
1511
+ willUseFallback: true
1512
+ });
1513
+ clearInterval(retryPolling);
1514
+ // Set up fallback polling that checks Tify viewer directly
1515
+ // This uses the existing checkPageChange mechanism
103
1516
  }
1517
+ }, 100);
1518
+
1519
+ return;
1520
+ }
1521
+
1522
+ // Track last known pages to detect changes
1523
+ let lastPages = null;
1524
+
1525
+ // Helper to handle page change
1526
+ // This is called when Tify's Vue store detects a page change (Tify buttons/keyboard/thumbnails)
1527
+ // This is USER-INITIATED navigation, not programmatic, so we should always update registry
1528
+ const handlePageChange = async (newPage) => {
1529
+ // Check if another navigation source is active (not 'user')
1530
+ if (this._isNavigationActive() && this._navigationState.source !== 'user') {
1531
+ return; // Another source is navigating, skip
1532
+ }
1533
+
1534
+ // Check if we recently committed (post-navigation protection)
1535
+ if (this._hasRecentCommit()) {
1536
+ return; // Recently committed, let it settle
1537
+ }
1538
+
1539
+ // Get canvases
1540
+ const root = this._getRootFromApp();
1541
+ if (!root) {
1542
+ console.warn('[pb-tify] handlePageChange: no root found for page', newPage);
1543
+ return;
1544
+ }
1545
+
1546
+ const canvases = this._getCanvases(root);
1547
+ if (newPage < 1 || newPage > canvases.length) {
1548
+ console.warn('[pb-tify] handlePageChange: invalid page', {
1549
+ newPage,
1550
+ totalCanvases: canvases.length,
1551
+ validRange: `1-${canvases.length}`
1552
+ });
1553
+ return;
1554
+ }
104
1555
 
105
- if (this._tify) {
106
- this._tify.destroy();
1556
+ // Always start with canvas from TEI Publisher manifest - it should have rendering property
1557
+ // The TEI Publisher manifest is the source of truth for rendering URLs with root/id parameters
1558
+ // This is the same manifest that _handleNavigate uses, which is why pb-navigation works!
1559
+ const canvasIndex = newPage - 1;
1560
+ let canvas = canvases[canvasIndex];
1561
+
1562
+ // If the canvas from the TEI Publisher manifest doesn't have rendering,
1563
+ // it means we're using the wrong manifest (external one). Try to get the TEI Publisher manifest.
1564
+ if (!canvas || !canvas.rendering || canvas.rendering.length === 0) {
1565
+ // The canvases array might be from the external manifest (without rendering)
1566
+ // Try to get the TEI Publisher manifest instead
1567
+ const teiPublisherRoot = this._getRootFromApp();
1568
+ if (teiPublisherRoot) {
1569
+ const teiPublisherCanvases = this._getCanvases(teiPublisherRoot);
1570
+ if (teiPublisherCanvases.length > 0 && newPage - 1 < teiPublisherCanvases.length) {
1571
+ const teiCanvas = teiPublisherCanvases[newPage - 1];
1572
+ // Check if this canvas has rendering (it should!)
1573
+ if (teiCanvas && teiCanvas.rendering && teiCanvas.rendering.length > 0) {
1574
+ canvas = teiCanvas;
1575
+ } else {
1576
+ // Still no rendering - try to match by label
1577
+ const label = canvas?.label?.none || canvas?.label?.en || canvas?.label;
1578
+ const labelStr = Array.isArray(label) ? label[0] : label;
1579
+ if (labelStr) {
1580
+ for (const teiCanvas of teiPublisherCanvases) {
1581
+ const teiLabel = teiCanvas.label?.none || teiCanvas.label?.en || teiCanvas.label;
1582
+ const teiLabelStr = Array.isArray(teiLabel) ? teiLabel[0] : teiLabel;
1583
+ if (teiLabelStr === labelStr && teiCanvas.rendering && teiCanvas.rendering.length > 0) {
1584
+ canvas = teiCanvas;
1585
+ break;
1586
+ }
1587
+ }
1588
+ }
1589
+ }
1590
+ }
107
1591
  }
1592
+ }
1593
+
1594
+ if (!canvas) {
1595
+ return;
1596
+ }
108
1597
 
109
- this._tify = new Tify({
110
- manifestUrl: this.toAbsoluteURL(this.manifest, this.getEndpoint())
1598
+ // Always update registry when page changes, even if URL appears to match
1599
+ // This ensures the root is updated correctly (root can change even if id stays the same)
1600
+ // _updateUrlFromPage will check if commit is actually needed
1601
+ const renderingId = canvas?.rendering?.[0]?.['@id'] || canvas?.rendering?.[0]?.id;
1602
+ let canvasId = null;
1603
+ if (renderingId) {
1604
+ try {
1605
+ const url = new URL(renderingId, window.location.href);
1606
+ canvasId = url.searchParams.get('id');
1607
+ } catch (e) {
1608
+ // Ignore
1609
+ }
1610
+ }
1611
+
1612
+ // Verify canvas label matches expected page
1613
+ const canvasLabel = canvas?.label?.none || canvas?.label?.en || canvas?.label;
1614
+ const canvasLabelStr = Array.isArray(canvasLabel) ? canvasLabel[0] : canvasLabel;
1615
+ const expectedPageNum = String(newPage).padStart(3, '0');
1616
+ const canvasPageNum = canvasLabelStr ? String(canvasLabelStr).padStart(3, '0') : null;
1617
+
1618
+ // If canvas label doesn't match expected page, wait a bit and retry
1619
+ // This handles cases where Vue reactivity hasn't updated the canvas array yet
1620
+ if (canvasPageNum && canvasPageNum !== expectedPageNum) {
1621
+ console.warn('[pb-tify] handlePageChange: canvas label mismatch, waiting for Vue update', {
1622
+ expectedPageNum,
1623
+ canvasPageNum,
1624
+ newPage
111
1625
  });
112
- this._tify.ready.then(() => {
113
- // open initial page if set earlier via pb-load-facsimile event
114
- if (this._initialPages) {
115
- this._tify.setPage(this._initialPages);
1626
+ setTimeout(() => {
1627
+ // Retry with a fresh lookup
1628
+ const retryRoot = this._getRootFromApp();
1629
+ if (retryRoot) {
1630
+ const retryCanvases = this._getCanvases(retryRoot);
1631
+ if (newPage >= 1 && newPage <= retryCanvases.length) {
1632
+ const retryCanvas = retryCanvases[newPage - 1];
1633
+ if (retryCanvas) {
1634
+ this._updateUrlFromPage(retryCanvas, true);
1635
+ this._emitPbRefresh(retryCanvas);
1636
+ }
1637
+ }
116
1638
  }
1639
+ }, 50); // Wait 50ms for Vue to update
1640
+ return; // Exit early, retry will handle it
1641
+ }
1642
+
1643
+ // CRITICAL: Set navigation state and flags BEFORE calling _updateUrlFromPage
1644
+ // This prevents _handleUrlChange from resetting Tify when the URL changes
1645
+ // Extract the ID we're about to commit so _handleUrlChange can skip it
1646
+
1647
+ // Extract document ID from URL path (same logic as _updateUrlFromPage)
1648
+ let docId = null;
1649
+ const pathParts = window.location.pathname.split('/');
1650
+ for (let i = pathParts.length - 1; i >= 0; i--) {
1651
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
1652
+ docId = pathParts[i];
1653
+ break;
1654
+ }
1655
+ }
1656
+
1657
+ let pageId = null;
1658
+ if (canvasId) {
1659
+ pageId = canvasId;
1660
+ } else if (canvasLabelStr && docId) {
1661
+ // Generate pageId from canvas label (same logic as _updateUrlFromPage)
1662
+ const pageNum = String(canvasLabelStr).padStart(3, '0');
1663
+ pageId = `${docId}_${pageNum}.jpg`;
1664
+ }
1665
+
1666
+ // Set navigation state for user-initiated navigation
1667
+ if (pageId && !this._isNavigationActive()) {
1668
+ this._setNavigationState('user', newPage, pageId);
1669
+ }
117
1670
 
118
- // extend tify's setPage function to allow emitting an event
119
- const {app} = this._tify;
120
- const originalSetPage = app.setPage;
1671
+ // Update registry and emit refresh
1672
+ await this._updateUrlFromPage(canvas, true);
1673
+
1674
+ // Always emit pb-refresh to ensure pb-view gets notified
1675
+ this._emitPbRefresh(canvas);
121
1676
 
122
- app.setPage = (pages) => {
123
- const page = Array.isArray(pages) ? pages[0] : pages;
124
- if(this._currentPage === page) {
125
- return;
1677
+ // Mark navigation complete for post-navigation protection
1678
+ setTimeout(() => {
1679
+ if (pageId) {
1680
+ this._markNavigationComplete(pageId);
1681
+ }
1682
+
1683
+ // Clear navigation state after a delay
1684
+ setTimeout(() => {
1685
+ this._clearNavigationState();
1686
+ }, 1500);
1687
+ }, 500);
1688
+ };
1689
+
1690
+ // Watch for changes in store.options.pages
1691
+ // BEST APPROACH: Intercept both store.setPage and store.updateOptions to catch page changes immediately
1692
+ // This is more reliable than polling, especially in test environments
1693
+
1694
+ // Validate that store methods exist before intercepting
1695
+ if (typeof store.updateOptions !== 'function') {
1696
+ console.error('[pb-tify] store.updateOptions is not a function, cannot intercept', {
1697
+ storeType: typeof store,
1698
+ storeKeys: Object.keys(store || {}),
1699
+ hasUpdateOptions: 'updateOptions' in store
1700
+ });
1701
+ return; // Can't intercept if methods don't exist
1702
+ }
1703
+ if (typeof store.setPage !== 'function') {
1704
+ console.error('[pb-tify] store.setPage is not a function, cannot intercept', {
1705
+ storeType: typeof store,
1706
+ storeKeys: Object.keys(store || {}),
1707
+ hasSetPage: 'setPage' in store
1708
+ });
1709
+ return; // Can't intercept if methods don't exist
1710
+ }
1711
+
1712
+ // Store original methods
1713
+ const originalUpdateOptions = store.updateOptions;
1714
+ const originalSetPage = store.setPage;
1715
+ let lastWatchedPages = store.options.pages ? [...store.options.pages] : null;
1716
+
1717
+ // Verify we can actually replace the methods (they might be read-only or bound)
1718
+
1719
+ // Wrap store.setPage to intercept page changes (called by goToNextPage, goToPreviousPage, etc.)
1720
+ store.setPage = (pageOrPages) => {
1721
+ // Capture old pages BEFORE calling original method
1722
+ const oldPages = store.options.pages ? [...store.options.pages] : null;
1723
+ const oldPage = oldPages && Array.isArray(oldPages)
1724
+ ? oldPages.find(page => page > 0)
1725
+ : null;
1726
+
1727
+ // Call original method (this calls updateOptions internally)
1728
+ const result = originalSetPage.call(store, pageOrPages);
1729
+
1730
+ // The actual page change will be caught by updateOptions interception below
1731
+ // But we can also check here if needed
1732
+ return result;
1733
+ };
1734
+
1735
+ // Wrap store.updateOptions to intercept page changes
1736
+ store.updateOptions = (updatedOptions) => {
1737
+ // Capture old pages BEFORE calling original method (which updates the store)
1738
+ const oldPages = store.options.pages ? [...store.options.pages] : null;
1739
+ const oldPage = oldPages && Array.isArray(oldPages)
1740
+ ? oldPages.find(page => page > 0)
1741
+ : null;
1742
+
1743
+ // Call original method (this updates the store)
1744
+ const result = originalUpdateOptions.call(store, updatedOptions);
1745
+
1746
+ // Check if pages changed - either from updatedOptions OR from store.options.pages after update
1747
+ // Tify might update pages directly on the reactive store, or call updateOptions multiple times
1748
+ // Use a small delay to ensure Vue reactivity has updated store.options.pages
1749
+ // This is especially important for the first click in test environments
1750
+ setTimeout(() => {
1751
+ const newPagesFromOptions = updatedOptions && updatedOptions.pages ? updatedOptions.pages : null;
1752
+ const newPagesFromStore = store.options.pages ? [...store.options.pages] : null;
1753
+ const newPages = newPagesFromOptions || newPagesFromStore;
1754
+
1755
+ if (newPages) {
1756
+ const newPage = Array.isArray(newPages) ? newPages.find(page => page > 0) : null;
1757
+ const lastKnownPage = lastWatchedPages && Array.isArray(lastWatchedPages)
1758
+ ? lastWatchedPages.find(page => page > 0)
1759
+ : null;
1760
+
1761
+ // Only handle if page actually changed from the last known state
1762
+ // Use lastWatchedPages (tracked across all calls) rather than oldPage (just this call)
1763
+ if (newPage && newPage !== lastKnownPage && newPage > 0) {
1764
+ // Update last watched pages immediately
1765
+ lastWatchedPages = Array.isArray(newPages) ? [...newPages] : null;
1766
+
1767
+ // CRITICAL: Set navigation state to prevent checkPageChange from interfering while we wait for Tify
1768
+ // This prevents checkPageChange from committing page 1 while we're waiting for page 2
1769
+ // Use navigation state instead of _isCommitting to avoid conflicts with handlePageChange
1770
+ const root = this._getRootFromApp();
1771
+ const canvases = this._getCanvases(root);
1772
+ if (canvases && canvases[newPage - 1]) {
1773
+ const canvas = canvases[newPage - 1];
1774
+ const canvasLabel = canvas.label?.none || canvas.label?.en || canvas.label;
1775
+ const labelStr = Array.isArray(canvasLabel) ? canvasLabel[0] : canvasLabel;
1776
+ if (labelStr) {
1777
+ const pathParts = window.location.pathname.split('/');
1778
+ let docId = null;
1779
+ for (let i = pathParts.length - 1; i >= 0; i--) {
1780
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
1781
+ docId = pathParts[i];
1782
+ break;
1783
+ }
1784
+ }
1785
+ if (docId) {
1786
+ const pageNum = String(labelStr).padStart(3, '0');
1787
+ const pageId = `${docId}_${pageNum}.jpg`;
1788
+ this._setNavigationState('user', newPage, pageId);
1789
+ }
1790
+ }
1791
+ }
1792
+
1793
+ // Wait for Tify's viewer to actually update to the new page before calling handlePageChange
1794
+ // This ensures the canvas lookup gets the correct canvas
1795
+ const waitForTifyUpdate = (attempts = 0) => {
1796
+ const maxAttempts = 20; // Try for up to 200ms (20 * 10ms)
1797
+
1798
+ if (!this._tify || !this._tify.viewer) {
1799
+ // Viewer not ready yet, wait a bit more
1800
+ if (attempts < maxAttempts) {
1801
+ setTimeout(() => waitForTifyUpdate(attempts + 1), 10);
1802
+ } else {
1803
+ // Give up and call handlePageChange anyway
1804
+ console.warn('[pb-tify] Viewer not ready after waiting, calling handlePageChange anyway');
1805
+ // handlePageChange will manage navigation state and _isCommitting
1806
+ handlePageChange(newPage);
1807
+ }
1808
+ return;
1809
+ }
1810
+
1811
+ // Check Tify's internal page state
1812
+ let tifyCurrentPage = null;
1813
+ if (typeof this._tify.viewer.currentPage === 'function') {
1814
+ try {
1815
+ const page = this._tify.viewer.currentPage();
1816
+ if (typeof page === 'number') {
1817
+ tifyCurrentPage = page + 1; // Convert to 1-indexed
126
1818
  }
1819
+ } catch (e) {
1820
+ // Ignore
1821
+ }
1822
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
1823
+ tifyCurrentPage = this._tify.viewer._sequenceIndex + 1;
1824
+ }
1825
+
1826
+ if (tifyCurrentPage === newPage) {
1827
+ // Tify has updated to the correct page, safe to call handlePageChange
1828
+ // handlePageChange will manage navigation state and _isCommitting
1829
+ handlePageChange(newPage);
1830
+ } else if (attempts < maxAttempts) {
1831
+ // Tify hasn't updated yet, wait a bit more
1832
+ setTimeout(() => waitForTifyUpdate(attempts + 1), 10);
1833
+ } else {
1834
+ // Give up after max attempts - call handlePageChange anyway
1835
+ // handlePageChange will manage navigation state and _isCommitting
1836
+ handlePageChange(newPage);
1837
+ }
1838
+ };
1839
+
1840
+ // Start waiting for Tify to update
1841
+ waitForTifyUpdate();
1842
+ } else {
1843
+ // Update last watched pages even if page didn't change (for consistency)
1844
+ lastWatchedPages = Array.isArray(newPages) ? [...newPages] : null;
1845
+ }
1846
+ }
1847
+ }, 0); // Use setTimeout with 0ms delay to ensure Vue reactivity has updated store.options.pages
1848
+
1849
+ return result;
1850
+ };
1851
+
1852
+ // FALLBACK: Also use polling as backup (in case updateOptions isn't called directly)
1853
+ // This is more reliable than trying to set up a Vue watcher from outside the component tree
1854
+
1855
+ // Poll the reactive store directly (more reliable than trying to set up Vue watcher)
1856
+ const watchStorePages = () => {
1857
+ if (!store || !store.options || !store.options.pages) {
1858
+ // Log when store is not accessible (but throttle to avoid spam)
1859
+ if (Date.now() % 1000 < 100) { // Log roughly once per second when store is inaccessible
1860
+ console.warn('[pb-tify] Store not accessible in polling:', {
1861
+ hasStore: !!store,
1862
+ hasOptions: !!(store && store.options),
1863
+ hasPages: !!(store && store.options && store.options.pages),
1864
+ timestamp: Date.now()
1865
+ });
1866
+ }
1867
+ return;
1868
+ }
127
1869
 
128
- const canvas = app.$root.canvases[page - 1];
129
-
130
- this._switchPage(canvas);
131
- originalSetPage(pages);
132
- this._currentPage = page;
133
- };
1870
+ const currentPages = store.options.pages;
1871
+ const currentPage = Array.isArray(currentPages) ? currentPages.find(page => page > 0) : null;
1872
+ const lastPage = lastWatchedPages && Array.isArray(lastWatchedPages)
1873
+ ? lastWatchedPages.find(page => page > 0)
1874
+ : null;
134
1875
 
135
- this._setPage = app.setPage;
136
- });
1876
+ if (currentPage && currentPage !== lastPage) {
1877
+ handlePageChange(currentPage);
1878
+ lastWatchedPages = currentPages ? [...currentPages] : null;
1879
+ } else if (currentPages && !lastWatchedPages) {
1880
+ lastWatchedPages = [...currentPages];
1881
+ } else if (currentPages) {
1882
+ // Pages haven't changed, but store is accessible - polling is working
1883
+ // Don't log this - it's too verbose and creates console spam
1884
+ // The polling is working correctly, no need to log every 5 seconds
1885
+ lastWatchedPages = [...currentPages];
1886
+ } else {
1887
+ // Log if store becomes inaccessible
1888
+ if (lastWatchedPages) {
1889
+ console.warn('[pb-tify] Store pages became inaccessible (polling):', {
1890
+ hadPages: !!lastWatchedPages,
1891
+ storeAccessible: !!store,
1892
+ hasOptions: !!(store && store.options),
1893
+ hasPages: !!(store && store.options && store.options.pages)
1894
+ });
1895
+ lastWatchedPages = null;
1896
+ }
1897
+ }
1898
+ };
1899
+
1900
+ // Poll the reactive store as a fallback (in case interception misses something)
1901
+ // Use a reasonable interval - 200ms is fast enough for good UX without excessive CPU usage
1902
+ // The interception of store.updateOptions should catch most changes immediately
1903
+ this._vueStoreWatcherInterval = setInterval(watchStorePages, 200);
1904
+
1905
+ // Also try app.$watch as a backup (might work in some Vue setups)
1906
+ if (typeof app.$watch === 'function') {
1907
+ try {
1908
+ this._vueStoreWatcher = app.$watch(
1909
+ () => store.options.pages,
1910
+ (newPages, oldPages) => {
1911
+
1912
+ // Only handle if pages actually changed
1913
+ if (!oldPages || !newPages || !Array.isArray(newPages) || newPages.length === 0) {
1914
+ return;
1915
+ }
1916
+
1917
+ // Get first valid page (page > 0)
1918
+ const newPage = newPages.find(page => page > 0);
1919
+ const oldPage = oldPages && oldPages.length > 0 ? oldPages.find(page => page > 0) : null;
1920
+
1921
+ // Check if page actually changed
1922
+ if (newPage && newPage !== oldPage && newPage > 0) {
1923
+ handlePageChange(newPage);
1924
+ }
1925
+ },
1926
+ { deep: true, immediate: false }
1927
+ );
1928
+ } catch (e) {
1929
+ console.warn('[pb-tify] app.$watch failed, using polling only:', e);
1930
+ }
1931
+ }
1932
+ // Fallback: Use Vue 3 watch API directly if available
1933
+ // Try to import watch from Vue if we can access it
1934
+ else if (typeof window !== 'undefined' && window.Vue && window.Vue.watch) {
1935
+ // Vue 3 watch function available globally
1936
+ this._vueStoreWatcher = window.Vue.watch(
1937
+ () => store.options.pages,
1938
+ (newPages, oldPages) => {
1939
+
1940
+ if (!oldPages || !newPages || !Array.isArray(newPages) || newPages.length === 0) {
1941
+ return;
1942
+ }
1943
+
1944
+ const newPage = newPages.find(page => page > 0);
1945
+ const oldPage = oldPages && oldPages.length > 0 ? oldPages.find(page => page > 0) : null;
1946
+
1947
+ if (newPage && newPage !== oldPage && newPage > 0) {
1948
+ handlePageChange(newPage);
1949
+ }
1950
+ },
1951
+ { deep: true, immediate: false }
1952
+ );
1953
+ }
1954
+ // If neither works, fall back to polling (already set up in checkPageChange)
1955
+ else {
1956
+ console.warn('[pb-tify] Vue store watcher could not be set up - falling back to polling');
1957
+ }
1958
+ }
1959
+
1960
+ _handleManifestError(error) {
1961
+ // Clear any existing error message
1962
+ this._clearError();
137
1963
 
138
- this._tify.mount(this._container);
1964
+ // Create error message element
1965
+ const errorDiv = document.createElement('div');
1966
+ errorDiv.className = 'pb-tify-error';
1967
+ errorDiv.style.cssText = `
1968
+ display: flex;
1969
+ align-items: center;
1970
+ justify-content: center;
1971
+ height: 100%;
1972
+ width: 100%;
1973
+ background-color: #f8f9fa;
1974
+ border: 1px solid #dee2e6;
1975
+ border-radius: 4px;
1976
+ color: #6c757d;
1977
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1978
+ font-size: 14px;
1979
+ text-align: center;
1980
+ padding: 20px;
1981
+ `;
1982
+
1983
+ // Determine error message based on error type
1984
+ let errorMessage = 'Failed to load IIIF manifest';
1985
+
1986
+ // Check error message, status, and other properties
1987
+ const errorText = error.message || error.toString() || '';
1988
+ const status = error.status || error.statusCode;
1989
+
1990
+ if (status === 404 || errorText.includes('404') || errorText.includes('Not Found')) {
1991
+ errorMessage = 'IIIF manifest not found';
1992
+ } else if (status === 403 || errorText.includes('403') || errorText.includes('Forbidden')) {
1993
+ errorMessage = 'Access denied to IIIF manifest';
1994
+ } else if (
1995
+ errorText.includes('NetworkError') ||
1996
+ errorText.includes('Failed to fetch') ||
1997
+ errorText.includes('network')
1998
+ ) {
1999
+ errorMessage = 'Network error loading IIIF manifest';
2000
+ } else if (
2001
+ errorText.includes('Invalid JSON') ||
2002
+ errorText.includes('SyntaxError') ||
2003
+ errorText.includes('parse') ||
2004
+ errorText.includes('Unexpected token') ||
2005
+ errorText.includes('JSON') ||
2006
+ errorText.includes('$meta') ||
2007
+ errorText.includes('manifest')
2008
+ ) {
2009
+ errorMessage = 'Invalid IIIF manifest format';
2010
+ }
2011
+
2012
+ errorDiv.textContent = errorMessage;
2013
+
2014
+ // Add error element to container
2015
+ if (this._container) {
2016
+ this._container.appendChild(errorDiv);
2017
+ }
2018
+
2019
+ // Emit error event for parent components to handle
2020
+ this.emitTo('pb-tify-error', {
2021
+ error: error.message || 'Unknown error',
2022
+ manifest: this.manifest,
2023
+ });
2024
+ }
2025
+
2026
+ _clearError() {
2027
+ if (this._container) {
2028
+ const existingError = this._container.querySelector('.pb-tify-error');
2029
+ if (existingError) {
2030
+ existingError.remove();
2031
+ }
2032
+ }
2033
+ }
2034
+
2035
+ /**
2036
+ * Set up watcher for Tify's error store
2037
+ * This allows us to detect errors that Tify handles internally
2038
+ * and display them using our error handling system
2039
+ */
2040
+ _setupTifyErrorWatcher() {
2041
+ if (!this._tify || !this._tify.app) {
2042
+ return;
2043
+ }
2044
+
2045
+ // Try to access Tify's store
2046
+ const store = this._tify.app.config?.globalProperties?.$store ||
2047
+ this._tify.app.$store ||
2048
+ (this._tify.app.$root && this._tify.app.$root.$store);
2049
+
2050
+ if (!store || !store.errors) {
2051
+ return;
2052
+ }
2053
+
2054
+ // Watch for errors in Tify's store
2055
+ // Use polling since we can't easily set up Vue watchers from outside
2056
+ let lastErrorCount = store.errors.size;
2057
+ this._tifyErrorWatcherInterval = setInterval(() => {
2058
+ if (!this._tify || !store || !store.errors) {
2059
+ clearInterval(this._tifyErrorWatcherInterval);
2060
+ this._tifyErrorWatcherInterval = null;
2061
+ return;
2062
+ }
2063
+
2064
+ const currentErrorCount = store.errors.size;
2065
+ if (currentErrorCount > lastErrorCount) {
2066
+ // New errors were added
2067
+ const newErrors = Array.from(store.errors).slice(lastErrorCount);
2068
+ for (const errorMessage of newErrors) {
2069
+ // Create error object from Tify's error message
2070
+ const error = new Error(errorMessage);
2071
+ // Try to extract status code from error message
2072
+ if (errorMessage.includes('404') || errorMessage.includes('Not Found')) {
2073
+ error.status = 404;
2074
+ } else if (errorMessage.includes('403') || errorMessage.includes('Forbidden')) {
2075
+ error.status = 403;
2076
+ }
2077
+ this._handleManifestError(error);
2078
+ }
2079
+ lastErrorCount = currentErrorCount;
2080
+ } else if (currentErrorCount < lastErrorCount) {
2081
+ // Errors were cleared
2082
+ lastErrorCount = currentErrorCount;
2083
+ this._clearError();
2084
+ }
2085
+ }, 200); // Check every 200ms
2086
+ }
2087
+
2088
+
2089
+ /**
2090
+ * Navigation State Management
2091
+ * ==========================
2092
+ *
2093
+ * pb-tify uses a single _navigationState object to track all navigation activities.
2094
+ * This replaces the previous system of 7 separate boolean flags.
2095
+ *
2096
+ * _navigationState object structure:
2097
+ * {
2098
+ * source: 'user' | 'thumbnail' | 'programmatic' | 'url',
2099
+ * targetPage: number,
2100
+ * targetId: string,
2101
+ * timestamp: number,
2102
+ * isActive: boolean,
2103
+ * recentlyCommittedId: string | null, // ID of recently committed page
2104
+ * completedAt: number | null // When navigation completed
2105
+ * }
2106
+ */
2107
+ _setNavigationState(source, targetPage, targetId) {
2108
+ this._navigationState = {
2109
+ source, // 'user', 'thumbnail', 'programmatic', 'url'
2110
+ targetPage,
2111
+ targetId,
2112
+ timestamp: Date.now(),
2113
+ isActive: true,
2114
+ recentlyCommittedId: null,
2115
+ completedAt: null
2116
+ };
2117
+ }
2118
+
2119
+ /**
2120
+ * Mark navigation as complete, storing the committed ID for post-navigation protection
2121
+ * @param {string} committedId - The ID that was committed
2122
+ */
2123
+ _markNavigationComplete(committedId) {
2124
+ if (this._navigationState) {
2125
+ this._navigationState.isActive = false;
2126
+ this._navigationState.recentlyCommittedId = committedId;
2127
+ this._navigationState.completedAt = Date.now();
139
2128
  }
2129
+ }
140
2130
 
141
- _switchPage(canvas) {
142
- const rendering = canvas.rendering;
2131
+ _clearNavigationState() {
2132
+ this._navigationState = null;
2133
+ }
2134
+
2135
+ _isNavigationActive(source = null) {
2136
+ if (!this._navigationState || !this._navigationState.isActive) {
2137
+ return false;
2138
+ }
2139
+ // If source is specified, only return true if that specific source is active
2140
+ if (source) {
2141
+ return this._navigationState.source === source;
2142
+ }
2143
+ // If no source specified, return true if any navigation is active
2144
+ return true;
2145
+ }
2146
+
2147
+ _matchesNavigationTarget(id) {
2148
+ if (!this._navigationState || !this._navigationState.isActive) {
2149
+ return false;
2150
+ }
2151
+ return this._navigationState.targetId === id;
2152
+ }
2153
+
2154
+ /**
2155
+ * Check if an ID was recently committed (within maxAge ms)
2156
+ * Used for post-navigation protection to prevent bounce-back
2157
+ * @param {string} id - The ID to check
2158
+ * @param {number} maxAge - Maximum age in ms (default 3000ms)
2159
+ * @returns {boolean}
2160
+ */
2161
+ _isRecentlyCommitted(id, maxAge = 3000) {
2162
+ if (!this._navigationState || !this._navigationState.recentlyCommittedId) {
2163
+ return false;
2164
+ }
2165
+ if (this._navigationState.recentlyCommittedId !== id) {
2166
+ return false;
2167
+ }
2168
+ if (!this._navigationState.completedAt) {
2169
+ return false;
2170
+ }
2171
+ return Date.now() - this._navigationState.completedAt < maxAge;
2172
+ }
2173
+
2174
+ /**
2175
+ * Check if we recently committed any page (within maxAge ms)
2176
+ * @param {number} maxAge - Maximum age in ms (default 3000ms)
2177
+ * @returns {boolean}
2178
+ */
2179
+ _hasRecentCommit(maxAge = 3000) {
2180
+ if (!this._navigationState || !this._navigationState.recentlyCommittedId) {
2181
+ return false;
2182
+ }
2183
+ if (!this._navigationState.completedAt) {
2184
+ return false;
2185
+ }
2186
+ return Date.now() - this._navigationState.completedAt < maxAge;
2187
+ }
2188
+
2189
+ /**
2190
+ * Get the recently committed ID (if any)
2191
+ * @returns {string|null}
2192
+ */
2193
+ _getRecentlyCommittedId() {
2194
+ if (!this._navigationState) {
2195
+ return null;
2196
+ }
2197
+ return this._navigationState.recentlyCommittedId;
2198
+ }
2199
+
2200
+ /**
2201
+ * Update URL from current canvas/page.
2202
+ * Extracts root/id from canvas rendering URL and commits to registry.
2203
+ * Also updates hash fragment based on canvas label (e.g., #A-N-38_005.jpg).
2204
+ * @param {Object} canvas - The canvas object
2205
+ * @param {boolean} force - Force update even if updating from registry
2206
+ * @returns {Promise<void>} - Resolves when URL update is complete
2207
+ */
2208
+ async _updateUrlFromPage(canvas, force = false) {
2209
+
2210
+ // CRITICAL: If we just committed a page change and we're trying to commit a different page, skip to prevent bounce-back
2211
+ // This prevents checkPageChange or _handleUrlChange from overwriting what we just committed
2212
+ // Check both _lastCommittedId AND current registry state to prevent overwriting recent commits
2213
+ const registryState = registry.getState(this);
2214
+
2215
+ // Generate the pageId for this canvas to compare
2216
+ let canvasPageId = null;
2217
+ const canvasLabel = canvas?.label?.none || canvas?.label?.en || canvas?.label;
2218
+ const canvasLabelStr = Array.isArray(canvasLabel) ? canvasLabel[0] : canvasLabel;
2219
+ if (canvasLabelStr) {
2220
+ const pathParts = window.location.pathname.split('/');
2221
+ let docId = null;
2222
+ for (let i = pathParts.length - 1; i >= 0; i--) {
2223
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
2224
+ docId = pathParts[i];
2225
+ break;
2226
+ }
2227
+ }
2228
+ if (docId) {
2229
+ const pageNum = String(canvasLabelStr).padStart(3, '0');
2230
+ canvasPageId = `${docId}_${pageNum}.jpg`;
2231
+ }
2232
+ }
2233
+ // Also try to extract from rendering URL
2234
+ if (!canvasPageId && canvas?.rendering && canvas.rendering.length > 0) {
2235
+ const renderingId = canvas.rendering[0]['@id'] || canvas.rendering[0].id;
2236
+ if (renderingId) {
2237
+ try {
2238
+ const url = new URL(renderingId, window.location.href);
2239
+ canvasPageId = url.searchParams.get('id');
2240
+ } catch (e) {
2241
+ // Ignore
2242
+ }
2243
+ }
2244
+ }
2245
+
2246
+ // CRITICAL: Prevent initial load handlers from overwriting user navigation
2247
+ // If initial load is not complete AND registry already has a different page, check if we should skip
2248
+ if (!this._initialLoadComplete && registryState.id) {
2249
+ // Try to extract page numbers from both sources
2250
+ const registryPageNum = registryState.id.match(/_(\d{2,3})\./);
2251
+ let canvasPageNum = null;
2252
+
2253
+ // Try to get page number from canvasPageId first
2254
+ if (canvasPageId) {
2255
+ const match = canvasPageId.match(/_(\d{2,3})\./);
2256
+ if (match) canvasPageNum = match;
2257
+ }
2258
+
2259
+ // If canvasPageId extraction failed, try to extract from canvas label directly
2260
+ if (!canvasPageNum && canvasLabelStr) {
2261
+ const labelMatch = String(canvasLabelStr).match(/(\d{2,3})/);
2262
+ if (labelMatch) {
2263
+ canvasPageNum = [null, labelMatch[1]]; // Match format: [fullMatch, pageNum]
2264
+ }
2265
+ }
2266
+
2267
+ // If we have both page numbers and registry has a newer page, skip and mark as complete
2268
+ if (registryPageNum && canvasPageNum &&
2269
+ parseInt(canvasPageNum[1], 10) < parseInt(registryPageNum[1], 10)) {
2270
+ this._initialLoadComplete = true;
2271
+ return; // Skip entirely - don't even update hash
2272
+ }
2273
+ }
2274
+
2275
+ // Simplified state check: if another source is navigating to a different target, skip
2276
+ // BUT: If force=true (from handlePageChange), always proceed - it's an intentional navigation
2277
+ if (!force && this._navigationState && this._navigationState.isActive && canvasPageId) {
2278
+ // If navigation is active and this doesn't match the target, skip
2279
+ // Exception: if source is 'user', allow it (user navigation should proceed)
2280
+ if (!this._matchesNavigationTarget(canvasPageId) &&
2281
+ this._navigationState.source !== 'user') {
2282
+ return; // Another source is navigating to a different target
2283
+ }
2284
+ }
2285
+
2286
+ // Skip if we're not in forced mode and another navigation is active (not ours)
2287
+ // This prevents checkPageChange from overwriting intentional navigation
2288
+ if (!force && this._isNavigationActive() && !this._matchesNavigationTarget(canvasPageId)) {
2289
+ return; // Another navigation is active, skip
2290
+ }
2291
+
2292
+ // Skip if we recently committed and this isn't a forced update
2293
+ if (!force && this._hasRecentCommit()) {
2294
+ const recentId = this._getRecentlyCommittedId();
2295
+ // Only skip if we're trying to commit something different from what we recently committed
2296
+ if (recentId && canvasPageId && canvasPageId !== recentId) {
2297
+ // Check if we're trying to commit an older page (wouldDowngrade check)
2298
+ const recentPageNum = recentId.match(/_(\d{2,3})\./);
2299
+ const canvasPageNum = canvasPageId.match(/_(\d{2,3})\./);
2300
+ if (recentPageNum && canvasPageNum &&
2301
+ parseInt(canvasPageNum[1], 10) < parseInt(recentPageNum[1], 10)) {
2302
+ return; // Skip - would downgrade to older page
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ // If force=true (from handlePageChange) and state already matches, emit pb-refresh but skip commit
2308
+ if (force && this._isNavigationActive() && canvasPageId && registryState.id === canvasPageId) {
2309
+ setTimeout(() => {
2310
+ this._emitPbRefresh(canvas);
2311
+ }, 50);
2312
+ return; // Skip commit but pb-refresh was emitted
2313
+ }
2314
+
2315
+ if (!canvas) {
2316
+ console.warn('[pb-tify] _updateUrlFromPage: skipping - no canvas provided');
2317
+ return;
2318
+ }
2319
+
2320
+ // If canvas doesn't have rendering property, try to get it from manifest
2321
+ // The manifest canvas should always have rendering with root/id parameters
2322
+ // root should be an eXist-db node ID like "3.5.6.1" extracted from rendering URL
2323
+ if (!canvas.rendering || canvas.rendering.length === 0) {
2324
+ const root = this._getRootFromApp();
2325
+ if (root) {
2326
+ const canvases = this._getCanvases(root);
2327
+ // Try to find matching canvas in manifest by @id first (most reliable)
2328
+ if (canvas['@id']) {
2329
+ for (const manifestCanvas of canvases) {
2330
+ if (manifestCanvas['@id'] === canvas['@id']) {
2331
+ // Found matching canvas in manifest - use it for rendering
2332
+ canvas = manifestCanvas;
2333
+ break;
2334
+ }
2335
+ }
2336
+ }
2337
+ // Fallback: try matching by label if @id didn't work
2338
+ if ((!canvas.rendering || canvas.rendering.length === 0) && canvas.label) {
2339
+ const label = canvas.label.none || canvas.label.en || canvas.label;
2340
+ const labelStr = Array.isArray(label) ? label[0] : label;
2341
+ if (labelStr) {
2342
+ for (const manifestCanvas of canvases) {
2343
+ const manifestLabel = manifestCanvas.label?.none || manifestCanvas.label?.en || manifestCanvas.label;
2344
+ const manifestLabelStr = Array.isArray(manifestLabel) ? manifestLabel[0] : manifestLabel;
2345
+ if (manifestLabelStr === labelStr) {
2346
+ // Found matching canvas in manifest - use it for rendering
2347
+ canvas = manifestCanvas;
2348
+ break;
2349
+ }
2350
+ }
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ // If still no rendering, try to get root from page number → root lookup map
2356
+ // This map is built from the TEI Publisher manifest which includes rendering URLs with root
2357
+ if ((!canvas.rendering || canvas.rendering.length === 0) && canvas.label) {
2358
+ const label = canvas.label.none || canvas.label.en || canvas.label;
2359
+ const labelStr = Array.isArray(label) ? label[0] : label;
2360
+ if (labelStr) {
2361
+ // Ensure we have the page-to-root map
2362
+ await this._ensurePageToRootMap();
2363
+
2364
+ // Look up root by page number (e.g., "011")
2365
+ const pageNum = String(labelStr).padStart(3, '0');
2366
+ const root = this._pageToRootMap && this._pageToRootMap[pageNum];
2367
+
2368
+ if (root) {
2369
+ // Found root! Construct rendering URL and add it to canvas
2370
+ const pathParts = window.location.pathname.split('/');
2371
+ let docPath = null;
2372
+ for (let i = pathParts.length - 1; i >= 0; i--) {
2373
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
2374
+ docPath = pathParts[i];
2375
+ break;
2376
+ }
2377
+ }
2378
+
2379
+ if (docPath) {
2380
+ const endpoint = this.getEndpoint();
2381
+ const renderingUrl = `${endpoint}/api/parts/${encodeURIComponent(docPath)}/html?root=${encodeURIComponent(root)}`;
2382
+
2383
+ if (!canvas.rendering) {
2384
+ canvas.rendering = [];
2385
+ }
2386
+ canvas.rendering.push({
2387
+ '@id': renderingUrl,
2388
+ id: renderingUrl,
2389
+ type: 'Text',
2390
+ format: 'text/html'
2391
+ });
2392
+ }
2393
+ } else {
2394
+ console.warn('[pb-tify] _updateUrlFromPage: root not found in lookup map for page', { pageNum, labelStr });
2395
+ }
2396
+ }
2397
+ }
2398
+ }
2399
+
2400
+ // Extract document ID from URL path (e.g., /exist/apps/mf-app/A-N-38 -> A-N-38)
2401
+ // Or from manifest URL if available
2402
+ let docId = null;
2403
+ const pathParts = window.location.pathname.split('/');
2404
+ // Look for the document ID in the path (usually the last non-empty part before query)
2405
+ for (let i = pathParts.length - 1; i >= 0; i--) {
2406
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
2407
+ docId = pathParts[i];
2408
+ break;
2409
+ }
2410
+ }
2411
+
2412
+ // If no docId found in path, try to extract from manifest URL
2413
+ if (!docId && this.manifest) {
2414
+ try {
2415
+ const manifestUrl = new URL(this.manifest, window.location.origin);
2416
+ const manifestPath = manifestUrl.pathname.split('/');
2417
+ docId = manifestPath[manifestPath.length - 1];
2418
+ } catch (e) {
2419
+ // Ignore
2420
+ }
2421
+ }
2422
+
2423
+ // Get canvas label for generating id and hash
2424
+ let pageId = null; // Format: A-N-38_002.jpg
2425
+ let hashFragment = null; // Format: #A-N-38_002.jpg
2426
+
2427
+ if (canvas.label && docId) {
2428
+ const label = canvas.label.none || canvas.label.en || canvas.label;
2429
+ let labelStr = null;
2430
+
2431
+ if (Array.isArray(label)) {
2432
+ labelStr = label[0];
2433
+ } else if (typeof label === 'string') {
2434
+ labelStr = label;
2435
+ }
2436
+
2437
+ if (labelStr) {
2438
+ // Pad label to 3 digits (e.g., "2" -> "002", "10" -> "010")
2439
+ const pageNum = String(labelStr).padStart(3, '0');
2440
+ // Format: DOCUMENT_ID_PAGENUM.jpg (e.g., A-N-38_002.jpg)
2441
+ pageId = `${docId}_${pageNum}.jpg`;
2442
+ hashFragment = `#${pageId}`;
2443
+ }
2444
+ }
2445
+
2446
+ // Try to extract root/id from rendering URL (may override pageId)
2447
+ // If no rendering property, we may have just fetched it above - check again
2448
+ let root = null;
2449
+ let id = null;
2450
+ const { rendering } = canvas;
2451
+ if (rendering && rendering.length > 0) {
2452
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
2453
+ if (renderingId) {
2454
+ try {
2455
+ // Handle both absolute and relative URLs
2456
+ let url;
2457
+ if (renderingId.startsWith('http://') || renderingId.startsWith('https://')) {
2458
+ url = new URL(renderingId);
2459
+ } else {
2460
+ // Relative URL - convert to absolute using current origin
2461
+ url = new URL(renderingId, window.location.origin);
2462
+ }
2463
+ root = url.searchParams.get('root');
2464
+ id = url.searchParams.get('id');
2465
+ } catch (error) {
2466
+ console.warn('[pb-tify] _updateUrlFromPage: Error parsing rendering URL:', renderingId, error);
2467
+ }
2468
+ }
2469
+ }
2470
+
2471
+ // Use id from rendering URL if available, otherwise use generated pageId
2472
+ const finalId = id || pageId;
2473
+
2474
+ // Check if we're already on this page (prevent duplicate commits)
2475
+ const currentState = registry.getState(this);
2476
+ const idMatches = finalId && currentState.id === finalId;
2477
+ const rootMatches = root && currentState.root === root;
2478
+ const alreadyMatches = idMatches && (rootMatches || !root); // Match if id matches and (root matches or no root provided)
2479
+
2480
+ // Check if root has changed (critical for transcription sync)
2481
+ const rootChanged = root && currentState.root !== root;
2482
+
2483
+ // Check if URL is missing id parameter in query string (needed for shareable URLs)
2484
+ // Even if state matches, we need to ensure id is in query params AND hash
2485
+ const urlParams = new URLSearchParams(window.location.search);
2486
+ const urlHasIdParam = urlParams.has('id') && urlParams.get('id') === finalId;
2487
+ const needsIdInQuery = finalId && !urlHasIdParam;
2488
+
2489
+ // Commit if different from current state OR if URL is missing id parameter OR if root changed
2490
+ // Root changes are critical for transcription sync, so always commit when root changes
2491
+ // CRITICAL: If force=true (from handlePageChange), always commit even if state appears to match
2492
+ // This ensures user-initiated navigation always updates the URL
2493
+ // Also commit if navigation state is active and matches target (intentional navigation)
2494
+ const isNavigationActive = this._navigationState && this._navigationState.isActive;
2495
+ const matchesNavigationTarget = this._matchesNavigationTarget(finalId);
2496
+ const shouldCommit = (finalId || root) &&
2497
+ (!alreadyMatches || needsIdInQuery || rootChanged || force ||
2498
+ (isNavigationActive && matchesNavigationTarget));
2499
+
2500
+ if (shouldCommit) {
2501
+ // Check if we should skip (recently committed same ID and not intentional navigation)
2502
+ const recentId = this._getRecentlyCommittedId();
2503
+ const shouldSkip = recentId && finalId === recentId &&
2504
+ !rootChanged && // Don't skip if root changed
2505
+ !isNavigationActive && // Don't skip if navigation is active
2506
+ !force; // Don't skip if force=true (intentional navigation)
2507
+
2508
+ if (!shouldSkip) {
2509
+ // Update navigation state if not already set (for user-initiated commits)
2510
+ if (!isNavigationActive && force) {
2511
+ this._setNavigationState('user', null, finalId);
2512
+ }
2513
+
2514
+ registry.commit(this, {
2515
+ id: finalId || null,
2516
+ root: root || null,
2517
+ });
2518
+
2519
+ // Verify registry state was actually committed
2520
+ setTimeout(() => {
2521
+ const committedState = registry.getState(this);
2522
+ if (committedState.id !== finalId || committedState.root !== root) {
2523
+ console.warn('[pb-tify] Registry state mismatch after commit', {
2524
+ expectedId: finalId,
2525
+ expectedRoot: root,
2526
+ actualId: committedState.id,
2527
+ actualRoot: committedState.root
2528
+ });
2529
+ }
2530
+ }, 50);
2531
+ }
2532
+ }
2533
+
2534
+ // Always update hash fragment if we have one
2535
+ // This ensures the URL always contains page information
2536
+ if (hashFragment) {
2537
+ // Update hash directly - registry doesn't handle hash updates
2538
+ const currentUrl = new URL(window.location.href);
2539
+ currentUrl.hash = hashFragment;
2540
+ // Use replaceState to update hash without adding history entry
2541
+ // This keeps the URL in sync with the page
2542
+ window.history.replaceState(window.history.state, '', currentUrl.toString());
2543
+ }
2544
+
2545
+ // Always emit pb-refresh after updating URL (for Option 6: Hybrid approach)
2546
+ // This ensures pb-view gets notified even if it's debouncing registry changes
2547
+ // Since we now wait for Tify to change before committing, we can emit immediately
2548
+ // But still use a small delay to ensure registry state is fully updated
2549
+ setTimeout(() => {
2550
+ // Get the current canvas from Tify's viewer state to ensure accuracy
2551
+ let currentCanvas = canvas;
2552
+
2553
+ if (this._tify && this._tify.viewer) {
2554
+ const viewer = this._tify.viewer;
2555
+ if (viewer.currentCanvas) {
2556
+ currentCanvas = viewer.currentCanvas;
2557
+ } else if (viewer.currentPage) {
2558
+ const currentPage = typeof viewer.currentPage === 'function'
2559
+ ? viewer.currentPage()
2560
+ : viewer.currentPage;
2561
+ if (currentPage !== undefined && currentPage !== null) {
2562
+ const root = this._getRootFromApp();
2563
+ if (root) {
2564
+ const canvases = this._getCanvases(root);
2565
+ const pageIndex = typeof currentPage === 'number' ? currentPage - 1 : currentPage;
2566
+ if (pageIndex >= 0 && pageIndex < canvases.length) {
2567
+ currentCanvas = canvases[pageIndex];
2568
+ }
2569
+ }
2570
+ }
2571
+ } else if (viewer._sequenceIndex !== undefined) {
2572
+ const root = this._getRootFromApp();
2573
+ if (root) {
2574
+ const canvases = this._getCanvases(root);
2575
+ if (viewer._sequenceIndex >= 0 && viewer._sequenceIndex < canvases.length) {
2576
+ currentCanvas = canvases[viewer._sequenceIndex];
2577
+ }
2578
+ }
2579
+ }
2580
+ }
2581
+
2582
+ // Verify registry state matches what we're emitting
2583
+ // This ensures pb-view gets the correct state
2584
+ const registryState = registry.getState(this);
2585
+ const { rendering } = currentCanvas || canvas;
2586
+ let canvasId = null;
2587
+ if (rendering && rendering.length > 0) {
2588
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
2589
+ if (renderingId) {
2590
+ try {
2591
+ const url = new URL(renderingId);
2592
+ canvasId = url.searchParams.get('id');
2593
+ } catch (e) {
2594
+ // Ignore URL parsing errors
2595
+ }
2596
+ }
2597
+ }
2598
+
2599
+ // If registry state doesn't match yet, wait a bit more
2600
+ // This is important because pb-view prioritizes registry state over event detail
2601
+ // NOTE: With readOnlyRegistry enabled on pb-view, this should rarely happen
2602
+ // as pb-view won't reset the registry anymore
2603
+ if (canvasId && registryState.id !== canvasId) {
2604
+ // Wait a bit more for registry to stabilize
2605
+ setTimeout(() => {
2606
+ this._emitPbRefresh(currentCanvas || canvas);
2607
+ }, 150);
2608
+ return;
2609
+ }
2610
+
2611
+ // Registry state matches - emit refresh immediately
2612
+ this._emitPbRefresh(currentCanvas || canvas);
2613
+ }, 150); // Increased delay to ensure registry commit has fully processed
2614
+ }
2615
+
2616
+ /**
2617
+ * Handle URL changes (e.g., from browser back/forward).
2618
+ * Called by registry subscription when URL changes.
2619
+ * @param {Object} state - The new state from registry
2620
+ */
2621
+ _handleUrlChange(state) {
2622
+ if (!this._tify || !this._tify.app || !this._setPage) {
2623
+ return;
2624
+ }
2625
+
2626
+ // Check if navigation is active (any source except 'url')
2627
+ if (this._isNavigationActive()) {
2628
+ // If this URL change matches our navigation target, skip (we're navigating to it)
2629
+ if (this._matchesNavigationTarget(state.id)) {
2630
+ return;
2631
+ }
2632
+ // If another source is navigating (not 'url'), skip (they control navigation)
2633
+ if (this._navigationState.source !== 'url') {
2634
+ return;
2635
+ }
2636
+ }
2637
+
2638
+ // Check if we recently committed this page (post-navigation protection)
2639
+ if (this._isRecentlyCommitted(state.id)) {
2640
+ return; // This is our own commit, don't reset Tify
2641
+ }
2642
+
2643
+ // CRITICAL CHECK: Verify the state hasn't changed since this callback was queued
2644
+ // Registry callbacks can be delayed, so the state might have changed
2645
+ const currentRegistryState = registry.getState(this);
2646
+ if (currentRegistryState.id !== state.id || currentRegistryState.root !== state.root) {
2647
+ // State has changed - this callback is stale, ignore it
2648
+ return;
2649
+ }
2650
+
2651
+ const root = this._getRootFromApp();
2652
+ if (!root) {
2653
+ return;
2654
+ }
2655
+
2656
+ const canvases = this._getCanvases(root);
2657
+ if (canvases.length === 0) {
2658
+ return;
2659
+ }
2660
+
2661
+ // PRIORITY 1: Check hash first (most reliable - e.g., #A-N-38_004.jpg)
2662
+ let targetPage = null;
2663
+ const hash = window.location.hash;
2664
+ if (hash) {
2665
+ const hashMatch = hash.match(/_(\d{2,3})\./);
2666
+ if (hashMatch) {
2667
+ const pageNum = parseInt(hashMatch[1], 10);
2668
+ if (pageNum >= 1 && pageNum <= canvases.length) {
2669
+ targetPage = pageNum;
2670
+ }
2671
+ }
2672
+ }
2673
+
2674
+ // PRIORITY 2: Find page from state.id (registry is single source of truth)
2675
+ if (!targetPage && state.id) {
2676
+ for (let i = 0; i < canvases.length; i++) {
2677
+ const canvas = canvases[i];
2678
+ const { rendering } = canvas;
143
2679
  if (rendering && rendering.length > 0) {
144
- const url = new URL(rendering[0]['@id']);
145
- const params = {};
2680
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
2681
+ if (renderingId) {
2682
+ try {
2683
+ const url = new URL(renderingId);
2684
+ const canvasId = url.searchParams.get('id');
2685
+ if (canvasId === state.id) {
2686
+ targetPage = i + 1;
2687
+ break;
2688
+ }
2689
+ } catch (e) {
2690
+ // Ignore
2691
+ }
2692
+ }
2693
+ }
2694
+ // Also check canvas label for id match (e.g., A-N-38_002.jpg)
2695
+ if (canvas.label) {
2696
+ const label = canvas.label.none || canvas.label.en || canvas.label;
2697
+ const labelStr = Array.isArray(label) ? label[0] : label;
2698
+ if (labelStr) {
2699
+ // Exact match or substring match
2700
+ if (state.id === labelStr || state.id.includes(labelStr) || labelStr.includes(state.id)) {
2701
+ targetPage = i + 1;
2702
+ break;
2703
+ }
2704
+ // Also try extracting page number from both
2705
+ const statePageMatch = state.id.match(/_(\d{2,3})\./);
2706
+ const labelPageMatch = labelStr.match(/(\d{2,3})/);
2707
+ if (statePageMatch && labelPageMatch && statePageMatch[1] === labelPageMatch[1]) {
2708
+ targetPage = i + 1;
2709
+ break;
2710
+ }
2711
+ }
2712
+ }
2713
+ }
2714
+ }
2715
+
2716
+ if (!targetPage) {
2717
+ return;
2718
+ }
2719
+
2720
+ // CRITICAL: Check if Tify is already on the correct page BEFORE any other logic
2721
+ // This prevents unnecessary resets that could cause bounce-back
2722
+ let tifyCurrentPage = null;
2723
+ if (this._tify && this._tify.viewer) {
2724
+ if (typeof this._tify.viewer.currentPage === 'function') {
2725
+ try {
2726
+ const currentTifyPage = this._tify.viewer.currentPage();
2727
+ if (typeof currentTifyPage === 'number') {
2728
+ tifyCurrentPage = currentTifyPage + 1;
2729
+ }
2730
+ } catch (e) {
2731
+ // Ignore
2732
+ }
2733
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
2734
+ const seqIndex = this._tify.viewer._sequenceIndex;
2735
+ if (typeof seqIndex === 'number') {
2736
+ tifyCurrentPage = seqIndex + 1;
2737
+ }
2738
+ }
2739
+ }
2740
+
2741
+ // If already on correct page, just emit refresh and return
2742
+ // This is a critical guardrail - don't reset Tify if it's already correct
2743
+ if (tifyCurrentPage === targetPage) {
2744
+ this._currentPage = targetPage;
2745
+ this._emitPbRefresh(canvases[targetPage - 1]);
2746
+ return;
2747
+ }
2748
+
2749
+ // This should never be reached if _lastCommittedId matches (we return early above)
2750
+ // But add extra safety check for _targetPageId
2751
+ if (this._targetPageId && state.id === this._targetPageId) {
2752
+ // This is our target page - don't interfere, just emit refresh if needed
2753
+ this._emitPbRefresh(canvases[targetPage - 1]);
2754
+ return;
2755
+ }
2756
+
2757
+ // EXTRA PROTECTION: Check if Tify's current canvas matches the state we're trying to navigate to
2758
+ // This prevents resetting Tify if it's already showing the correct content, even if page numbers differ
2759
+ if (tifyCurrentPage && tifyCurrentPage > 0 && tifyCurrentPage <= canvases.length) {
2760
+ const currentCanvas = canvases[tifyCurrentPage - 1];
2761
+ if (currentCanvas) {
2762
+ // Check if current canvas matches the target state
2763
+ let currentCanvasId = null;
2764
+ const { rendering } = currentCanvas;
2765
+ if (rendering && rendering.length > 0) {
2766
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
2767
+ if (renderingId) {
2768
+ try {
2769
+ const url = new URL(renderingId, window.location.origin);
2770
+ currentCanvasId = url.searchParams.get('id');
2771
+ } catch (e) {
2772
+ // Ignore
2773
+ }
2774
+ }
2775
+ }
2776
+ // Also check label
2777
+ if (!currentCanvasId && currentCanvas.label) {
2778
+ const label = currentCanvas.label.none || currentCanvas.label.en || currentCanvas.label;
2779
+ const labelStr = Array.isArray(label) ? label[0] : label;
2780
+ if (labelStr && state.id) {
2781
+ const pathParts = window.location.pathname.split('/');
2782
+ let docId = null;
2783
+ for (let i = pathParts.length - 1; i >= 0; i--) {
2784
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
2785
+ docId = pathParts[i];
2786
+ break;
2787
+ }
2788
+ }
2789
+ if (docId) {
2790
+ const pageNum = String(labelStr).padStart(3, '0');
2791
+ currentCanvasId = `${docId}_${pageNum}.jpg`;
2792
+ }
2793
+ }
2794
+ }
2795
+
2796
+ // If current canvas matches the state, don't reset Tify
2797
+ if (currentCanvasId && state.id && currentCanvasId === state.id) {
2798
+ this._currentPage = tifyCurrentPage;
2799
+ this._emitPbRefresh(currentCanvas);
2800
+ return;
2801
+ }
2802
+ }
2803
+ }
2804
+
2805
+ // Check if another navigation source is active
2806
+ if (this._isNavigationActive() && this._navigationState.source !== 'url') {
2807
+ return; // Another source is navigating, don't interfere
2808
+ }
2809
+
2810
+ // Set navigation state for URL-based navigation
2811
+ this._setNavigationState('url', targetPage, state.id);
2812
+
2813
+ // Update Tify to match registry
2814
+ this._setPage([targetPage]);
2815
+ this._currentPage = targetPage;
2816
+
2817
+ // Wait for Tify to actually change pages before emitting refresh
2818
+ const verifyAndEmit = (attempts = 0) => {
2819
+ let tifyPage = null;
2820
+ if (this._tify && this._tify.viewer) {
2821
+ if (typeof this._tify.viewer.currentPage === 'function') {
2822
+ try {
2823
+ const page = this._tify.viewer.currentPage();
2824
+ if (typeof page === 'number') {
2825
+ tifyPage = page + 1;
2826
+ }
2827
+ } catch (e) {
2828
+ // Ignore
2829
+ }
2830
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
2831
+ const seqIndex = this._tify.viewer._sequenceIndex;
2832
+ if (typeof seqIndex === 'number') {
2833
+ tifyPage = seqIndex + 1;
2834
+ }
2835
+ }
2836
+ }
2837
+
2838
+ if (tifyPage === targetPage) {
2839
+ // Tify is on the correct page - emit refresh
2840
+ this._clearNavigationState();
2841
+ this._emitPbRefresh(canvases[targetPage - 1]);
2842
+ } else if (attempts < 10) {
2843
+ // Not yet, retry
2844
+ setTimeout(() => verifyAndEmit(attempts + 1), 200);
2845
+ } else {
2846
+ // Give up - emit refresh anyway
2847
+ this._clearNavigationState();
2848
+ this._emitPbRefresh(canvases[targetPage - 1]);
2849
+ }
2850
+ };
2851
+
2852
+ setTimeout(() => verifyAndEmit(), 200);
2853
+ }
2854
+
2855
+ /**
2856
+ * Emit pb-refresh event for transcription synchronization
2857
+ * @param {Object} canvas - The canvas object
2858
+ */
2859
+ _emitPbRefresh(canvas) {
2860
+ // Get registry state first (always available and up-to-date)
2861
+ const registryState = registry.getState(this);
2862
+
2863
+ // Try to get params from canvas rendering URL
2864
+ let params = {};
2865
+ if (canvas) {
2866
+ const { rendering } = canvas;
2867
+ if (rendering && rendering.length > 0) {
2868
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
2869
+ if (renderingId) {
2870
+ try {
2871
+ // Handle both absolute and relative URLs
2872
+ let url;
2873
+ if (renderingId.startsWith('http://') || renderingId.startsWith('https://')) {
2874
+ url = new URL(renderingId);
2875
+ } else {
2876
+ // Relative URL - convert to absolute using current origin
2877
+ url = new URL(renderingId, window.location.origin);
2878
+ }
146
2879
  url.searchParams.forEach((value, key) => {
147
- params[key] = value;
148
- })
149
- console.log('<pb-tify> page changed, emitting refresh with params %o', params);
150
- this.emitTo('pb-refresh', params);
2880
+ params[key] = value;
2881
+ });
2882
+ } catch (error) {
2883
+ console.warn('[pb-tify] _emitPbRefresh: Error parsing rendering URL:', renderingId, error);
2884
+ }
151
2885
  }
2886
+ }
2887
+ }
2888
+
2889
+ // ALWAYS include root from registry state, even if we got params from canvas
2890
+ // This ensures pb-view gets the correct root value, which is critical for transcription sync
2891
+ // The registry state is the source of truth after _updateUrlFromPage has committed
2892
+ if (registryState.root) {
2893
+ params.root = registryState.root;
152
2894
  }
2895
+
2896
+ // If no params from canvas, also include id from registry state
2897
+ // This is a fallback when canvas doesn't have rendering property
2898
+ if (!params.id && registryState.id) {
2899
+ params.id = registryState.id;
2900
+ }
2901
+
2902
+ // If we still have no params, we can't emit a meaningful event
2903
+ if (Object.keys(params).length === 0) {
2904
+ return;
2905
+ }
2906
+
2907
+ try {
2908
+ // Include registry state in event detail to ensure pb-view has the latest state
2909
+ // pb-view prioritizes registry state, so we need to make sure it's included
2910
+
2911
+ // Get channels - use emit attribute if set, otherwise use defaultChannel
2912
+ // Application code should specify emit="transcription" (or other channel) via HTML attribute
2913
+ // This follows the pb-mixin pattern where components are generic and applications configure them
2914
+ const channels = getEmittedChannels(this);
2915
+
2916
+ // Merge params with registry state for the event detail
2917
+ const eventDetail = Object.assign({}, params, {
2918
+ ...registryState, // Include registry state so pb-view gets the correct state
2919
+ _source: this
2920
+ });
2921
+
2922
+ // Use emitTo which properly handles channels and ensures pb-view receives the event
2923
+ // emitTo dispatches from the element, but events bubble to document where subscribeTo listens
2924
+ // emitTo will set the correct 'key' in the event detail based on the channel
2925
+ this.emitTo('pb-refresh', eventDetail, channels);
2926
+
2927
+ // Also dispatch directly to document to ensure it's caught by document.addEventListener
2928
+ // This is a fallback in case bubbling doesn't work as expected
2929
+ // Use the first channel from getEmittedChannels (or defaultChannel if none)
2930
+ const channelKey = channels[0] || defaultChannel;
2931
+ const directEventDetail = Object.assign({}, eventDetail, { key: channelKey });
2932
+ const directEv = new CustomEvent('pb-refresh', {
2933
+ detail: directEventDetail,
2934
+ composed: true,
2935
+ bubbles: true,
2936
+ });
2937
+ document.dispatchEvent(directEv);
2938
+ } catch (error) {
2939
+ console.error('<pb-tify> Error emitting pb-refresh:', error);
2940
+ }
2941
+ }
153
2942
 
154
- _addOverlay(coordinates) {
155
- if (!Array.isArray(coordinates) || coordinates.length !== 4) {
156
- console.error('coords incomplete or missing (array of 4 numbers expected)', coordinates);
157
- return;
2943
+ /**
2944
+ * Get the canvas array from the manifest, supporting both IIIF 2.0 and 3.0 formats.
2945
+ * @param {Object} root - The root manifest object
2946
+ * @returns {Array} Array of canvases
2947
+ */
2948
+ _getCanvases(root) {
2949
+ if (!root) {
2950
+ return [];
2951
+ }
2952
+
2953
+ // Helper to check if an item is a Canvas
2954
+ const isCanvas = (item) => {
2955
+ return item && (
2956
+ item.type === 'Canvas' ||
2957
+ item['@type'] === 'sc:Canvas' ||
2958
+ item['@type'] === 'Canvas'
2959
+ );
2960
+ };
2961
+
2962
+ // Helper to recursively collect all canvases from items
2963
+ const collectCanvases = (items) => {
2964
+ if (!Array.isArray(items)) {
2965
+ return [];
2966
+ }
2967
+ const canvases = [];
2968
+ for (const item of items) {
2969
+ if (isCanvas(item)) {
2970
+ canvases.push(item);
2971
+ } else if (item.items && Array.isArray(item.items)) {
2972
+ // Recursively search in nested items (for sequences, ranges, etc.)
2973
+ canvases.push(...collectCanvases(item.items));
158
2974
  }
2975
+ }
2976
+ return canvases;
2977
+ };
2978
+
2979
+ // IIIF 3.0: canvases are in items (directly or nested in sequences/ranges)
2980
+ if (root.items && Array.isArray(root.items)) {
2981
+ const canvases = collectCanvases(root.items);
2982
+ if (canvases.length > 0) {
2983
+ return canvases;
2984
+ }
2985
+ }
2986
+
2987
+ // IIIF 2.0: canvases are in sequences[0].canvases
2988
+ if (root.sequences && Array.isArray(root.sequences) && root.sequences.length > 0) {
2989
+ const canvases = root.sequences[0].canvases || [];
2990
+ if (canvases.length > 0) {
2991
+ return canvases;
2992
+ }
2993
+ }
2994
+
2995
+ // Fallback: try canvases directly (some IIIF 2.0 variants)
2996
+ if (root.canvases && Array.isArray(root.canvases)) {
2997
+ return root.canvases;
2998
+ }
2999
+
3000
+ return [];
3001
+ }
159
3002
 
160
- const { viewer } = this._tify;
161
- const { viewport } = viewer;
162
- const overlayId = 'runtime-overlay';
163
-
164
- if(this.overlay) {
165
- viewer.removeOverlay(this.overlay);
3003
+ _getRootFromApp() {
3004
+ // Try multiple ways to get the manifest/root data from Tify
3005
+ if (!this._tify) {
3006
+ return this._cachedManifest || null;
3007
+ }
3008
+
3009
+ // First try to get from app object (Vue 2/3 style)
3010
+ if (this._tify.app) {
3011
+ const app = this._tify.app;
3012
+
3013
+ // Vue 2 style: $root, $data
3014
+ const root = app.$root || app.root || app.manifest || (app.$data && app.$data.root) || (app.$data && app.$data.manifest);
3015
+ if (root) {
3016
+ return root;
3017
+ }
3018
+
3019
+ // Vue 3: Try _context.app (root app instance)
3020
+ if (app._context && app._context.app) {
3021
+ const rootApp = app._context.app;
3022
+ const rootFromContext = rootApp.$root || rootApp.root || rootApp.manifest ||
3023
+ (rootApp.$data && rootApp.$data.root) ||
3024
+ (rootApp.$data && rootApp.$data.manifest);
3025
+ if (rootFromContext) {
3026
+ return rootFromContext;
166
3027
  }
3028
+ }
3029
+
3030
+ // Vue 3: Try _context directly
3031
+ if (app._context) {
3032
+ const rootFromContext = app._context.root || app._context.manifest;
3033
+ if (rootFromContext) {
3034
+ return rootFromContext;
3035
+ }
3036
+ }
3037
+
3038
+ // Vue 3: Try from app's internal state (composition API)
3039
+ if (app._instance && app._instance.setupState) {
3040
+ const setupState = app._instance.setupState;
3041
+ if (setupState.root) return setupState.root;
3042
+ if (setupState.manifest) return setupState.manifest;
3043
+ }
3044
+
3045
+ // Vue 3: Try _instance.exposed (exposed properties)
3046
+ if (app._instance && app._instance.exposed) {
3047
+ const exposed = app._instance.exposed;
3048
+ if (exposed.root) return exposed.root;
3049
+ if (exposed.manifest) return exposed.manifest;
3050
+ }
3051
+ }
3052
+
3053
+ // Try direct access from Tify instance
3054
+ if (this._tify.manifest) {
3055
+ return this._tify.manifest;
3056
+ }
3057
+
3058
+ // Try from viewer
3059
+ if (this._tify.viewer && this._tify.viewer.manifest) {
3060
+ return this._tify.viewer.manifest;
3061
+ }
3062
+
3063
+ // Try from Tify's internal store or state
3064
+ if (this._tify.store && this._tify.store.state) {
3065
+ const state = this._tify.store.state;
3066
+ if (state.root) return state.root;
3067
+ if (state.manifest) return state.manifest;
3068
+ }
3069
+
3070
+ // Fallback: use cached manifest if available
3071
+ return this._cachedManifest || null;
3072
+ }
3073
+
3074
+ async _fetchAndCacheManifest() {
3075
+ // Fetch manifest directly from URL as fallback
3076
+ if (this._manifestUrl && !this._cachedManifest) {
3077
+ try {
3078
+ const response = await fetch(this._manifestUrl);
3079
+ this._cachedManifest = await response.json();
3080
+ return this._cachedManifest;
3081
+ } catch (error) {
3082
+ console.warn('<pb-tify> Failed to fetch manifest as fallback:', error);
3083
+ return null;
3084
+ }
3085
+ }
3086
+ return this._cachedManifest || null;
3087
+ }
3088
+
3089
+ /**
3090
+ * Fetch the page-to-root mapping from the backend API.
3091
+ * This map allows us to get the root identifier (node ID format, e.g., "1.7.2.2.4")
3092
+ * for any page number.
3093
+ * @returns {Promise<Object>} Map of page numbers (e.g., "011") to root values (e.g., "1.7.2.2.17")
3094
+ */
3095
+ async _fetchPageToRootMap() {
3096
+ // Extract document path from URL
3097
+ const pathParts = window.location.pathname.split('/');
3098
+ let docPath = null;
3099
+ for (let i = pathParts.length - 1; i >= 0; i--) {
3100
+ if (pathParts[i] && pathParts[i] !== 'apps' && pathParts[i] !== 'exist') {
3101
+ docPath = pathParts[i];
3102
+ break;
3103
+ }
3104
+ }
3105
+
3106
+ if (!docPath) {
3107
+ console.warn('[pb-tify] _fetchPageToRootMap: could not extract document path from URL');
3108
+ return null;
3109
+ }
3110
+
3111
+ const endpoint = this.getEndpoint();
3112
+ // In component test environments, endpoint might be undefined
3113
+ // Use window.location.origin as fallback (same as the page's origin)
3114
+ const effectiveEndpoint = endpoint || window.location.origin;
3115
+ // Use the new dedicated roots endpoint (Option 2) - much more efficient than parsing full manifest
3116
+ const rootsUrl = `${effectiveEndpoint}/api/iiif/${encodeURIComponent(docPath)}/roots`;
3117
+
3118
+ try {
3119
+ const response = await fetch(rootsUrl);
3120
+ if (!response.ok) {
3121
+ console.warn('[pb-tify] _fetchPageToRootMap: failed to fetch roots map', {
3122
+ status: response.status,
3123
+ statusText: response.statusText,
3124
+ url: rootsUrl
3125
+ });
3126
+ return null;
3127
+ }
3128
+
3129
+ const pageToRootMap = await response.json();
3130
+ return pageToRootMap;
3131
+ } catch (error) {
3132
+ console.warn('[pb-tify] _fetchPageToRootMap: failed to fetch or parse roots map', error);
3133
+ return null;
3134
+ }
3135
+ }
3136
+
3137
+ /**
3138
+ * Ensure the page-to-root lookup map is available.
3139
+ * Fetches it once and caches it for subsequent use.
3140
+ * @returns {Promise<void>}
3141
+ */
3142
+ async _ensurePageToRootMap() {
3143
+ // If already cached, return immediately
3144
+ if (this._pageToRootMap) {
3145
+ return;
3146
+ }
3147
+
3148
+ // If already fetching, wait for it to complete
3149
+ if (this._fetchingPageToRootMap) {
3150
+ // Wait for the fetch to complete (polling)
3151
+ let attempts = 0;
3152
+ while (this._fetchingPageToRootMap && attempts < 50) {
3153
+ await new Promise(resolve => setTimeout(resolve, 100));
3154
+ attempts++;
3155
+ }
3156
+ return;
3157
+ }
3158
+
3159
+ // Start fetching
3160
+ this._fetchingPageToRootMap = true;
3161
+ try {
3162
+ this._pageToRootMap = await this._fetchPageToRootMap();
3163
+ } finally {
3164
+ this._fetchingPageToRootMap = false;
3165
+ }
3166
+ }
167
3167
 
168
- const viewportBounds = viewport.getBounds();
3168
+ /**
3169
+ * Handle navigation to next/previous page
3170
+ * @private
3171
+ */
3172
+ async _handleNavigate(direction) {
3173
+ const root = this._getRootFromApp();
3174
+
3175
+ if (!this._tify || !this._tify.app) {
3176
+ return;
3177
+ }
3178
+
3179
+ // If root is not available yet, wait for it and retry
3180
+ if (!root) {
3181
+ setTimeout(() => {
3182
+ this._handleNavigate(direction);
3183
+ }, 100);
3184
+ return;
3185
+ }
3186
+
3187
+ // root was already declared above, reuse it
3188
+ const canvases = this._getCanvases(root);
3189
+ const totalPages = canvases.length;
3190
+
3191
+ if (totalPages === 0) {
3192
+ return;
3193
+ }
3194
+
3195
+ // Get current page from registry (single source of truth)
3196
+ const state = registry.getState(this);
3197
+ let currentPage = null;
3198
+
3199
+ // Find page from state.id
3200
+ if (state.id) {
3201
+ for (let i = 0; i < canvases.length; i++) {
3202
+ const canvas = canvases[i];
3203
+ const { rendering } = canvas;
3204
+ if (rendering && rendering.length > 0) {
3205
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
3206
+ if (renderingId) {
3207
+ try {
3208
+ const url = new URL(renderingId);
3209
+ const canvasId = url.searchParams.get('id');
3210
+ if (canvasId === state.id) {
3211
+ currentPage = i + 1;
3212
+ break;
3213
+ }
3214
+ } catch (e) {
3215
+ // Ignore
3216
+ }
3217
+ }
3218
+ }
3219
+ // Also check canvas label
3220
+ if (canvas.label) {
3221
+ const label = canvas.label.none || canvas.label.en || canvas.label;
3222
+ const labelStr = Array.isArray(label) ? label[0] : label;
3223
+ if (labelStr && state.id.includes(labelStr)) {
3224
+ currentPage = i + 1;
3225
+ break;
3226
+ }
3227
+ }
3228
+ }
3229
+ }
3230
+
3231
+ // Fallback to Tify's state or internal state
3232
+ if (!currentPage) {
3233
+ if (this._tify && this._tify.viewer) {
3234
+ if (typeof this._tify.viewer.currentPage === 'function') {
3235
+ try {
3236
+ const currentTifyPage = this._tify.viewer.currentPage();
3237
+ if (typeof currentTifyPage === 'number') {
3238
+ currentPage = currentTifyPage + 1;
3239
+ }
3240
+ } catch (e) {
3241
+ // Ignore
3242
+ }
3243
+ } else if (this._tify.viewer._sequenceIndex !== undefined) {
3244
+ const seqIndex = this._tify.viewer._sequenceIndex;
3245
+ if (typeof seqIndex === 'number') {
3246
+ currentPage = seqIndex + 1;
3247
+ }
3248
+ }
3249
+ }
3250
+ }
3251
+
3252
+ // Final fallback
3253
+ if (!currentPage) {
3254
+ currentPage = this._currentPage || 1;
3255
+ }
3256
+
3257
+ let newPage;
3258
+ if (direction === 'forward') {
3259
+ newPage = Math.min(currentPage + 1, totalPages);
3260
+ } else if (direction === 'backward') {
3261
+ newPage = Math.max(currentPage - 1, 1);
3262
+ } else {
3263
+ return;
3264
+ }
169
3265
 
170
- const [x1, y1, w, h] = coordinates;
171
- const rect = viewport.imageToViewportRectangle(x1, y1, w, h);
3266
+ // Update Tify directly, then update registry
3267
+ const canvas = canvases[newPage - 1];
3268
+ if (canvas) {
3269
+ // Extract target page ID
3270
+ const { rendering } = canvas;
3271
+ let targetId = null;
3272
+ if (rendering && rendering.length > 0) {
3273
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
3274
+ if (renderingId) {
3275
+ try {
3276
+ const url = new URL(renderingId);
3277
+ targetId = url.searchParams.get('id');
3278
+ } catch (e) {
3279
+ // Ignore
3280
+ }
3281
+ }
3282
+ }
3283
+
3284
+ // Set navigation state for programmatic navigation
3285
+ this._setNavigationState('programmatic', newPage, targetId);
3286
+
3287
+ // Update Tify directly first, then update registry
3288
+ if (this._setPage) {
3289
+ this._setPage([newPage]);
3290
+
3291
+ // Wait a bit for Tify to update, then update registry
3292
+ setTimeout(async () => {
3293
+ await this._updateUrlFromPage(canvas, true);
3294
+
3295
+ // Mark navigation complete
3296
+ if (targetId) {
3297
+ this._markNavigationComplete(targetId);
3298
+ }
3299
+ setTimeout(() => {
3300
+ this._clearNavigationState();
3301
+ }, 500);
3302
+ }, 100);
3303
+ } else {
3304
+ // Fallback: just update registry
3305
+ await this._updateUrlFromPage(canvas, true);
3306
+
3307
+ // Mark navigation complete
3308
+ if (targetId) {
3309
+ this._markNavigationComplete(targetId);
3310
+ }
3311
+ setTimeout(() => {
3312
+ this._clearNavigationState();
3313
+ }, 500);
3314
+ }
3315
+ }
3316
+ }
172
3317
 
173
- // Scroll into view if necessary
174
- if (!viewportBounds.containsPoint(rect.getTopLeft())) {
175
- viewer.viewport.panTo(rect.getCenter());
3318
+ async _switchPage(canvas) {
3319
+ // Update URL from canvas (single source of truth)
3320
+ // This triggers _handleUrlChange which updates Tify
3321
+ await this._updateUrlFromPage(canvas);
3322
+
3323
+ // Emit refresh event for transcription
3324
+ const { rendering } = canvas;
3325
+ if (rendering && rendering.length > 0) {
3326
+ const renderingId = rendering[0]['@id'] || rendering[0].id;
3327
+ if (renderingId) {
3328
+ try {
3329
+ const url = new URL(renderingId);
3330
+ const params = {};
3331
+ url.searchParams.forEach((value, key) => {
3332
+ params[key] = value;
3333
+ });
3334
+
3335
+ const channel = this.emit || this.getAttribute('emit') || 'transcription';
3336
+ const eventDetail = Object.assign({}, params, {
3337
+ key: channel,
3338
+ _source: this
3339
+ });
3340
+
3341
+ const ev = new CustomEvent('pb-refresh', {
3342
+ detail: eventDetail,
3343
+ composed: true,
3344
+ bubbles: true,
3345
+ });
3346
+
3347
+ document.dispatchEvent(ev);
3348
+
3349
+ // Use getEmittedChannels to respect emit attribute or defaultChannel
3350
+ const channels = getEmittedChannels(this);
3351
+ this.emitTo('pb-refresh', params, channels);
3352
+ } catch (error) {
3353
+ console.error('<pb-tify> Error parsing rendering URL:', renderingId, error);
176
3354
  }
3355
+ }
3356
+ }
3357
+ }
177
3358
 
178
- // Add overlay to viewer
179
- const overlay = document.createElement('div');
180
- this.overlay = overlay
181
- overlay.id = overlayId;
182
- overlay.style.border = 'var(--pb-facsimile-border, none)';
183
- overlay.style.outline = 'var(--pb-facsimile-outline, 4px solid rgba(0, 0, 128, 0.5))';
184
- overlay.style.background = 'var(--pb-facsimile-background, rgba(0, 0, 128, 0.05))';
185
- viewer.addOverlay({
186
- element: overlay,
187
- location: rect
188
- });
3359
+ _addOverlay(coordinates) {
3360
+ if (!Array.isArray(coordinates) || coordinates.length !== 4) {
3361
+ console.error('coords incomplete or missing (array of 4 numbers expected)', coordinates);
3362
+ return;
3363
+ }
3364
+
3365
+ const { viewer } = this._tify;
3366
+ const { viewport } = viewer;
3367
+ const overlayId = 'runtime-overlay';
3368
+
3369
+ if (this.overlay) {
3370
+ viewer.removeOverlay(this.overlay);
189
3371
  }
190
3372
 
191
- createRenderRoot() {
192
- return this;
3373
+ const viewportBounds = viewport.getBounds();
3374
+
3375
+ const [x1, y1, w, h] = coordinates;
3376
+ const rect = viewport.imageToViewportRectangle(x1, y1, w, h);
3377
+
3378
+ // Scroll into view if necessary
3379
+ if (!viewportBounds.containsPoint(rect.getTopLeft())) {
3380
+ viewer.viewport.panTo(rect.getCenter());
193
3381
  }
3382
+
3383
+ // Add overlay to viewer
3384
+ const overlay = document.createElement('div');
3385
+ this.overlay = overlay;
3386
+ overlay.id = overlayId;
3387
+ overlay.style.border = 'var(--pb-facsimile-border, none)';
3388
+ overlay.style.outline = 'var(--pb-facsimile-outline, 4px solid rgba(0, 0, 128, 0.5))';
3389
+ overlay.style.background = 'var(--pb-facsimile-background, rgba(0, 0, 128, 0.05))';
3390
+ viewer.addOverlay({
3391
+ element: overlay,
3392
+ location: rect,
3393
+ });
3394
+ }
3395
+
3396
+ createRenderRoot() {
3397
+ return this;
3398
+ }
194
3399
  }
195
- customElements.define('pb-tify', PbTify);
3400
+ customElements.define('pb-tify', PbTify);