kviewer 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ A Nuxt module for viewing, annotating, and exporting PDFs. Built on pdfjs-dist,
20
20
  - ↩️ Undo/redo history
21
21
  - 🗂️ Multi-tab document support (`KViewerTabs`)
22
22
  - 🎨 Customizable header and footer slots
23
+ - 👁️ Read tracking — `allPagesRead()` + `all-pages-read` event to gate "must read all pages" signing
23
24
 
24
25
  ## Quick Setup
25
26
 
@@ -112,6 +113,9 @@ const isEditing = viewerRef.value?.formEditMode.value
112
113
  | `formEditMode` | `Ref<boolean>` |
113
114
  | `setFormEditMode(enabled)` | `void` |
114
115
  | `toggleFormEditMode()` | `boolean` (the new state) |
116
+ | `allPagesRead()` | `boolean` |
117
+ | `getViewedPages()` | `number[]` |
118
+ | `resetViewedPages()` | `void` |
115
119
 
116
120
  `exportPdf` options default to `{ flatten: false, download: false, preserveOriginalAnnotations: false }`.
117
121
 
@@ -138,6 +142,30 @@ Or imperatively from the ref API (`setFormEditMode`, `toggleFormEditMode`, react
138
142
 
139
143
  When native PDF annotations are auto-imported into Konva, exporting with `preserveOriginalAnnotations: true` may duplicate annotations. Prefer `preserveOriginalAnnotations: false` in that workflow.
140
144
 
145
+ ### Read tracking
146
+
147
+ For "must read all pages before signing" flows, `KViewer` tracks which pages have entered the viewport. Gate your sign action on the `all-pages-read` event (push) or the `allPagesRead()` ref method (pull):
148
+
149
+ ```vue
150
+ <template>
151
+ <KViewer
152
+ :source="pdfUrl"
153
+ @all-pages-read="canSign = true"
154
+ @update:viewed-pages="(pages) => (readCount = pages.length)"
155
+ />
156
+ <button :disabled="!canSign">Sign</button>
157
+ </template>
158
+
159
+ <script setup lang="ts">
160
+ const canSign = ref(false)
161
+ const readCount = ref(0)
162
+ </script>
163
+ ```
164
+
165
+ A page counts as read once its top edge scrolls into view (computed from geometry, so a fast scroll to the bottom still marks every page it passed). The viewer deliberately does not enforce that the user dwelled on each page — skipping is the signer's choice. The host app owns the policy (e.g. an extra "I have read all pages" checkbox); the viewer only reports the status. Call `resetViewedPages()` to restart tracking; loading a new `source` resets it automatically.
166
+
167
+ The same surface is available over the [embed bridge](src/runtime/embed): `client.allPagesRead()`, `client.getViewedPages()`, `client.resetViewedPages()`, and the `all-pages-read` / `viewedPages-changed` events via `client.on(...)`.
168
+
141
169
  ## Development
142
170
 
143
171
  <details>
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kviewer",
3
3
  "configKey": "kviewer",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -84,12 +84,24 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
84
84
  setFormEditMode: (enabled: boolean) => void;
85
85
  /** Flip form-edit mode. Returns the new state. */
86
86
  toggleFormEditMode: () => boolean;
87
+ /** True once the user has viewed every page — each page has entered the
88
+ * viewport at least once. Use to gate a "must read all pages" sign action.
89
+ * See also the `all-pages-read` event for a push-style signal. */
90
+ allPagesRead: () => boolean;
91
+ /** Page numbers viewed so far, ascending. */
92
+ getViewedPages: () => number[];
93
+ /** Clear viewed-page tracking, e.g. to restart a signing session. */
94
+ resetViewedPages: () => void;
87
95
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
96
+ "all-pages-read": () => any;
88
97
  "update:formEditMode": (value: boolean) => any;
89
98
  "update:activeRoleId": (value: string | null) => any;
99
+ "update:viewedPages": (pages: number[]) => any;
90
100
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
101
+ "onAll-pages-read"?: (() => any) | undefined;
91
102
  "onUpdate:formEditMode"?: ((value: boolean) => any) | undefined;
92
103
  "onUpdate:activeRoleId"?: ((value: string | null) => any) | undefined;
104
+ "onUpdate:viewedPages"?: ((pages: number[]) => any) | undefined;
93
105
  }>, {
94
106
  userName: string;
95
107
  freehandGroupingDelay: number;
@@ -229,7 +229,7 @@ const props = defineProps({
229
229
  scripting: { type: Boolean, required: false, default: false },
230
230
  menuItems: { type: Array, required: false, default: void 0 }
231
231
  });
232
- const emit = defineEmits(["update:formEditMode", "update:activeRoleId"]);
232
+ const emit = defineEmits(["update:formEditMode", "update:activeRoleId", "update:viewedPages", "all-pages-read"]);
233
233
  const viewerRoot = ref(null);
234
234
  const scrollContainer = ref(null);
235
235
  const { state: viewerState, setScrollToPageFn, setDownloadPdfFn } = provideViewerState();
@@ -306,6 +306,31 @@ watch(
306
306
  viewerState.currentPage.value = page;
307
307
  }
308
308
  );
