storyloco 2.9.0 → 2.11.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 (54) hide show
  1. package/README.md +125 -0
  2. package/package.json +54 -10
  3. package/packages/churchsuite_categories/types.ts +9 -0
  4. package/packages/shared/dist/components/ui/button/button.svelte.d.ts +33 -33
  5. package/packages/shared/dist/components/ui/select/select-group.svelte +1 -1
  6. package/packages/shared/dist/components/ui/textarea/textarea.svelte +1 -2
  7. package/packages/vite/dist/editor/anchor.d.ts +23 -0
  8. package/packages/vite/dist/editor/anchor.js +92 -0
  9. package/packages/vite/dist/editor/block_bar.svelte +624 -0
  10. package/packages/vite/dist/editor/block_bar.svelte.d.ts +36 -0
  11. package/packages/vite/dist/editor/client.d.ts +202 -0
  12. package/packages/vite/dist/editor/client.js +473 -0
  13. package/packages/vite/dist/editor/comments.svelte +1386 -0
  14. package/packages/vite/dist/editor/comments.svelte.d.ts +27 -0
  15. package/packages/vite/dist/editor/compare.svelte +716 -0
  16. package/packages/vite/dist/editor/compare.svelte.d.ts +11 -0
  17. package/packages/vite/dist/editor/content.d.ts +85 -0
  18. package/packages/vite/dist/editor/content.js +360 -0
  19. package/packages/vite/dist/editor/edit_bar.svelte +1712 -0
  20. package/packages/vite/dist/editor/edit_bar.svelte.d.ts +29 -0
  21. package/packages/vite/dist/editor/face.svelte +69 -0
  22. package/packages/vite/dist/editor/face.svelte.d.ts +15 -0
  23. package/packages/vite/dist/editor/image_picker.svelte +645 -0
  24. package/packages/vite/dist/editor/image_picker.svelte.d.ts +36 -0
  25. package/packages/vite/dist/editor/index.d.ts +2 -0
  26. package/packages/vite/dist/editor/index.js +2 -0
  27. package/packages/vite/dist/editor/inspector.svelte +2650 -0
  28. package/packages/vite/dist/editor/inspector.svelte.d.ts +85 -0
  29. package/packages/vite/dist/editor/live.d.ts +105 -0
  30. package/packages/vite/dist/editor/live.js +419 -0
  31. package/packages/vite/dist/editor/overlay.d.ts +81 -0
  32. package/packages/vite/dist/editor/overlay.js +1369 -0
  33. package/packages/vite/dist/editor/presence.d.ts +14 -0
  34. package/packages/vite/dist/editor/presence.js +399 -0
  35. package/packages/vite/dist/editor/richtext.d.ts +93 -0
  36. package/packages/vite/dist/editor/richtext.js +1147 -0
  37. package/packages/vite/dist/editor/server.d.ts +139 -0
  38. package/packages/vite/dist/editor/server.js +1320 -0
  39. package/packages/vite/dist/editor/time_machine.svelte +1235 -0
  40. package/packages/vite/dist/editor/time_machine.svelte.d.ts +12 -0
  41. package/packages/vite/dist/editor/together.d.ts +34 -0
  42. package/packages/vite/dist/editor/together.js +106 -0
  43. package/packages/vite/dist/editor/tools.svelte +2323 -0
  44. package/packages/vite/dist/editor/tools.svelte.d.ts +23 -0
  45. package/packages/vite/dist/hooks.d.ts +19 -3
  46. package/packages/vite/dist/hooks.js +24 -4
  47. package/packages/vite/dist/kit.d.ts +22 -0
  48. package/packages/vite/dist/kit.js +1 -0
  49. package/packages/vite/dist/middleware.d.ts +37 -0
  50. package/packages/vite/dist/middleware.js +43 -0
  51. package/packages/vite/dist/redirects/handle.d.ts +1 -1
  52. package/packages/vite/dist/server.d.ts +4 -4
  53. package/packages/vite/dist/storyblok.svelte.d.ts +10 -4
  54. package/packages/vite/dist/storyblok.svelte.js +45 -15
