@inkbar/svelte 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +13 -0
  2. package/dist/components/InkFindBar.svelte +213 -0
  3. package/dist/components/InkFindBar.svelte.d.ts +6 -0
  4. package/dist/components/InkFindBar.svelte.d.ts.map +1 -0
  5. package/dist/components/InkFormAlert.svelte +108 -0
  6. package/dist/components/InkFormAlert.svelte.d.ts +13 -0
  7. package/dist/components/InkFormAlert.svelte.d.ts.map +1 -0
  8. package/dist/components/InkRoot.svelte +136 -4
  9. package/dist/components/InkRoot.svelte.d.ts +22 -2
  10. package/dist/components/InkRoot.svelte.d.ts.map +1 -1
  11. package/dist/components/InkSearchButton.svelte +56 -0
  12. package/dist/components/InkSearchButton.svelte.d.ts +5 -0
  13. package/dist/components/InkSearchButton.svelte.d.ts.map +1 -0
  14. package/dist/components/InkSignFieldPopover.svelte +187 -0
  15. package/dist/components/InkSignFieldPopover.svelte.d.ts +10 -0
  16. package/dist/components/InkSignFieldPopover.svelte.d.ts.map +1 -0
  17. package/dist/components/InkSignatureDialog.svelte +11 -3
  18. package/dist/components/InkSignatureDialog.svelte.d.ts +9 -3
  19. package/dist/components/InkSignatureDialog.svelte.d.ts.map +1 -1
  20. package/dist/components/InkSignatureMenu.svelte +10 -99
  21. package/dist/components/InkSignatureMenu.svelte.d.ts.map +1 -1
  22. package/dist/components/InkViewport.svelte +46 -1
  23. package/dist/components/InkViewport.svelte.d.ts.map +1 -1
  24. package/dist/components/SignatureForm.svelte +7 -2
  25. package/dist/components/SignatureForm.svelte.d.ts +2 -0
  26. package/dist/components/SignatureForm.svelte.d.ts.map +1 -1
  27. package/dist/components/SignatureMenuPanel.svelte +127 -0
  28. package/dist/components/SignatureMenuPanel.svelte.d.ts +13 -0
  29. package/dist/components/SignatureMenuPanel.svelte.d.ts.map +1 -0
  30. package/dist/components/search-button.d.ts +10 -0
  31. package/dist/components/search-button.d.ts.map +1 -0
  32. package/dist/components/search-button.js +1 -0
  33. package/dist/context.d.ts +14 -2
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/hooks/useForm.svelte.d.ts +17 -0
  36. package/dist/hooks/useForm.svelte.d.ts.map +1 -0
  37. package/dist/hooks/useForm.svelte.js +20 -0
  38. package/dist/hooks/useSearch.svelte.d.ts +11 -0
  39. package/dist/hooks/useSearch.svelte.d.ts.map +1 -0
  40. package/dist/hooks/useSearch.svelte.js +31 -0
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -0
  44. package/package.json +6 -5