309
+ watch(
310
+ () => viewerState.currentPage.value,
311
+ (page) => virtualization.markPageViewed(page),
312
+ { immediate: true }
313
+ );
314
+ watch(
315
+ () => pageMetas.value.length,
316
+ (len) => {
317
+ if (len > 0 && !isSinglePageMode.value) {
318
+ nextTick(() => virtualization.updateCurrentPageByRect());
319
+ }
320
+ }
321
+ );
322
+ watch(
323
+ virtualization.allPagesRead,
324
+ (read, prev) => {
325
+ if (read && !prev) emit("all-pages-read");
326
+ }
327
+ );
328
+ watch(
329
+ virtualization.viewedPages,
330
+ (pages) => {
331
+ emit("update:viewedPages", [...pages].sort((a, b) => a - b));
332
+ }
333
+ );
309
334
  function onPageRef(pageNumber, el) {
310
335
  if (el) {
311
336
  virtualization.observePage(pageNumber, el);
@@ -892,7 +917,15 @@ defineExpose({
892
917
  const next = !viewerState.formEditMode.value;
893
918
  viewerState.setFormEditMode(next);
894
919
  return next;
895
- }
920
+ },
921
+ /** True once the user has viewed every page — each page has entered the
922
+ * viewport at least once. Use to gate a "must read all pages" sign action.
923
+ * See also the `all-pages-read` event for a push-style signal. */
924
+ allPagesRead: () => virtualization.allPagesRead.value,
925
+ /** Page numbers viewed so far, ascending. */
926
+ getViewedPages: () => [...virtualization.viewedPages.value].sort((a, b) => a - b),
927
+ /** Clear viewed-page tracking, e.g. to restart a signing session. */
928
+ resetViewedPages: () => virtualization.resetViewedPages()
896
929
  });
897
930
  </script>
898
931
 
@@ -84,12 +84,24 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
84
84
  setFormEditMode: (enabled: boolean) => void;
85
85
  /** Flip form-edit mode. Returns the new state. */
86
86
  toggleFormEditMode: () => boolean;
87
+ /** True once the user has viewed every page — each page has entered the
88
+ * viewport at least once. Use to gate a "must read all pages" sign action.
89
+ * See also the `all-pages-read` event for a push-style signal. */
90
+ allPagesRead: () => boolean;
91
+ /** Page numbers viewed so far, ascending. */
92
+ getViewedPages: () => number[];
93
+ /** Clear viewed-page tracking, e.g. to restart a signing session. */
94
+ resetViewedPages: () => void;
87
95
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
96
+ "all-pages-read": () => any;
88
97
  "update:formEditMode": (value: boolean) => any;
89
98
  "update:activeRoleId": (value: string | null) => any;
99
+ "update:viewedPages": (pages: number[]) => any;
90
100
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
101
+ "onAll-pages-read"?: (() => any) | undefined;
91
102
  "onUpdate:formEditMode"?: ((value: boolean) => any) | undefined;
92
103
  "onUpdate:activeRoleId"?: ((value: string | null) => any) | undefined;