package/README.md CHANGED
@@ -17,6 +17,7 @@ A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, a
17
17
  - **⏰ Time** - Simple time input field with step control
18
18
  - **💷 Currency** - Amount input with a currency selector; configure allowed codes via the `currencies` and `default_currency` options
19
19
  - **📅 Dates** - List of dates, each with an optional time, end date and end time, plus a timezone picker; configure via the `min`, `max` and `auto_sort` options
20
+ - **⛪ ChurchSuite Categories** - Multi-select of ChurchSuite calendar categories, read from your site's endpoint via the `endpoint` option
20
21
 
21
22
  ### Vite Plugins
22
23
 
@@ -114,6 +115,19 @@ API methods await it, so you never call it on the server. `components` takes the
114
115
  `import.meta.glob`: Vite has to see the literal pattern to rewrite it, so the glob has to stay
115
116
  at your call site rather than move into the client.
116
117
 
118
+ `api` goes to the SDK as its `apiOptions`. To keep the token off the browser, read through a
119
+ proxy of your own that adds it, and give the client a stand-in:
120
+
121
+ ```typescript
122
+ export const client = new StoryblokClient({
123
+ token: 'proxy',
124
+ // the SDK wants a whole URL
125
+ api: { endpoint: `${browser ? location.origin : 'http://localhost'}/api/storyblok` }
126
+ })
127
+ ```
128
+
129
+ The editor then takes the real token from `STORYBLOK_ACCESS_TOKEN`.
130
+
117
131
  `client.handle_error` is on the instance, so `.catch(client.handle_error)` works without
118
132
  wrapping anything.
119
133
 
@@ -270,6 +284,110 @@ await client.get('cdn/datasource_entries', { version })
270
284
 
271
285
  Errors go through `client.handle_error`, so a missing story is a 404 and anything else a 500, shaped by the client's `error` option.
272
286
 