package/README.md CHANGED
@@ -145,6 +145,12 @@ Inside `InkRoot`, `useHistory()` returns the same object. Its `batch(fn)` runs `
145
145
 
146
146
  `key` identifies the document. Reuse the same key when only the signed URL changes and the document is not reloaded.
147
147
 
148
+ ## Forms
149
+
150
+ `InkViewport` fills the PDF's form fields and runs the form's scripts. `useForm()` returns the fields, their values, and `set` and `reset`. Use `bind:fields` to hold the values in your own state. The form never submits, opens a link, sends mail or prints: `onformaction` hands each request to you. `formHighlight={false}` turns off the field tint, and a change applies to the open document in place.
151
+
152
+ The [forms guide](https://inkbar.dev/docs/guides/forms/) covers controlled fields, form actions, scripts, signature fields and saving values as Gloss.
153
+
148
154
  ## Thread popover
149
155
 
150
156
  `InkThreadPopover` opens the comment thread of the activated note or text markup (a highlight, underline, strikeout or squiggly). A new note or highlight has no text yet, so the form first writes its own `contents` (labelled "Comment"); after that it adds replies. A highlight with a comment shows a small marker at its end. Replies show in the thread only; the page draws just the note or highlight they answer.
@@ -175,6 +181,12 @@ A plugin from `definePlugin` (in `@inkbar/core/dom`) adds commands, tools, decor
175
181
 
176
182
  Plugin stores undo with annotations. In controlled mode with `history`, each undo step also snapshots the plugin stores, so accepting something and undoing it restores both.
177
183
 
184
+ ## Search
185
+
186
+ `InkFindBar` is the find bar and `InkSearchButton` opens it. Place the bar anywhere inside `InkRoot`, once `InkViewport` is there; Ctrl/Cmd+F opens it while it is mounted. `useSearch()` returns the search `state` and its actions (`open`, `setQuery`, `setMatchCase`, `next`, `previous`, `close`) for your own control. See the [`@inkbar/core` README](https://github.com/inkbar/inkbar/tree/main/packages/core#search) for the API.
187
+
188
+ The bar handles Escape itself and stops it there, so an `onkeydown` you pass to `InkFindBar` never sees Escape.
189
+
178
190
  ## Custom rendering
179
191
 
180
192
  Slots are snippets. `InkViewport` takes `loading`, `empty`, `error`, `annotation` and `pageError`. `InkThreadPopover` takes `children` as a snippet that receives the popover's props. `InkToolButton` takes `children` as a snippet of `{ active, label }`, and `InkCommand` one of `{ label, busy }`. The props each receives are typed as `AnnotationSlotProps` and `ThreadPopoverSlotProps`, the same types the Vue and React packages export.
@@ -196,6 +208,7 @@ The scroll area takes focus. From there:
196
208
  - With the `highlight` or `redact-area` tool active, `Enter` turns the text selection into a mark.
197
209
  - With the `note` tool active, `Enter` places a note at the centre of the current page and opens it.
198
210
  - With the `signature` tool active, `Enter` places the chosen signature at the centre of the current page.
211
+ - `Ctrl` or `Cmd` + `Z` undoes. `Ctrl` or `Cmd` + `Shift` + `Z`, or `Ctrl` + `Y`, redoes. `Ctrl` and `Cmd` both work on every platform. The keys do what `InkUndo` and `InkRedo` do, from anywhere in `InkRoot`, toolbars included, and act only while that button is enabled. In a text field or editable content the keys stay with the element, so a field keeps its own text undo. In a `<select>` they are left to the browser, which does nothing with them.
199
212
 
200
213
  On a focused annotation:
201
214
 
@@ -0,0 +1,213 @@
1
+ <script module lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements'
3
+
4
+ export type InkFindBarProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'>
5
+ </script>
6
+
7
+ <script lang="ts">
8
+ /*
9
+ * The find bar: a field, the match count, previous and next, a match-case toggle and close,
10
+ * a view of the root's search state. Place it anywhere inside `InkRoot`: it portals itself
11
+ * into `[data-ink-viewport-root]`, the positioned ancestor the theme floats it from over the
12
+ * top-right of the pages (as `InkThreadPopover` anchors to the root's overlay rather than
13
+ * where it is written). It renders nothing until `InkViewport` has mounted, and is hidden
14
+ * while the search is closed. While it is mounted it registers with the search controller,
15
+ * which is what lets Ctrl/Cmd+F open it.
16
+ *
17
+ * Enter in the field steps to the next match, Shift+Enter to the previous, Escape closes.
18
+ * Closing returns focus to where it was before the bar opened, else to the document.
19
+ */
20
+ import { findAnnouncer, findCountText } from '@inkbar/core/dom'
21
+ import { CaseSensitive, ChevronDown, ChevronUp, X } from '@inkbar/icons'
22
+ import { untrack } from 'svelte'
23
+
24
+ import { useInkContext } from '../context.js'
25
+ import { useSearch } from '../hooks/useSearch.svelte.js'
26
+ import { useReadable } from '../use-readable.svelte.js'
27
+
28
+ import InkIcon from './InkIcon.svelte'
29
+
30
+ import type { SearchState } from '@inkbar/core/dom'
31
+
32
+ let { class: className, ...rest }: InkFindBarProps = $props()
33
+
34
+ const ctx = useInkContext('InkFindBar')
35
+ const search = useSearch()
36
+ const scroll = useReadable(() => ctx.viewportEl, null)
37
+ /** The viewport root: the scroll element's parent, once InkViewport has mounted. */
38
+ const target = $derived(scroll.current?.parentElement ?? null)
39
+
40
+ let bar = $state<HTMLDivElement | null>(null)
41
+ let field = $state<HTMLInputElement | null>(null)
42
+ let live = $state<HTMLDivElement | null>(null)
43
+ /** Where focus was when the bar opened, and whether it was open on the last pass. */
44
+ let before: HTMLElement | null = null
45
+ let wasOpen = false
46
+
47
+ const open = $derived(search.state.open)
48
+ const query = $derived(search.state.query)
49
+
50
+ // After the update, so the bar is shown (and can take focus) when it opens.
51
+ $effect(() => {
52
+ const el = bar
53
+ const input = field
54
+ const isOpen = open
55
+ const text = query
56
+ if (!el || !input) return
57
+ untrack(() => {
58
+ if (isOpen !== wasOpen) {
59
+ wasOpen = isOpen
60
+ const doc = el.ownerDocument
61
+ if (isOpen) {
62
+ const active = doc.activeElement
63
+ before =
64
+ active instanceof HTMLElement && active !== doc.body && !el.contains(active)
65
+ ? active
66
+ : null
67
+ input.focus()
68
+ input.select()
69
+ } else {
70
+ // Focus inside the bar is about to be lost with it; focus elsewhere is left alone.
71
+ const hadFocus =
72
+ el.contains(doc.activeElement) || doc.activeElement === doc.body
73
+ if (hadFocus) {
74
+ const to =
75
+ before?.isConnected && !el.contains(before) ? before : scroll.current
76
+ to?.focus({ preventScroll: true })
77
+ }
78
+ before = null
79
+ }
80
+ }
81
+ if (input.value !== text) {
82
+ input.value = text
83
+ // A query set from code arrives after the bar opened: keep the new text selected.
84
+ if (el.ownerDocument.activeElement === input) input.select()
85
+ }
86
+ })
87
+ })
88
+
89
+ // Registered while mounted.
90
+ $effect(() => {
91
+ const controller = ctx.search
92
+ if (!controller || !target) return
93
+ return controller.attachBar()
94
+ })
95
+
96
+ // The live region follows the controller's state change by change, as the vanilla bar does,
97
+ // and is written directly. An update shows only the last of the changes batched into it, so
98
+ // two steps in one task that end where they began would otherwise say nothing.
99
+ $effect(() => {
100
+ const controller = ctx.search
101
+ const el = live
102
+ if (!controller || !el) return
103
+ return untrack(() => {
104
+ const announce = findAnnouncer()
105
+ const say = (s: SearchState): void => {
106
+ const text = announce(s)
107
+ if (el.textContent !== text) el.textContent = text
108
+ }
109
+ say(controller.state.get())
110
+ return controller.state.subscribe(say)
111
+ })
112
+ })
113
+
114
+ // A native listener, so Escape stops at the bar as it does in the vanilla and Vue bars;
115
+ // Svelte's delegated events would stop it only after the root's own listeners saw it.
116
+ $effect(() => {
117
+ const controller = ctx.search
118
+ const el = bar
119
+ if (!controller || !el) return
120
+ const onKeydown = (e: KeyboardEvent): void => {
121
+ if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return
122
+ e.preventDefault()
123
+ e.stopPropagation()
124
+ controller.close()
125
+ }
126
+ el.addEventListener('keydown', onKeydown)
127
+ return () => el.removeEventListener('keydown', onKeydown)
128
+ })
129
+
130
+ /**
131
+ * Moves the bar itself into the viewport root, leaving nothing where it is written. Svelte
132
+ * removes it when its own `{#if}` branch goes, but not when an ancestor's block goes and
133
+ * takes only the nodes still in place, so the cleanup removes it too. A keyed `{#each}` that
134
+ * reorders would move the bar back to where it is written, so keep `InkFindBar` out of one.
135
+ */
136
+ const into = (to: Element) => (el: HTMLElement) => {
137
+ to.appendChild(el)
138
+ return () => el.remove()
139
+ }
140
+ </script>
141
+
142
+ {#if target}
143
+ <div
144
+ {...rest}
145
+ bind:this={bar}
146
+ class={['ink-find-bar', className]}
147
+ data-ink-find-bar=""
148
+ role="search"
149
+ hidden={!open}
150
+ {@attach into(target)}
151
+ >
152
+ <input
153
+ bind:this={field}
154
+ type="text"
155
+ aria-label="Find in document"
156
+ autocomplete="off"
157
+ spellcheck={false}
158
+ oninput={(e) => search.setQuery(e.currentTarget.value)}
159
+ onkeydown={(e) => {
160
+ if (e.key !== 'Enter' || e.isComposing) return
161
+ e.preventDefault()
162
+ if (e.shiftKey) search.previous()
163
+ else search.next()
164
+ }}
165
+ />
166
+ <span class="ink-find-count" data-ink-find-count=""
167
+ >{findCountText(search.state)}</span
168
+ >
169
+ <button
170
+ type="button"
171
+ data-ink-find-previous=""
172
+ aria-label="Previous match"
173
+ title="Previous match"
174
+ onclick={() => search.previous()}
175
+ >
176
+ <InkIcon icon={ChevronUp} />
177
+ </button>
178
+ <button
179
+ type="button"
180
+ data-ink-find-next=""
181
+ aria-label="Next match"
182
+ title="Next match"
183
+ onclick={() => search.next()}
184
+ >
185
+ <InkIcon icon={ChevronDown} />
186
+ </button>
187
+ <button
188
+ type="button"
189
+ data-ink-find-match-case=""
190
+ aria-label="Match case"
191
+ title="Match case"
192
+ aria-pressed={search.state.matchCase}
193
+ onclick={() => search.setMatchCase(!search.state.matchCase)}
194
+ >
195
+ <InkIcon icon={CaseSensitive} />
196
+ </button>
197
+ <button
198
+ type="button"
199
+ data-ink-find-close=""
200
+ aria-label="Close find"
201
+ title="Close find"
202
+ onclick={() => search.close()}
203
+ >
204
+ <InkIcon icon={X} />
205
+ </button>
206
+ <div
207
+ bind:this={live}
208
+ class="ink-visually-hidden"
209
+ data-ink-find-status=""
210
+ aria-live="polite"
211
+ ></div>
212
+ </div>
213
+ {/if}
@@ -0,0 +1,6 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ export type InkFindBarProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'>;
3
+ declare const InkFindBar: import("svelte").Component<InkFindBarProps, {}, "">;
4
+ type InkFindBar = ReturnType<typeof InkFindBar>;
5
+ export default InkFindBar;
6
+ //# sourceMappingURL=InkFindBar.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InkFindBar.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/InkFindBar.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAErD,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,UAAU,CAAC,CAC/E;AAmLD,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ /*
3
+ * A form script's `app.alert`: the message and OK, shown without blocking (no overlay, not
4
+ * modal), so the form stays usable around it. InkViewport mounts it; hosts compose nothing.
5
+ * OK, or Escape while focus is in the alert, closes it, and focus goes back where it was. A
6
+ * press outside leaves it open, and Escape anywhere else stays with the viewer. A new message
7
+ * replaces the one shown.
8
+ *
9
+ * The markup is core's `createFormAlert`, portalled to `<body>`. It is not a Bits UI dialog:
10
+ * Bits UI's escape layer takes every Escape on the page while it is open (it prevents the
11
+ * key's default), which would end the viewer's own use of the key.
12
+ */
13
+ import { FORM_LABELS } from '@inkbar/core/dom'
14
+ import { Portal } from 'bits-ui'
15
+ import { untrack } from 'svelte'
16
+
17
+ let {
18
+ message,
19
+ shown,
20
+ fallback,
21
+ onClose,
22
+ }: {
23
+ /** The message to show (text, not markup); `null` while none is. */
24
+ message: string | null
25
+ /** Counts the times a message was shown, so the same text shown again takes focus again. */
26
+ shown: number
27
+ /** Where focus goes when nothing it can return to is left. */
28
+ fallback: () => HTMLElement | null
29
+ onClose: () => void
30
+ } = $props()
31
+
32
+ const uid = $props.id()
33
+ let panel = $state<HTMLElement | null>(null)
34
+ let ok = $state<HTMLButtonElement | null>(null)
35
+ /** Where focus was when the alert opened. */
36
+ let returnTo: HTMLElement | null = null
37
+ let wasOpen = false
38
+ const open = $derived(message !== null)
39
+
40
+ // After the alert renders, before anything else moves focus: where focus was is read first.
41
+ $effect(() => {
42
+ const showing = open
43
+ void shown
44
+ const button = ok
45
+ if (!showing) {
46
+ wasOpen = false
47
+ return
48
+ }
49
+ if (!button) return
50
+ untrack(() => {
51
+ if (!wasOpen) {
52
+ wasOpen = true
53
+ const active = document.activeElement
54
+ returnTo =
55
+ active instanceof HTMLElement && active !== document.body ? active : null
56
+ }
57
+ button.focus({ preventScroll: true })
58
+ })
59
+ })
60
+
61
+ function close(): void {
62
+ // Focus that moved elsewhere while the alert showed stays there.
63
+ const hadFocus = !!panel?.contains(document.activeElement)
64
+ const back = returnTo?.isConnected === true ? returnTo : fallback()
65
+ onClose()
66
+ if (hadFocus) back?.focus({ preventScroll: true })
67
+ }
68
+
69
+ function onkeydown(e: KeyboardEvent): void {
70
+ if (e.key !== 'Escape' || e.defaultPrevented) return
71
+ e.preventDefault()
72
+ e.stopPropagation()
73
+ close()
74
+ }
75
+ </script>
76
+
77
+ {#if message !== null}
78
+ <Portal>
79
+ <div
80
+ bind:this={panel}
81
+ role="alertdialog"
82
+ aria-labelledby="{uid}-title"
83
+ aria-describedby="{uid}-message"
84
+ class="ink-dialog ink-form-alert"
85
+ data-ink-form-alert=""
86
+ data-state="open"
87
+ tabindex="-1"
88
+ {onkeydown}
89
+ >
90
+ <h2 id="{uid}-title" class="ink-dialog-title">{FORM_LABELS.alert}</h2>
91
+ <p id="{uid}-message" class="ink-form-alert-message" data-ink-form-alert-message="">
92
+ {message}
93
+ </p>
94
+ <div class="ink-dialog-actions">
95
+ <button
96
+ bind:this={ok}
97
+ type="button"
98
+ class="ink-button"
99
+ data-variant="primary"
100
+ data-ink-form-alert-ok=""
101
+ onclick={close}
102
+ >
103
+ {FORM_LABELS.ok}
104
+ </button>
105
+ </div>
106
+ </div>
107
+ </Portal>
108
+ {/if}
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ /** The message to show (text, not markup); `null` while none is. */
3
+ message: string | null;
4
+ /** Counts the times a message was shown, so the same text shown again takes focus again. */
5
+ shown: number;
6
+ /** Where focus goes when nothing it can return to is left. */
7
+ fallback: () => HTMLElement | null;
8
+ onClose: () => void;
9
+ };
10
+ declare const InkFormAlert: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type InkFormAlert = ReturnType<typeof InkFormAlert>;
12
+ export default InkFormAlert;
13
+ //# sourceMappingURL=InkFormAlert.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InkFormAlert.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/InkFormAlert.svelte.ts"],"names":[],"mappings":"AAkBC,KAAK,gBAAgB,GAAI;IACtB,oEAAoE;IACpE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,4FAA4F;IAC5F,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,WAAW,GAAG,IAAI,CAAA;IAClC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAC;AA6EJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -5,6 +5,9 @@
5
5
  AnnotationChange,
6
6
  AnnotationDefaults,
7
7
  DocumentSource,
8
+ FieldsChange,
9
+ FieldValues,
10
+ FormAction,
8
11
  InkDocument,
9
12
  InkError,
10
13
  InkStatus,
@@ -38,6 +41,21 @@
38
41
  annotations?: Annotation[] | undefined
39
42
  onAnnotationsChange?: ((next: Annotation[]) => void) | undefined
40
43
  onChange?: ((change: AnnotationChange) => void) | undefined
44
+ /**
45
+ * Pass (with `bind:`) to put the form's field values under the host's control. A commit
46
+ * shows at once and is reported after; the host's push wins. The mode is fixed when a
47
+ * document opens, as for `annotations`.
48
+ */
49
+ fields?: FieldValues | undefined
50
+ /**
51
+ * Committed field values, in full, with what changed them. Called only while the fields
52
+ * are controlled, after the bound value is written.
53
+ */
54
+ onFieldsChange?: ((next: FieldValues, change: FieldsChange) => void) | undefined
55
+ /** The form asked to submit, open a link, send mail, print or prompt. Inkbar does none. */
56
+ onformaction?: ((action: FormAction) => void) | undefined
57
+ /** Tints fillable form fields. Default true. A change applies to the open document in place. */
58
+ formHighlight?: boolean | undefined
41
59
  tool?: ToolId | undefined
42
60
  onToolChange?: ((tool: ToolId) => void) | undefined
43
61
  /**
@@ -78,6 +96,11 @@
78
96
  /** Undo history. Defaults to on for uncontrolled, off for controlled. */
79
97
  history?: boolean | undefined
80
98
  onReady?: ((doc: InkDocument) => void) | undefined
99
+ /**
100
+ * Opening failed, or later: a form script failed (`FormScriptError`; the form stays
101
+ * fillable), or a signature field refused its signing (`FieldSigned`, `FieldReadOnly`).
102
+ * An error from while the document opened comes after `onReady`.
103
+ */
81
104
  onError?: ((error: InkError) => void) | undefined
82
105
  onActivate?: ((id: string) => void) | undefined
83
106
  /** The root element, for hosts that bind it. */
@@ -102,12 +125,18 @@
102
125
  writable,
103
126
  } from '@inkbar/core'