104
+ "onUpdate:viewedPages"?: ((pages: number[]) => any) | undefined;
93
105
  }>, {
94
106
  userName: string;
95
107
  freehandGroupingDelay: number;
@@ -21,6 +21,17 @@ export interface PageVirtualization {
21
21
  pageMetas: ShallowRef<PageMeta[]>;
22
22
  currentPage: Ref<number>;
23
23
  renderedPages: ComputedRef<Set<number>>;
24
+ /** Pages that have entered the viewport at least once since the document
25
+ * loaded (or since the last `resetViewedPages()`). Marked from scroll/pan
26
+ * geometry — not render state — so a fast fling that skips rendering the
27
+ * middle pages still counts them as viewed. */
28
+ viewedPages: ShallowRef<Set<number>>;
29
+ /** True once every page has been viewed. */
30
+ allPagesRead: ComputedRef<boolean>;
31
+ /** Mark a single page as viewed (used by single-page navigation). */
32
+ markPageViewed: (pageNumber: number) => void;
33
+ /** Clear the viewed-pages set, e.g. to start a fresh signing session. */
34
+ resetViewedPages: () => void;
24
35
  isPageRendered: (pageNumber: number) => boolean;
25
36
  observePage: (pageNumber: number, element: HTMLElement) => void;
26
37
  unobservePage: (pageNumber: number) => void;
@@ -5,6 +5,30 @@ export function createPageVirtualization() {
5
5
  const pageMetas = shallowRef([]);
6
6
  const currentPage = ref(1);
7
7
  const visiblePages = shallowRef(/* @__PURE__ */ new Set());
8
+ const viewedPages = shallowRef(/* @__PURE__ */ new Set());
9
+ const allPagesRead = computed(() => {
10
+ const total = pageMetas.value.length;
11
+ return total > 0 && viewedPages.value.size >= total;
12
+ });
13
+ function markPageViewed(pageNumber) {
14
+ if (viewedPages.value.has(pageNumber)) return;
15
+ const next = new Set(viewedPages.value);
16
+ next.add(pageNumber);
17
+ viewedPages.value = next;
18
+ }
19
+ function markPagesViewed(pageNumbers) {
20
+ let next = null;
21
+ for (const n of pageNumbers) {
22
+ if (viewedPages.value.has(n) || next?.has(n)) continue;
23
+ if (!next) next = new Set(viewedPages.value);
24
+ next.add(n);
25
+ }
26
+ if (next) viewedPages.value = next;
27
+ }
28
+ function resetViewedPages() {
29
+ if (viewedPages.value.size === 0) return;
30
+ viewedPages.value = /* @__PURE__ */ new Set();
31
+ }
8
32
  let observer = null;
9
33
  let scrollRoot = null;
10
34
  let scrollListener = null;
@@ -88,12 +112,19 @@ export function createPageVirtualization() {
88
112
  if (bestPage !== currentPage.value) {
89
113
  currentPage.value = bestPage;
90
114
  }
115
+ const viewportBottom = rootTop + scrollRoot.clientHeight;
116
+ const reached = [];
117
+ for (const [pageNum, el] of pageToElement) {
118
+ if (el.offsetTop < viewportBottom) reached.push(pageNum);
119
+ }
120
+ markPagesViewed(reached);
91
121
  }
92
122
  function scrollToPage(pageNumber) {
93
123
  const element = pageToElement.get(pageNumber);
94
124
  if (element && scrollRoot) {
95
125
  element.scrollIntoView({ block: "start", behavior: "instant" });
96
126
  currentPage.value = pageNumber;
127
+ markPageViewed(pageNumber);
97
128
  }
98
129
  }
99
130
  function getPageElement(pageNumber) {
@@ -103,8 +134,10 @@ export function createPageVirtualization() {
103
134
  if (!scrollRoot) return;
104
135
  const rootRect = scrollRoot.getBoundingClientRect();
105
136
  const rootMid = rootRect.top + rootRect.height / 2;
137
+ const rootBottom = rootRect.top + rootRect.height;
106
138
  let bestPage = currentPage.value;
107
139
  let bestDist = Infinity;
140
+ const reached = [];
108
141
  for (const [pageNum, el] of pageToElement) {
109
142
  const rect = el.getBoundingClientRect();
110
143
  const dist = rootMid < rect.top ? rect.top - rootMid : rootMid > rect.bottom ? rootMid - rect.bottom : 0;
@@ -112,7 +145,9 @@ export function createPageVirtualization() {
112
145
  bestDist = dist;
113
146
  bestPage = pageNum;
114
147
  }
148
+ if (rect.top < rootBottom) reached.push(pageNum);
115
149
  }
150
+ markPagesViewed(reached);
116
151
  if (bestPage !== currentPage.value) {
117
152
  currentPage.value = bestPage;
118
153
  }
@@ -221,6 +256,7 @@ export function createPageVirtualization() {
221
256
  elementToPage.clear();
222
257
  pageToElement.clear();
223
258
  visiblePages.value = /* @__PURE__ */ new Set();
259
+ viewedPages.value = /* @__PURE__ */ new Set();
224
260
  pageMetas.value = [];
225
261
  scrollRoot = null;
226
262
  }
@@ -228,6 +264,10 @@ export function createPageVirtualization() {
228
264
  pageMetas,
229
265
  currentPage,
230
266
  renderedPages,
267
+ viewedPages,
268
+ allPagesRead,
269
+ markPageViewed,
270
+ resetViewedPages,
231
271
  isPageRendered,
232
272
  observePage,
233
273
  unobservePage,
@@ -20,7 +20,8 @@ type EventHandler<E extends EventName> = (payload: EventPayloads[E]) => void;
20
20
  * Parent-page client that drives a `<KViewer>` instance running inside
21
21
  * an iframe. Wraps `postMessage` into a Promise-based RPC mirroring the
22
22
  * viewer's exposed methods, and surfaces iframe-emitted events
23
- * (`ready`, `formEditMode-changed`) via `on()`.
23
+ * (`ready`, `formEditMode-changed`, `viewedPages-changed`, `all-pages-read`)
24
+ * via `on()`.
24
25
  */
25
26
  export declare class KViewerEmbedClient {
26
27
  private readonly iframe;
@@ -52,6 +53,13 @@ export declare class KViewerEmbedClient {
52
53
  getFormEditMode(): Promise<boolean>;
53
54
  setFormEditMode(enabled: boolean): Promise<void>;
54
55
  toggleFormEditMode(): Promise<boolean>;
56
+ /** True once the user has viewed every page. For a push-style signal,
57
+ * subscribe via `on('all-pages-read', …)` / `on('viewedPages-changed', …)`. */
58
+ allPagesRead(): Promise<boolean>;
59
+ /** Page numbers viewed so far, ascending. */
60
+ getViewedPages(): Promise<number[]>;
61
+ /** Clear viewed-page tracking, e.g. to restart a signing session. */
62
+ resetViewedPages(): Promise<void>;
55
63
  private call;
56
64
  private handleMessage;
57
65
  }
@@ -87,6 +87,19 @@ export class KViewerEmbedClient {
87
87
  toggleFormEditMode() {
88
88
  return this.call("toggleFormEditMode");
89
89
  }
90
+ /** True once the user has viewed every page. For a push-style signal,
91
+ * subscribe via `on('all-pages-read', …)` / `on('viewedPages-changed', …)`. */
92
+ allPagesRead() {
93
+ return this.call("allPagesRead");
94
+ }
95
+ /** Page numbers viewed so far, ascending. */
96
+ getViewedPages() {
97
+ return this.call("getViewedPages");
98
+ }
99
+ /** Clear viewed-page tracking, e.g. to restart a signing session. */
100
+ resetViewedPages() {
101
+ return this.call("resetViewedPages");
102
+ }
90
103
  // ── internals ──────────────────────────────────────────────────────────
91
104
  call(method, ...args) {
92
105
  if (this.disposed) {
@@ -26,6 +26,9 @@ export interface KViewerApi {
26
26
  formEditMode: Ref<boolean>;
27
27
  setFormEditMode: (enabled: boolean) => void;
28
28
  toggleFormEditMode: () => boolean;
29
+ allPagesRead: () => boolean;
30
+ getViewedPages: () => number[];
31
+ resetViewedPages: () => void;
29
32
  }
30
33
  export interface KViewerEmbedBridgeOptions {
31
34
  /**
@@ -75,6 +75,13 @@ export function useKViewerEmbedBridge(viewerRef, options) {
75
75
  }
76
76
  case "toggleFormEditMode":
77
77
  return viewer.toggleFormEditMode();
78
+ case "allPagesRead":
79
+ return viewer.allPagesRead();
80
+ case "getViewedPages":
81
+ return viewer.getViewedPages();
82
+ case "resetViewedPages":
83
+ viewer.resetViewedPages();
84
+ return void 0;
78
85
  default:
79
86
  throw new Error(`Unknown KViewer embed method: ${req.method}`);
80
87
  }
@@ -110,17 +117,32 @@ export function useKViewerEmbedBridge(viewerRef, options) {
110
117
  target.postMessage(reply, event.origin === "" ? options.parentOrigin : event.origin);
111
118
  }
112
119
  win.addEventListener("message", handleMessage);
113
- let stopFormEditWatch = null;
120
+ let stopViewerStateWatches = [];
114
121
  const stopViewerWatch = watch(
115
122
  viewerRef,
116
123
  (viewer) => {
117
- stopFormEditWatch?.();
118
- stopFormEditWatch = null;
124
+ for (const stop of stopViewerStateWatches) stop();
125
+ stopViewerStateWatches = [];
119
126
  if (!viewer) return;
120
127
  postEvent("ready", { protocolVersion: KVIEWER_EMBED_PROTOCOL_VERSION });
121
- stopFormEditWatch = watch(
122
- viewer.formEditMode,
123
- (enabled) => postEvent("formEditMode-changed", { enabled })
128
+ stopViewerStateWatches.push(
129
+ watch(
130
+ viewer.formEditMode,
131
+ (enabled) => postEvent("formEditMode-changed", { enabled })
132
+ ),
133
+ // Read-tracking. The getters touch the viewer's reactive viewed-pages
134
+ // state, so watch re-runs them whenever a page is viewed or tracking
135
+ // is reset.
136
+ watch(
137
+ () => viewer.allPagesRead(),
138
+ (read, prev) => {
139
+ if (read && !prev) postEvent("all-pages-read", {});
140
+ }
141
+ ),
142
+ watch(
143
+ () => viewer.getViewedPages(),
144
+ (pages) => postEvent("viewedPages-changed", { pages, allRead: viewer.allPagesRead() })
145
+ )
124
146
  );
125
147
  },
126
148
  { immediate: true }
@@ -128,8 +150,8 @@ export function useKViewerEmbedBridge(viewerRef, options) {
128
150
  function unmount() {
129
151
  win.removeEventListener("message", handleMessage);
130
152
  stopViewerWatch();
131
- stopFormEditWatch?.();
132
- stopFormEditWatch = null;
153
+ for (const stop of stopViewerStateWatches) stop();
154
+ stopViewerStateWatches = [];
133
155
  }
134
156
  onScopeDispose(unmount);
135
157
  return { unmount };
@@ -59,6 +59,18 @@ export interface MethodSignatures {
59
59
  args: [];
60
60
  result: boolean;
61
61
  };
62
+ allPagesRead: {
63
+ args: [];
64
+ result: boolean;
65
+ };
66
+ getViewedPages: {
67
+ args: [];
68
+ result: number[];
69
+ };
70
+ resetViewedPages: {
71
+ args: [];
72
+ result: undefined;
73
+ };
62
74
  }
63
75
  export type MethodName = keyof MethodSignatures;
64
76
  export interface RequestMessage<M extends MethodName = MethodName> {
@@ -91,6 +103,15 @@ export interface EventPayloads {
91
103
  'formEditMode-changed': {
92
104
  enabled: boolean;
93
105
  };
106
+ /** The set of viewed pages changed. `allRead` is true once every page has
107
+ * been viewed. */
108
+ 'viewedPages-changed': {
109
+ pages: number[];
110
+ allRead: boolean;
111
+ };
112
+ /** Fires once when the final unseen page is viewed. Re-arms after
113
+ * `resetViewedPages()` or a new document. */
114
+ 'all-pages-read': Record<string, never>;
94
115
  }
95
116
  export type EventName = keyof EventPayloads;
96
117
  export interface EventMessage<E extends EventName = EventName> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kviewer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Kabema PDF Editor",
5
5
  "repository": "kabema/kviewer",
6
6
  "license": "MIT",