287
+ ### Edit on the Site
288
+
289
+ Anyone with a role in the space can edit the real site in place, signed in with their own
290
+ Storyblok account, so their permissions apply and every change is credited to them. Changes
291
+ save as drafts; Publish in the corner badge lists what will go live, block by block, first.
292
+
293
+ It needs the **Site Editor** app installed in the space (Storyblok › Apps), with the site's
294
+ origin allowed on the app's page. `localhost` always is, and a pattern like
295
+ `https://*-team.vercel.app` covers every preview. Then three lines:
296
+
297
+ ```typescript
298
+ // hooks.server.ts
299
+ import { editor } from 'storyloco/editor/server'
300
+
301
+ export const handle = sequence(editor, version /* , ...the rest */)
302
+ ```
303
+
304
+ ```svelte
305
+ <!-- +layout.svelte -->
306
+ <script lang="ts">
307
+ import { EditBar } from 'storyloco/editor'
308
+ </script>
309
+
310
+ <EditBar />
311
+ ```
312
+
313
+ ```typescript
314
+ // app.d.ts
315
+ interface Locals {
316
+ editor?: import('storyloco/editor/server').Editor
317
+ }
318
+ ```
319
+
320
+ A site with a Content Security Policy needs `wss://moxy.uilo.co` in its `connect-src`, for the
321
+ multiplayer socket, and `'self'` in its `frame-ancestors`, for the phone and tablet preview.
322
+
323
+ A site that picks its own content version rather than using `version` asks `editor_version(event)`
324
+ first: draft for editors and guests, published for the live view. A site that caches pages at
325
+ its CDN (`s-maxage`) needs editors, and guests (`sl_guest…`), on a cache key of their own, or
326
+ whoever signs in on the live site is handed the cached visitor page, with no edit bar. The CDN
327
+ answers before the hook runs and won't vary on cookies, so it takes Routing Middleware: when a
328
+ request carries an `sl_editor…` cookie, rewrite it onto a `?_storyloco=1` URL, and never cache
329
+ responses there.
330
+
331
+ No secrets: sign-in runs through moxy, and the space comes from `STORYBLOK_SPACE_ID` or the
332
+ site's public token. Blocks that spread `attrs(blok)` are editable as they are; nothing marks
333
+ fields up. With edit mode on (the badge's switch, or **E**):
334
+
335
+ - **Text** edits where it is, matched to its field by what it shows. Body copy edits in place
336
+ as rich text, with the site's own styles.
337
+ - **Images** offer Replace: the space's library or an upload, with alt text and a focus point.
338
+ - **Blocks** get a toolbar on hover to move, duplicate, add after or delete (with Undo), and a
339
+ click opens every field of the block from its schema, nested blocks included. Drag a block
340
+ by its name in the toolbar to put it anywhere in its list.
341
+ - **AI** suggests an image's alt text in the image picker, and rewrites a selection or
342
+ paragraph of rich text shorter, friendlier, clearer, or with its spelling fixed.
343
+ - **Other stories** shown on the page, like cards listing the latest articles, edit as those
344
+ stories: their text and images where they show, and a click opens them.
345
+ - **Video** uploads to Mux when the space has the Mux app connected.
346
+ - **Undo** takes back any change made from the page, and Redo makes it again: **⌘Z** and
347
+ **⇧⌘Z** (Ctrl+Z and Ctrl+Y) outside a field, or the badge's arrows.
348
+ - **Someone else** saving the page in Storyblok while it's open shows a note with Refresh.
349
+ - **⌘K** (Ctrl+K), or the badge's ⌘ button, finds a page, a block on this one, or something
350
+ to do: make a new page, ask someone to review this one, or preview it at phone or tablet size.
351
+ - **History** goes back through the page's saves on a timeline, the page showing itself as it
352
+ was at each one, and restores any of them as the draft (with Undo). Pages that `connect()`
353
+ their story show it in place; others list what restoring would change.
354
+ - **Compare with live** (⌘K, or from Publish) flips the page between the draft and what
355
+ visitors see now, scrolled to the same place (**L**), with the blocks that aren't live yet
356
+ marked on the draft.
357
+ - **Share** the draft, like a Google Doc, with people who don't edit in Storyblok: invite them
358
+ by email (they sign in from the link, once), or let anyone with the link look. Guests see the
359
+ draft and, if they can comment, comment; someone from the link confirms their email with a
360
+ code first, so no comment is anonymous. Guests never edit, resolve, or get a Storyblok token,
361
+ and their pages aren't cached or indexed.
362
+
363
+ Everyone editing a site at once sees each other. Each person has a colour: their pointer and
364
+ name move over the page, what they've selected is outlined, and their avatars sit by the
365
+ badge. Text and rich text type together, letters merging as they're typed with everyone's
366
+ caret showing, and saves to the same story take turns, so no one's change is lost.
367
+
368
+ - **Follow** someone by clicking their avatar: the page goes where they go and scrolls with
369
+ them. Scroll yourself, or press Escape, to stop.
370
+ - **Say something** by your pointer with **/**; the others see it as it's typed.
371
+ - **Comment** with **C**, or the badge's speech bubble, then click where it goes. Threads take
372
+ replies, @mentions and Resolve. Anyone in one, or mentioned, finds it under the badge's bell,
373
+ its unread count on the badge, and gets a browser notification too once they've chosen
374
+ Notify me. A review request lands there too, and anything left unread for 15 minutes is
375
+ emailed, guests' included.
376
+
377
+ Visitors never see any of it. On the live site, press **⌘⇧E** (Ctrl+Shift+E) or add `?edit` to
378
+ any address to sign in; once signed in, the badge shows on every page, and ⌘⇧E or **E** turns
379
+ edit mode on and off. In development the badge is always there.
380
+
381
+ Links inside content don't navigate in edit mode; hold ⌘ or Ctrl to follow one. Match the
382
+ editor to a site with `--editor-bg`, `-ink`, `-muted`, `-line`, `-shadow`, `-font`,
383
+ `--editor-accent` (and `-accent-ink`), `--editor-good`, `--editor-danger`, and `--editor-pill`
384
+ for the toast and the format bar, or build a bar of your own from `editor`, `set_editing`,
385
+ `publish`, `signin` and `signout`.
386
+
387
+ `bun run smoke <site url>…` (in `packages/vite`) checks each site loads and, signed in, that a
388
+ click on a block opens its settings. Sign in once per site with `--signin`, which opens a
389
+ window; later runs reuse it without one.
390
+
273
391
  ### Use Shared Components
274
392
 
275
393
  ```typescript
@@ -565,6 +683,13 @@ MIT License - feel free to use this however you want, you absolute legend! 🎯
565
683
  4. Push to the branch (`git push origin feature/amazing-feature`)
566
684
  5. Open a Pull Request
567
685
 
686
+ ### Releasing
687
+
688
+ A release is a merge. Label a PR `release:patch`, `release:minor` or `release:major` before merging
689
+ it, and the Release workflow bumps the version, checks and builds it, commits "Release X.Y.Z: <the
690
+ PR's title>" to main with its tag, and publishes it to npm through trusted publishing, with no
691
+ token or login. To release main as it stands, run Actions → Release → Run workflow.
692
+
568
693
  ---
569
694
 
570
695
  Built with ❤️ and a lot of swearing by the Storyloco team
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyloco",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "A collection of slick Storyblok field plugins built with Svelte 5, TypeScript, and Tailwind CSS",
5
5
  "keywords": [
6
6
  "storyblok",
@@ -36,7 +36,8 @@
36
36
  "packages/seo/types.ts",
37
37
  "packages/hubspot_form/types.ts",
38
38
  "packages/currency/types.ts",
39
- "packages/dates/types.ts"
39
+ "packages/dates/types.ts",
40
+ "packages/churchsuite_categories/types.ts"
40
41
  ],
41
42
  "exports": {
42
43
  ".": {
@@ -83,6 +84,19 @@
83
84
  "types": "./packages/vite/dist/server.d.ts",
84
85
  "import": "./packages/vite/dist/server.js"
85
86
  },
87
+ "./editor": {
88
+ "types": "./packages/vite/dist/editor/index.d.ts",
89
+ "svelte": "./packages/vite/dist/editor/index.js",
90
+ "import": "./packages/vite/dist/editor/index.js"
91
+ },
92
+ "./middleware": {
93
+ "types": "./packages/vite/dist/middleware.d.ts",
94
+ "import": "./packages/vite/dist/middleware.js"
95
+ },
96
+ "./editor/server": {
97
+ "types": "./packages/vite/dist/editor/server.d.ts",
98
+ "import": "./packages/vite/dist/editor/server.js"
99
+ },
86
100
  "./time": {
87
101
  "types": "./packages/time/types.ts",
88
102
  "svelte": "./packages/time/src/app.svelte.ts"
@@ -105,22 +119,25 @@
105
119
  "./dates": {
106
120
  "types": "./packages/dates/types.ts",
107
121
  "svelte": "./packages/dates/src/app.svelte.ts"
122
+ },
123
+ "./churchsuite_categories": {
124
+ "types": "./packages/churchsuite_categories/types.ts"
108
125
  }
109
126
  },
110
127
  "devDependencies": {
111
- "@types/node": "26.1.1",
112
- "@typescript-eslint/eslint-plugin": "8.65.0",
113
- "@typescript-eslint/parser": "8.65.0",
114
- "kitto": "1.6.0",
115
- "oxfmt": "0.59.0",
128
+ "@types/node": "26.6.5",
129
+ "@typescript-eslint/eslint-plugin": "8.72.0",
130
+ "@typescript-eslint/parser": "8.72.0",
131
+ "kitto": "1.16.1",
132
+ "oxfmt": "0.74.0",
116
133
  "typescript": "6.0.3",
117
- "vite": "7.2.2"
134
+ "vite": "8.3.5"
118
135
  },
119
136
  "peerDependencies": {
120
137
  "@storyblok/svelte": "7.2.2",
121
138
  "@storyblok/field-plugin": "^1.7.1",
122
139
  "svelte": "^5.0.0",
123
- "@sveltejs/kit": "^2.0.0",
140
+ "@sveltejs/kit": "^2.0.0 || ^3.0.0",
124
141
  "storyblok-js-client": "^7.0.0"
125
142
  },
126
143
  "scripts": {
@@ -134,7 +151,34 @@
134
151
  "packages/*"
135
152
  ],
136
153
  "dependencies": {
154
+ "@tiptap/core": "^3.32.0",
155
+ "@tiptap/extension-blockquote": "^3.32.0",
156
+ "@tiptap/extension-bold": "^3.32.0",
157
+ "@tiptap/extension-code": "^3.32.0",
158
+ "@tiptap/extension-code-block": "^3.32.0",
159
+ "@tiptap/extension-collaboration": "^3.32.0",
160
+ "@tiptap/extension-collaboration-caret": "^3.32.0",
161
+ "@tiptap/extension-document": "^3.32.0",
162
+ "@tiptap/extension-hard-break": "^3.32.0",
163
+ "@tiptap/extension-heading": "^3.32.0",
164
+ "@tiptap/extension-highlight": "^3.32.0",
165
+ "@tiptap/extension-horizontal-rule": "^3.32.0",
166
+ "@tiptap/extension-image": "^3.32.0",
167
+ "@tiptap/extension-italic": "^3.32.0",
168
+ "@tiptap/extension-link": "^3.32.0",
169
+ "@tiptap/extension-list": "^3.32.0",
170
+ "@tiptap/extension-paragraph": "^3.32.0",
171
+ "@tiptap/extension-strike": "^3.32.0",
172
+ "@tiptap/extension-subscript": "^3.32.0",
173
+ "@tiptap/extension-superscript": "^3.32.0",
174
+ "@tiptap/extension-table": "^3.32.0",
175
+ "@tiptap/extension-text": "^3.32.0",
176
+ "@tiptap/extension-underline": "^3.32.0",
177
+ "@tiptap/pm": "^3.32.0",
178
+ "@tiptap/y-tiptap": "^3.0.10",
137
179
  "ora": "9.4.1",
138
- "path-to-regexp": "8.4.2"
180
+ "path-to-regexp": "9.0.0",
181
+ "y-protocols": "^1.0.7",
182
+ "yjs": "^13.6.33"
139
183
  }
140
184
  }
@@ -0,0 +1,9 @@
1
+ export interface ChurchsuiteCategory {
2
+ id: number
3
+ name: string
4
+ colour?: string
5
+ }
6
+
7
+ // the chosen ChurchSuite calendar category ids; the same shape the legacy
8
+ // churchsuite-event-categories plugin saved, so old values load as they are
9
+ export type ChurchsuiteCategories = Array<number>
@@ -1,52 +1,52 @@
1
1
  import { type WithElementRef } from '../../../utils.js';
2
2
  import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
3
3
  import { type VariantProps } from 'tailwind-variants';
4
- export declare const variants: import("tailwind-variants").TVReturnType<{
4
+ export declare const variants: import("tailwind-variants/dist/types.cjs").TVReturnType<{
5
5
  variant: {
6
- default: string;
7
- destructive: string;
8
- outline: string;
9
- secondary: string;
10
- ghost: string;
11
- link: string;
6
+ default: "bg-tertiary text-tertiary-foreground shadow-xs hover:bg-tertiary/90";
7
+ destructive: "bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white";
8
+ outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
9
+ secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 border";
10
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-secondary-foreground/10";
11
+ link: "text-primary underline-offset-4 hover:underline";
12
12
  };
13
13
  size: {
14
- default: string;
15
- sm: string;
16
- lg: string;
17
- icon: string;
14
+ default: "h-9 px-4 py-2 has-[>svg]:px-3";
15
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5";
16
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4";
17
+ icon: "size-9";
18
18
  };
19
19
  }, undefined, "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 relative cursor-pointer", {
20
20
  variant: {
21
- default: string;
22
- destructive: string;
23
- outline: string;
24
- secondary: string;
25
- ghost: string;
26
- link: string;
21
+ default: "bg-tertiary text-tertiary-foreground shadow-xs hover:bg-tertiary/90";
22
+ destructive: "bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white";
23
+ outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
24
+ secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 border";
25
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-secondary-foreground/10";
26
+ link: "text-primary underline-offset-4 hover:underline";
27
27
  };
28
28
  size: {
29
- default: string;
30
- sm: string;
31
- lg: string;
32
- icon: string;
29
+ default: "h-9 px-4 py-2 has-[>svg]:px-3";
30
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5";
31
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4";
32
+ icon: "size-9";
33
33
  };
34
- }, undefined, import("tailwind-variants").TVReturnType<{
34
+ }, undefined, import("tailwind-variants/dist/types.cjs").TVReturnTypeLike<{
35
35
  variant: {
36
- default: string;
37
- destructive: string;
38
- outline: string;
39
- secondary: string;
40
- ghost: string;
41
- link: string;
36
+ default: "bg-tertiary text-tertiary-foreground shadow-xs hover:bg-tertiary/90";
37
+ destructive: "bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white";
38
+ outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
39
+ secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 border";
40
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-secondary-foreground/10";
41
+ link: "text-primary underline-offset-4 hover:underline";
42
42
  };
43
43
  size: {
44
- default: string;
45
- sm: string;
46
- lg: string;
47
- icon: string;
44
+ default: "h-9 px-4 py-2 has-[>svg]:px-3";
45
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5";
46
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4";
47
+ icon: "size-9";
48
48
  };
49
- }, undefined, "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 relative cursor-pointer", unknown, unknown, undefined>>;
49
+ }, undefined>>;
50
50
  export type ButtonVariant = VariantProps<typeof variants>['variant'];
51
51
  export type ButtonSize = VariantProps<typeof variants>['size'];
52
52
  export type ButtonProps = WithElementRef<HTMLButtonAttributes> & WithElementRef<HTMLAnchorAttributes> & {
@@ -4,4 +4,4 @@
4
4
  let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props()
5
5
  </script>
6
6
 
7
- <SelectPrimitive.Group data-slot="select-group" {...restProps} />
7
+ <SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -18,5 +18,4 @@
18
18
  className
19
19
  )}
20
20
  bind:value
21
- {...restProps}
22
- ></textarea>
21
+ {...restProps}></textarea>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Where a comment's pin sits inside its block, finer than a fraction of the block: the element it's on, by
3
+ * its place under the block, and the character when it's on text. A block reflows at another width, its
4
+ * text rewrapping, and the pin follows the word it was put on.
5
+ */
6
+ export type Anchor = {
7
+ /** Child indexes from the block down to the element */
8
+ path: Array<number>;
9
+ tag: string;
10
+ /** Where on the element, or on the character, as a fraction of its box */
11
+ fx: number;
12
+ fy: number;
13
+ /** The character, as an offset into the element's text, and the text from there to find it again by */
14
+ at?: number;
15
+ near?: string;
16
+ };
17
+ /** The anchor for a point on an element inside a block; null when it isn't inside */
18
+ export declare function anchor_at(block: Element, under: Element, x: number, y: number): Anchor | null;
19
+ /** Where an anchor is on screen now; null when the block's changed so it isn't there any more */
20
+ export declare function anchored(block: Element, anchor: Anchor): {
21
+ x: number;
22
+ y: number;
23
+ } | null;
@@ -0,0 +1,92 @@
1
+ const NEAR = 16;
2
+ const within = (box, x, y) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
3
+ /** The character under a point among an element's text, as its text node and offset */
4
+ function character_at(el, x, y) {
5
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
6
+ const range = document.createRange();
7
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
8
+ range.selectNodeContents(node);
9
+ if (![...range.getClientRects()].some((box) => within(box, x, y)))
10
+ continue;
11
+ for (let i = 0; i < node.length; i++) {
12
+ range.setStart(node, i);
13
+ range.setEnd(node, i + 1);
14
+ const box = [...range.getClientRects()].find((b) => within(b, x, y));
15
+ if (box)
16
+ return { node, i, box };
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ /** The anchor for a point on an element inside a block; null when it isn't inside */
22
+ export function anchor_at(block, under, x, y) {
23
+ if (!block.contains(under))
24
+ return null;
25
+ const hit = character_at(under, x, y);
26
+ const el = hit?.node.parentElement ?? under;
27
+ const path = [];
28
+ for (let at = el; at !== block; at = at.parentElement)
29
+ path.unshift([...at.parentElement.children].indexOf(at));
30
+ let on = null;
31
+ if (hit) {
32
+ const before = document.createRange();
33
+ before.setStart(el, 0);
34
+ before.setEnd(hit.node, hit.i);
35
+ const at = before.toString().length;
36
+ const near = (el.textContent ?? "").slice(at, at + NEAR);
37
+ // all space is nothing to find it by
38
+ if (near.trim())
39
+ on = { at, near };
40
+ }
41
+ const box = on ? hit.box : el.getBoundingClientRect();
42
+ return {
43
+ path,
44
+ tag: el.localName,
45
+ fx: box.width ? (x - box.left) / box.width : 0,
46
+ fy: box.height ? (y - box.top) / box.height : 0,
47
+ ...on,
48
+ };
49
+ }
50
+ /** Where an anchor is on screen now; null when the block's changed so it isn't there any more */
51
+ export function anchored(block, anchor) {
52
+ let el = block;
53
+ for (const i of anchor.path)
54
+ el = el?.children[i];
55
+ if (!el || el.localName !== anchor.tag)
56
+ return null;
57
+ if (anchor.at === undefined || !anchor.near) {
58
+ const box = el.getBoundingClientRect();
59
+ if (!box.width && !box.height)
60
+ return null;
61
+ return { x: box.left + anchor.fx * box.width, y: box.top + anchor.fy * box.height };
62
+ }
63
+ // the same words, where they were or as near there as they've moved to with an edit
64
+ const text = el.textContent ?? "";
65
+ const after = text.indexOf(anchor.near, anchor.at);
66
+ const before = text.lastIndexOf(anchor.near, anchor.at);
67
+ const at = after < 0
68
+ ? before
69
+ : before < 0
70
+ ? after
71
+ : after - anchor.at < anchor.at - before
72
+ ? after
73
+ : before;
74
+ if (at < 0)
75
+ return null;
76
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
77
+ let left = at;
78
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
79
+ if (left >= node.length) {
80
+ left -= node.length;
81
+ continue;
82
+ }
83
+ const range = document.createRange();
84
+ range.setStart(node, left);
85
+ range.setEnd(node, left + 1);
86
+ const box = range.getClientRects()[0];
87
+ if (!box)
88
+ return null;
89
+ return { x: box.left + anchor.fx * box.width, y: box.top + anchor.fy * box.height };
90
+ }
91
+ return null;
92
+ }