104
127
  import {
128
+ attachHistoryKeys,
105
129
  createPluginHost,
130
+ createSearchController,
106
131
  createToolOptions,
107
132
  cssVariableDefaults,
108
133
  FONT_FAMILIES,
134
+ handleFindKey,
135
+ keepHistoryFocus,
136
+ pageSelectionText,
109
137
  SIGNATURE_FONTS,
110
138
  trackToolStyle,
139
+ viewerSearchOf,
111
140
  } from '@inkbar/core/dom'
112
141
  import { DEV } from 'esm-env'
113
142
  import { untrack } from 'svelte'
@@ -117,7 +146,12 @@
117
146
  import { useReadable } from '../use-readable.svelte.js'
118
147
 
119
148
  import type { PluginHistory } from '@inkbar/core'
120
- import type { PluginHost, ToolController, Viewport } from '@inkbar/core/dom'
149
+ import type {
150
+ PluginHost,
151
+ SearchController,
152
+ ToolController,
153
+ Viewport,
154
+ } from '@inkbar/core/dom'
121
155
 
122
156
  let {
123
157
  src,
@@ -127,6 +161,10 @@
127
161
  annotations = $bindable(),
128
162
  onAnnotationsChange,
129
163
  onChange,
164
+ fields = $bindable(),
165
+ onFieldsChange,
166
+ onformaction,
167
+ formHighlight,
130
168
  tool = $bindable(),
131
169
  onToolChange,
132
170
  tools: hostTools,
@@ -228,6 +266,7 @@
228
266
  let opened: InkDocument | null = null
229
267
  /** The snapshot history's hold on a controlled document's store. */
230
268
  let detach: () => void = noop
269
+ let stopFormErrors: () => void = noop
231
270
  untrack(() => snapshotHistory.clear())
232
271
  const source = untrack(() => src)
233
272
  if (source === null) {
@@ -260,6 +299,20 @@
260
299
  },
261
300
  }
262
301
  : { mode: 'uncontrolled' as const },
302
+ fields:
303
+ fields !== undefined
304
+ ? {
305
+ mode: 'controlled' as const,
306
+ initial: fields,
307
+ onChange: (next: FieldValues, change: FieldsChange) => {
308
+ // As for annotations: a commit made in the tick this root unmounts or
309
+ // reopens never reaches the host.
310
+ if (cancelled) return
311
+ fields = next
312
+ onFieldsChange?.(next, change)
313
+ },
314
+ }
315
+ : { mode: 'uncontrolled' as const },
263
316
  }))
264
317
  )
265
318
  if (cancelled) {
@@ -271,6 +324,17 @@
271
324
  const current = untrack(() => annotations)
272
325
  if (doc.annotations.mode === 'controlled' && current !== undefined)
273
326
  doc.annotations.setControlledValue(current)
327
+ // The same for the fields; a document whose fields opened uncontrolled ignores the push.
328
+ const currentFields = untrack(() => fields)
329
+ if (currentFields !== undefined) doc.form.setControlledValue(currentFields)
330
+ // A form script that fails leaves the form fillable; the failure goes to `onError`.
331
+ // Subscribed before anyone hears of the document, so the form replays the errors from
332
+ // while it opened here; they are held until `onReady` has run.
333
+ let held: InkError[] | undefined = []
334
+ stopFormErrors = doc.form.on('error', (e) => {
335
+ if (held) held.push(e)
336
+ else onError?.(e)
337
+ })
274
338
  documentNow = doc
275
339
  document = doc
276
340
  // Controlled edits run the plugins' reactions inside their own snapshot step.
@@ -281,8 +345,15 @@
281
345
  override = null
282
346
  setTestHook(ref, doc)
283
347
  const s = doc.status.get()
284
- if (s.state === 'ready') onReady?.(doc)
285
- else if (s.state === 'error') onError?.(s.error)
348
+ try {
349
+ if (s.state === 'ready') onReady?.(doc)
350
+ else if (s.state === 'error') onError?.(s.error)
351
+ } finally {
352
+ // A host whose `onReady` throws still hears the held errors and every later one.
353
+ const early = held
354
+ held = undefined
355
+ if (!cancelled) for (const e of early) onError?.(e)
356
+ }
286
357
  } catch (cause) {
287
358
  if (cancelled) return
288
359
  const error =
@@ -303,6 +374,7 @@
303
374
  // Cleared first, so the plugins close their stores while the document is still alive.
304
375
  hostDocument.set(null)
305
376
  detach()
377
+ stopFormErrors()
306
378
  opened?.destroy()
307
379
  documentNow = null
308
380
  document = null
@@ -318,6 +390,15 @@
318
390
  untrack(() => doc.annotations.setControlledValue(list))
319
391
  })
320
392
 
393
+ // The host's field values reach the form; the echo of a commit it applied changes nothing.
394
+ // Read through a derived, so a host that spreads its props pushes only a new value.
395
+ const pushedFields = $derived(fields)
396
+ $effect(() => {
397
+ const values = pushedFields
398
+ const doc = document
399
+ if (values && doc) untrack(() => doc.form.setControlledValue(values))
400
+ })
401
+
321
402
  function setTool(id: ToolId): void {
322
403
  // Picking the active tool again starts it afresh, as after a one-shot tool's use.
323
404
  if (currentTool === id && tools?.active.get() === id) tools.set(id)
@@ -362,6 +443,21 @@
362
443
  react: (fn) => pluginHistory.react(fn),
363
444
  }
364
445
 
446
+ // The undo and redo keys anywhere in the root do what InkUndo and InkRedo do. When either
447
+ // button undoes or redoes the last step and disables itself, focus goes back to the document.
448
+ $effect(() => {
449
+ const el = ref
450
+ if (!el) return
451
+ const stopKeys = attachHistoryKeys(el, () => inkHistory)
452
+ const stopFocus = keepHistoryFocus(el, () =>
453
+ el.querySelector<HTMLElement>('[data-ink-scroll]')
454
+ )
455
+ return () => {
456
+ stopKeys()
457
+ stopFocus()
458
+ }
459
+ })
460
+
365
461
  /**
366
462
  * The history the plugins reach, picked by the open document's mode when they call. The host
367
463
  * reads it as soon as a document opens, so it goes by `documentNow`, which is set by then.
@@ -393,7 +489,15 @@
393
489
  const hostActivated = writable<string | null>(null)
394
490
  const viewportEl = writable<HTMLElement | null>(null)
395
491
  let pluginHost = $state.raw<PluginHost | null>(null)
492
+ /** One search controller for the root's life, built with the plugin host that shares it. */
493
+ let search = $state.raw<SearchController | null>(null)
396
494
  $effect(() => {
495
+ const controller = untrack(() =>
496
+ createSearchController({
497
+ document: readonly(hostDocument),
498
+ viewport: () => viewport,
499
+ })
500
+ )
397
501
  const host = untrack(() =>
398
502
  createPluginHost({
399
503
  // Read once, as documented on the prop.
@@ -408,13 +512,33 @@
408
512
  root: readonly(viewportEl),
409
513
  decorate: () => decorateAnnotation,
410
514
  viewport: () => viewport,
515
+ search: viewerSearchOf(controller),
411
516
  })
412
517
  )
518
+ search = controller
413
519
  pluginHost = host
414
520
  // Destroyed after InkViewport's engine and its plugin layers, as in `createViewer`: Svelte
415
521
  // creates a component's effects after its template and tears them down in creation order,
416
522
  // so the children's teardowns run first.
417
- return () => host.destroy()
523
+ return () => {
524
+ host.destroy()
525
+ controller.destroy()
526
+ }
527
+ })
528
+
529
+ /*
530
+ * Ctrl/Cmd+F while focus is in the root and a find bar is mounted; else the browser's. A
531
+ * native listener: the handler reads the event's `currentTarget`, which is the root here.
532
+ */
533
+ $effect(() => {
534
+ const el = ref
535
+ const controller = search
536
+ if (!el || !controller) return
537
+ const onKeydown = (e: KeyboardEvent): void => {
538
+ handleFindKey(e, controller, () => pageSelectionText(el))
539
+ }
540
+ el.addEventListener('keydown', onKeydown)
541
+ return () => el.removeEventListener('keydown', onKeydown)
418
542
  })
419
543
 
420
544
  /** The document's tool style; it batches through `inkHistory`, whichever mode is on. */
@@ -455,6 +579,9 @@
455
579
  get pluginHost() {
456
580
  return pluginHost
457
581
  },
582
+ get search() {
583
+ return search
584
+ },
458
585
  viewportEl,
459
586
  get viewport() {
460
587
  return viewport
@@ -505,6 +632,11 @@
505
632
  viewport = vp
506
633
  tools = tc
507
634
  },
635
+ get formHighlight() {
636
+ return formHighlight
637
+ },
638
+ reportFormAction: (a) => onformaction?.(a),
639
+ reportError: (e) => onError?.(e),
508
640
  }
509
641
  setInkContext(context, () => activatedNow)
510
642