@estiva-app/ui 0.67.0 → 0.68.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 (41) hide show
  1. package/dist/BlockNodes.d.ts +12 -1
  2. package/dist/BlockNodes.d.ts.map +1 -1
  3. package/dist/LinkChips.d.ts +82 -0
  4. package/dist/LinkChips.d.ts.map +1 -0
  5. package/dist/ReferenceCards.d.ts +77 -0
  6. package/dist/ReferenceCards.d.ts.map +1 -0
  7. package/dist/ReferenceMenu.d.ts +18 -0
  8. package/dist/ReferenceMenu.d.ts.map +1 -0
  9. package/dist/RichText.d.ts +9 -2
  10. package/dist/RichText.d.ts.map +1 -1
  11. package/dist/SelectionToolbar.d.ts +9 -1
  12. package/dist/SelectionToolbar.d.ts.map +1 -1
  13. package/dist/editor.d.ts +3 -0
  14. package/dist/editor.d.ts.map +1 -1
  15. package/dist/editor.js +589 -20
  16. package/dist/editor.js.map +4 -4
  17. package/dist/index.js +39 -19
  18. package/dist/index.js.map +4 -4
  19. package/package.json +1 -1
  20. package/registry.json +290 -3
  21. package/src/BlockNodes.test.tsx +2 -1
  22. package/src/BlockNodes.tsx +51 -9
  23. package/src/LinkChips.mdx +57 -0
  24. package/src/LinkChips.stories.tsx +67 -0
  25. package/src/LinkChips.test.tsx +178 -0
  26. package/src/LinkChips.ts +243 -0
  27. package/src/ReferenceCards.mdx +79 -0
  28. package/src/ReferenceCards.stories.tsx +64 -0
  29. package/src/ReferenceCards.test.tsx +143 -0
  30. package/src/ReferenceCards.ts +225 -0
  31. package/src/ReferenceMenu.mdx +75 -0
  32. package/src/ReferenceMenu.stories.tsx +59 -0
  33. package/src/ReferenceMenu.test.tsx +289 -0
  34. package/src/ReferenceMenu.tsx +346 -0
  35. package/src/RichText.test.tsx +17 -0
  36. package/src/RichText.tsx +34 -3
  37. package/src/SelectionToolbar.tsx +10 -2
  38. package/src/editor.ts +13 -0
  39. package/src/pages.test.ts +3 -0
  40. package/src/registry/registry.test.ts +6 -6
  41. package/stories/Choosing.mdx +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.67.0",
8
- "exports": 149,
9
- "typeExports": 146,
7
+ "packageVersion": "0.68.0",
8
+ "exports": 162,
9
+ "typeExports": 152,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -2729,6 +2729,25 @@
2729
2729
  "app": null,
2730
2730
  "looks": []
2731
2731
  },
2732
+ {
2733
+ "name": "findLinkChips",
2734
+ "repo": "estiva-ui",
2735
+ "kind": "helper",
2736
+ "importPath": "@estiva-app/ui/editor",
2737
+ "sourceFile": "src/LinkChips.ts",
2738
+ "purpose": "What to change in a document, and what to ask.",
2739
+ "purposeFrom": "comment",
2740
+ "props": [],
2741
+ "variants": [],
2742
+ "ownsBehaviours": [],
2743
+ "status": "stable",
2744
+ "migrationStage": null,
2745
+ "docsId": "primitives-linkchips--docs",
2746
+ "storyId": "primitives-linkchips--in-an-editor",
2747
+ "docPage": null,
2748
+ "app": null,
2749
+ "looks": []
2750
+ },
2732
2751
  {
2733
2752
  "name": "Form",
2734
2753
  "repo": "estiva-ui",
@@ -3337,6 +3356,25 @@
3337
3356
  "app": null,
3338
3357
  "looks": []
3339
3358
  },
3359
+ {
3360
+ "name": "isReferenceMenuOpen",
3361
+ "repo": "estiva-ui",
3362
+ "kind": "helper",
3363
+ "importPath": "@estiva-app/ui/editor",
3364
+ "sourceFile": "src/ReferenceMenu.tsx",
3365
+ "purpose": "Whether the menu is open, for an editor's own Escape: while it is, Escape closes it rather than leaving.",
3366
+ "purposeFrom": "comment",
3367
+ "props": [],
3368
+ "variants": [],
3369
+ "ownsBehaviours": [],
3370
+ "status": "stable",
3371
+ "migrationStage": null,
3372
+ "docsId": "primitives-referencemenu--docs",
3373
+ "storyId": "primitives-referencemenu--in-an-editor",
3374
+ "docPage": null,
3375
+ "app": null,
3376
+ "looks": []
3377
+ },
3340
3378
  {
3341
3379
  "name": "isSuggestionActive",
3342
3380
  "repo": "estiva-ui",
@@ -3667,6 +3705,25 @@
3667
3705
  "underline underline-offset-2 hover:text-text-primary truncate after:rounded-md focus-visible:outline-none"
3668
3706
  ]
3669
3707
  },
3708
+ {
3709
+ "name": "LinkChips",
3710
+ "repo": "estiva-ui",
3711
+ "kind": "component",
3712
+ "importPath": "@estiva-app/ui/editor",
3713
+ "sourceFile": "src/LinkChips.ts",
3714
+ "purpose": "A URL in a document that the app recognises reads as a chip of what it points at, and stays that URL when the document is saved.",
3715
+ "purposeFrom": "page",
3716
+ "props": [],
3717
+ "variants": [],
3718
+ "ownsBehaviours": [],
3719
+ "status": "stable",
3720
+ "migrationStage": null,
3721
+ "docsId": "primitives-linkchips--docs",
3722
+ "storyId": "primitives-linkchips--in-an-editor",
3723
+ "docPage": "src/LinkChips.mdx",
3724
+ "app": null,
3725
+ "looks": []
3726
+ },
3670
3727
  {
3671
3728
  "name": "ListColumn",
3672
3729
  "repo": "estiva-ui",
@@ -5485,6 +5542,63 @@
5485
5542
  "app": null,
5486
5543
  "looks": []
5487
5544
  },
5545
+ {
5546
+ "name": "referenceAtSelection",
5547
+ "repo": "estiva-ui",
5548
+ "kind": "helper",
5549
+ "importPath": "@estiva-app/ui/editor",
5550
+ "sourceFile": "src/ReferenceCards.ts",
5551
+ "purpose": "The chip the person has selected, with what it names and its card — or null.",
5552
+ "purposeFrom": "comment",
5553
+ "props": [],
5554
+ "variants": [],
5555
+ "ownsBehaviours": [],
5556
+ "status": "stable",
5557
+ "migrationStage": null,
5558
+ "docsId": "primitives-referencecards--docs",
5559
+ "storyId": "primitives-referencecards--in-an-editor",
5560
+ "docPage": null,
5561
+ "app": null,
5562
+ "looks": []
5563
+ },
5564
+ {
5565
+ "name": "referenceCardPlacements",
5566
+ "repo": "estiva-ui",
5567
+ "kind": "helper",
5568
+ "importPath": "@estiva-app/ui/editor",
5569
+ "sourceFile": "src/ReferenceCards.ts",
5570
+ "purpose": "Every line that asks for a card, in document order.",
5571
+ "purposeFrom": "comment",
5572
+ "props": [],
5573
+ "variants": [],
5574
+ "ownsBehaviours": [],
5575
+ "status": "stable",
5576
+ "migrationStage": null,
5577
+ "docsId": "primitives-referencecards--docs",
5578
+ "storyId": "primitives-referencecards--in-an-editor",
5579
+ "docPage": null,
5580
+ "app": null,
5581
+ "looks": []
5582
+ },
5583
+ {
5584
+ "name": "ReferenceCards",
5585
+ "repo": "estiva-ui",
5586
+ "kind": "component",
5587
+ "importPath": "@estiva-app/ui/editor",
5588
+ "sourceFile": "src/ReferenceCards.ts",
5589
+ "purpose": "A chip in a document can ask for a card after its line: compact, detailed or none.",
5590
+ "purposeFrom": "page",
5591
+ "props": [],
5592
+ "variants": [],
5593
+ "ownsBehaviours": [],
5594
+ "status": "stable",
5595
+ "migrationStage": null,
5596
+ "docsId": "primitives-referencecards--docs",
5597
+ "storyId": "primitives-referencecards--in-an-editor",
5598
+ "docPage": "src/ReferenceCards.mdx",
5599
+ "app": null,
5600
+ "looks": []
5601
+ },
5488
5602
  {
5489
5603
  "name": "referenceMenu",
5490
5604
  "repo": "estiva-ui",
@@ -5529,6 +5643,91 @@
5529
5643
  "app": null,
5530
5644
  "looks": []
5531
5645
  },
5646
+ {
5647
+ "name": "ReferenceMenu",
5648
+ "repo": "estiva-ui",
5649
+ "kind": "component",
5650
+ "importPath": "@estiva-app/ui/editor",
5651
+ "sourceFile": "src/ReferenceMenu.tsx",
5652
+ "purpose": "What a chip in a document offers whoever is writing it: Open, and its Card — none, compact or detailed.",
5653
+ "purposeFrom": "page",
5654
+ "props": [],
5655
+ "variants": [],
5656
+ "ownsBehaviours": [],
5657
+ "status": "stable",
5658
+ "migrationStage": null,
5659
+ "docsId": "primitives-referencemenu--docs",
5660
+ "storyId": "primitives-referencemenu--in-an-editor",
5661
+ "docPage": "src/ReferenceMenu.mdx",
5662
+ "app": null,
5663
+ "looks": []
5664
+ },
5665
+ {
5666
+ "name": "referenceMenuClick",
5667
+ "repo": "estiva-ui",
5668
+ "kind": "helper",
5669
+ "importPath": "@estiva-app/ui/editor",
5670
+ "sourceFile": "src/ReferenceMenu.tsx",
5671
+ "purpose": "A click on the chip at `pos`, from its node view: while editing, with the menu installed, it opens the menu, and the caller stops the click there.",
5672
+ "purposeFrom": "comment",
5673
+ "props": [],
5674
+ "variants": [],
5675
+ "ownsBehaviours": [],
5676
+ "status": "stable",
5677
+ "migrationStage": null,
5678
+ "docsId": "primitives-referencemenu--docs",
5679
+ "storyId": "primitives-referencemenu--in-an-editor",
5680
+ "docPage": null,
5681
+ "app": null,
5682
+ "looks": []
5683
+ },
5684
+ {
5685
+ "name": "ReferenceMenuView",
5686
+ "repo": "estiva-ui",
5687
+ "kind": "component",
5688
+ "importPath": "@estiva-app/ui/editor",
5689
+ "sourceFile": "src/ReferenceMenu.tsx",
5690
+ "purpose": "The menu `ReferenceMenu` holds, hung under its chip.",
5691
+ "purposeFrom": "comment",
5692
+ "props": [
5693
+ {
5694
+ "name": "editor",
5695
+ "takes": "Editor | null",
5696
+ "required": true,
5697
+ "note": null
5698
+ }
5699
+ ],
5700
+ "variants": [],
5701
+ "ownsBehaviours": [
5702
+ {
5703
+ "id": "portal",
5704
+ "behaviour": "Floats on top of the page"
5705
+ },
5706
+ {
5707
+ "id": "press-outside",
5708
+ "behaviour": "Closes on a press outside"
5709
+ },
5710
+ {
5711
+ "id": "page-keys",
5712
+ "behaviour": "Takes its keys by itself"
5713
+ },
5714
+ {
5715
+ "id": "focus",
5716
+ "behaviour": "Holds focus inside while open, and gives it back"
5717
+ },
5718
+ {
5719
+ "id": "follow",
5720
+ "behaviour": "Stays attached to its anchor on scroll and resize"
5721
+ }
5722
+ ],
5723
+ "status": "stable",
5724
+ "migrationStage": null,
5725
+ "docsId": "primitives-referencemenu--docs",
5726
+ "storyId": "primitives-referencemenu--in-an-editor",
5727
+ "docPage": null,
5728
+ "app": null,
5729
+ "looks": []
5730
+ },
5532
5731
  {
5533
5732
  "name": "ReferenceNode",
5534
5733
  "repo": "estiva-ui",
@@ -5567,6 +5766,25 @@
5567
5766
  "app": null,
5568
5767
  "looks": []
5569
5768
  },
5769
+ {
5770
+ "name": "refreshLinkChips",
5771
+ "repo": "estiva-ui",
5772
+ "kind": "helper",
5773
+ "importPath": "@estiva-app/ui/editor",
5774
+ "sourceFile": "src/LinkChips.ts",
5775
+ "purpose": "Look at the document again — after the app learned what more URLs name some other way.",
5776
+ "purposeFrom": "comment",
5777
+ "props": [],
5778
+ "variants": [],
5779
+ "ownsBehaviours": [],
5780
+ "status": "stable",
5781
+ "migrationStage": null,
5782
+ "docsId": "primitives-linkchips--docs",
5783
+ "storyId": "primitives-linkchips--in-an-editor",
5784
+ "docPage": null,
5785
+ "app": null,
5786
+ "looks": []
5787
+ },
5570
5788
  {
5571
5789
  "name": "RichText",
5572
5790
  "repo": "estiva-ui",
@@ -5606,6 +5824,12 @@
5606
5824
  "required": false,
5607
5825
  "note": "Draws a block this part has no design for (an attachment, a widget)."
5608
5826
  },
5827
+ {
5828
+ "name": "renderAfter",
5829
+ "takes": "a handler",
5830
+ "required": false,
5831
+ "note": "Draws something after a line of text: after a paragraph, a heading or a quote, and inside a list item or a table cell after its words — where a document's chip asks for its card (SPEC §13.3)."
5832
+ },
5609
5833
  {
5610
5834
  "name": "onTick",
5611
5835
  "takes": "a handler",
@@ -6154,6 +6378,12 @@
6154
6378
  "takes": "true/false",
6155
6379
  "required": false,
6156
6380
  "note": "The Link button and its address field. Needs a link mark in the editor and `KeptSelection`."
6381
+ },
6382
+ {
6383
+ "name": "children",
6384
+ "takes": "anything",
6385
+ "required": false,
6386
+ "note": "More of the strip, after the marks: the app's own `ToolbarButton`s for what is selected — a chip's card, say."
6157
6387
  }
6158
6388
  ],
6159
6389
  "variants": [],
@@ -6187,6 +6417,44 @@
6187
6417
  "app": null,
6188
6418
  "looks": []
6189
6419
  },
6420
+ {
6421
+ "name": "setReferenceCard",
6422
+ "repo": "estiva-ui",
6423
+ "kind": "helper",
6424
+ "importPath": "@estiva-app/ui/editor",
6425
+ "sourceFile": "src/ReferenceCards.ts",
6426
+ "purpose": "Sets the card of the selected chip. `none` removes it.",
6427
+ "purposeFrom": "comment",
6428
+ "props": [],
6429
+ "variants": [],
6430
+ "ownsBehaviours": [],
6431
+ "status": "stable",
6432
+ "migrationStage": null,
6433
+ "docsId": "primitives-referencecards--docs",
6434
+ "storyId": "primitives-referencecards--in-an-editor",
6435
+ "docPage": null,
6436
+ "app": null,
6437
+ "looks": []
6438
+ },
6439
+ {
6440
+ "name": "setReferenceCardAt",
6441
+ "repo": "estiva-ui",
6442
+ "kind": "helper",
6443
+ "importPath": "@estiva-app/ui/editor",
6444
+ "sourceFile": "src/ReferenceCards.ts",
6445
+ "purpose": "Sets the card of the chip at `pos` — one that names something.",
6446
+ "purposeFrom": "comment",
6447
+ "props": [],
6448
+ "variants": [],
6449
+ "ownsBehaviours": [],
6450
+ "status": "stable",
6451
+ "migrationStage": null,
6452
+ "docsId": "primitives-referencecards--docs",
6453
+ "storyId": "primitives-referencecards--in-an-editor",
6454
+ "docPage": null,
6455
+ "app": null,
6456
+ "looks": []
6457
+ },
6190
6458
  {
6191
6459
  "name": "shortcutLabel",
6192
6460
  "repo": "estiva-ui",
@@ -7608,6 +7876,25 @@
7608
7876
  "app": null,
7609
7877
  "looks": []
7610
7878
  },
7879
+ {
7880
+ "name": "useReferenceCards",
7881
+ "repo": "estiva-ui",
7882
+ "kind": "hook",
7883
+ "importPath": "@estiva-app/ui/editor",
7884
+ "sourceFile": "src/ReferenceCards.ts",
7885
+ "purpose": "The slots to draw cards into, kept current as the document changes.",
7886
+ "purposeFrom": "comment",
7887
+ "props": [],
7888
+ "variants": [],
7889
+ "ownsBehaviours": [],
7890
+ "status": "stable",
7891
+ "migrationStage": null,
7892
+ "docsId": "primitives-referencecards--docs",
7893
+ "storyId": "primitives-referencecards--in-an-editor",
7894
+ "docPage": null,
7895
+ "app": null,
7896
+ "looks": []
7897
+ },
7611
7898
  {
7612
7899
  "name": "useToast",
7613
7900
  "repo": "estiva-ui",
@@ -79,7 +79,8 @@ describe('what the editor hands back', () => {
79
79
  it('keeps a reference an atom, with its uri and the marks it carries', () => {
80
80
  editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
81
81
  const reference = (editor.getJSON() as Json).content?.[1].content?.[1]
82
- expect(reference).toEqual({ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } })
82
+ // No card chosen, no key a later version added (SPEC §13.3), and not a URL chip.
83
+ expect(reference).toEqual({ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'], embed: null, extra: null, href: null } })
83
84
  })
84
85
 
85
86
  it('keeps every imeta field on a file, which ProseMirror drops when undeclared', () => {
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from 'react'
1
+ import type { ComponentType, MouseEvent } from 'react'
2
2
  import { Extension, InputRule, Node, type Editor } from '@tiptap/core'
3
3
  import { DOMSerializer, type Node as ProseMirrorNode, type Slice } from '@tiptap/pm/model'
4
4
  import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
@@ -7,6 +7,7 @@ import type { EditorView, NodeView } from '@tiptap/pm/view'
7
7
  import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type ReactNodeViewProps } from '@tiptap/react'
8
8
  import { Checkbox, type CheckboxProps } from './Checkbox'
9
9
  import { calloutLook } from './RichText'
10
+ import { referenceMenuClick } from './ReferenceMenu'
10
11
 
11
12
  /*
12
13
  * The four nodes an editor of a block document needs that a stock schema does
@@ -364,9 +365,13 @@ export const BlockId = Extension.create({
364
365
  },
365
366
  })
366
367
 
367
- /** What a reference's view is handed: the `nostr:` URI it stands for. */
368
+ /** An http(s) URL, or null. */
369
+ const webUrl = (raw: string | null): string | null => (raw && /^https?:\/\/\S+$/i.test(raw) ? raw : null)
370
+
371
+ /** What a reference's view is handed: the `nostr:` URI it stands for, and the URL a URL chip was written as. */
368
372
  export interface ReferenceViewProps {
369
373
  uri: string
374
+ href?: string
370
375
  }
371
376
 
372
377
  export interface ReferenceNodeOptions {
@@ -384,6 +389,16 @@ export interface ReferenceNodeOptions {
384
389
  * nobody edits half a `naddr`. Selecting it selects the whole reference, which
385
390
  * is also how deleting one works. `marks` carries the run's other marks, so a
386
391
  * bold reference comes back bold.
392
+ *
393
+ * `embed` is the card its writer chose to draw after its block — `compact`,
394
+ * `detailed`, or null for none (SPEC §13.3, "A reference's card") — and `extra`
395
+ * every key of the mark this version does not know. Both are declared so that
396
+ * ProseMirror keeps them: an undeclared attribute is dropped when the node is
397
+ * made, and the card with it. `ReferenceCards` draws the cards.
398
+ *
399
+ * `href` makes it a **URL chip**: an app URL that reads as the chip of what it
400
+ * names, and is that URL on the wire (§13.3). `uri` is what it names, filled in
401
+ * by `LinkChips` once the app has resolved it; until then it draws as the URL.
387
402
  */
388
403
  export const ReferenceNode = Node.create<ReferenceNodeOptions>({
389
404
  name: 'reference',
@@ -397,8 +412,14 @@ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
397
412
  },
398
413
  addAttributes() {
399
414
  return {
400
- uri: { default: '' },
415
+ // A pasted URL chip keeps only its URL: what it names is the editor's to resolve (`LinkChips`), never
416
+ // the clipboard's, which could show one object while saving another URL (security review).
417
+ uri: { default: '', parseHTML: (element: HTMLElement) => (webUrl(element.getAttribute('data-href')) ? '' : (element.getAttribute('data-reference') ?? '')) },
401
418
  marks: { default: [] as string[] },
419
+ embed: { default: null as string | null, parseHTML: (element: HTMLElement) => element.getAttribute('data-embed') },
420
+ extra: { default: null as Record<string, unknown> | null, parseHTML: () => null },
421
+ // An http(s) URL only: `LinkChips` makes no other, and a pasted `javascript:` must not reach a saved link.
422
+ href: { default: null as string | null, parseHTML: (element: HTMLElement) => webUrl(element.getAttribute('data-href')) },
402
423
  }
403
424
  },
404
425
  parseHTML() {
@@ -406,16 +427,37 @@ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
406
427
  },
407
428
  renderHTML({ node }) {
408
429
  // A copy to the clipboard as HTML, and the whole drawing when no view is given.
409
- return ['span', { 'data-reference': String(node.attrs.uri) }, String(node.attrs.uri)]
430
+ // The card goes along, so a paste keeps it; a URL chip copies as its URL.
431
+ const embed = typeof node.attrs.embed === 'string' ? { 'data-embed': node.attrs.embed } : {}
432
+ const href = typeof node.attrs.href === 'string' && node.attrs.href ? { 'data-href': node.attrs.href } : {}
433
+ return ['span', { 'data-reference': String(node.attrs.uri), ...embed, ...href }, String(node.attrs.href || node.attrs.uri)]
434
+ },
435
+ renderText({ node }) {
436
+ return String(node.attrs.href || node.attrs.uri)
410
437
  },
411
438
  addNodeView() {
412
439
  const View = this.options.view
413
440
  if (!View) return null
414
- return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
415
- <NodeViewWrapper as="span" className="inline">
416
- <View uri={String(node.attrs.uri ?? '')} />
417
- </NodeViewWrapper>
418
- ))
441
+ return ReactNodeViewRenderer(({ node, editor, getPos }: ReactNodeViewProps) => {
442
+ const uri = String(node.attrs.uri ?? '')
443
+ const href = typeof node.attrs.href === 'string' && node.attrs.href ? node.attrs.href : undefined
444
+ return (
445
+ <NodeViewWrapper
446
+ as="span"
447
+ className="inline"
448
+ // While editing, a click opens the chip's menu (`ReferenceMenu`) rather than following the chip.
449
+ onClickCapture={(event: MouseEvent) => {
450
+ const pos = getPos()
451
+ if (typeof pos !== 'number' || !referenceMenuClick(editor, pos)) return
452
+ event.preventDefault()
453
+ event.stopPropagation()
454
+ }}
455
+ >
456
+ {/* A URL chip not resolved yet reads as the URL it was written as. */}
457
+ {uri ? <View uri={uri} {...(href ? { href } : {})} /> : <span>{href}</span>}
458
+ </NodeViewWrapper>
459
+ )
460
+ })
419
461
  },
420
462
  })
421
463
 
@@ -0,0 +1,57 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as LinkChipsStories from './LinkChips.stories'
3
+
4
+ <Meta of={LinkChipsStories} />
5
+
6
+ # LinkChips
7
+
8
+ A URL in a document that the app recognises reads as a chip of what it points
9
+ at, and stays that URL when the document is saved. A URL the app does not
10
+ recognise, or has not answered for yet, stays text.
11
+
12
+ <Canvas of={LinkChipsStories.InAnEditor} />
13
+
14
+ ## When
15
+
16
+ - **An editor of a document whose URLs can name things**: pair it with
17
+ **ReferenceNode**, which draws the chip, and **ReferenceCards**, which places
18
+ the card a chip can ask for.
19
+
20
+ ## When not
21
+
22
+ - A document that is only read → **RichText**, whose text already says which
23
+ runs are chips.
24
+ - An editor with a link mark (a composer's Link button) → its links are the
25
+ author's words, not chips. Turn the link mark off in a document editor
26
+ (`StarterKit.configure({ link: false })`), or URLs are linked before this
27
+ sees them.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { LinkChips, ReferenceCards, ReferenceNode } from '@estiva-app/ui/editor'
33
+
34
+ LinkChips.configure({
35
+ // What the app has resolved so far: the reference a URL names, or undefined.
36
+ referenceOf: (url) => known(url),
37
+ // Look up the rest. When it settles, the document is looked at again.
38
+ ask: (urls) => lookUp(urls),
39
+ })
40
+ ```
41
+
42
+ - A chip made from a URL is a `reference` node with `href` set to the URL;
43
+ `uri` is what it names. A document saves it as the URL.
44
+ - A chip read from a document before its URL is answered draws as the URL, and
45
+ offers no card choice until it is answered.
46
+ - The URL the caret is in, or at the end of, stays text: a half-typed address
47
+ never turns into a chip while it is being typed. A pasted URL is whole: it
48
+ is asked about and becomes its chip at once, with the caret at its end, so
49
+ **ReferenceMenu** can open its menu.
50
+ - A URL in code, in a code block, or under a link mark stays text.
51
+ - Each URL is asked about once, and one editor asks about at most `limit`
52
+ (24 by default), the first ones: an answer can cost the app a query.
53
+ - Making a chip is not an undo step: undo takes back what was typed.
54
+
55
+ ## What it owns
56
+
57
+ Nothing. It draws nothing; it turns the URLs the app recognises into chips.
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { BlockId, ReferenceNode, type ReferenceViewProps } from './BlockNodes'
5
+ import { Card } from './Card'
6
+ import { InlineChip } from './InlineChip'
7
+ import { LinkChips } from './LinkChips'
8
+ import { ReferenceCards } from './ReferenceCards'
9
+ import { richTextClassName } from './RichText'
10
+
11
+ /**
12
+ * A URL the app recognises reads as a chip; one it does not stays text. Here
13
+ * the app is a stand-in that recognises one address, after a short wait, and
14
+ * the chip is a placeholder naming what it points at: the real one is the
15
+ * app's, which knows how to resolve it.
16
+ */
17
+ const meta = {
18
+ title: 'Primitives/LinkChips',
19
+ parameters: { layout: 'padded' },
20
+ } satisfies Meta
21
+
22
+ export default meta
23
+ type Story = StoryObj
24
+
25
+ const KNOWN = 'https://example.com/item/one'
26
+ const answered = new Map<string, string>()
27
+
28
+ function Placeholder({ uri }: ReferenceViewProps) {
29
+ return <InlineChip>Item for {uri}</InlineChip>
30
+ }
31
+
32
+ function Box() {
33
+ const editor = useEditor({
34
+ extensions: [
35
+ StarterKit.configure({ link: false }),
36
+ BlockId,
37
+ ReferenceNode.configure({ view: Placeholder }),
38
+ ReferenceCards,
39
+ LinkChips.configure({
40
+ referenceOf: (url) => answered.get(url),
41
+ ask: (urls) =>
42
+ new Promise<void>((resolve) =>
43
+ setTimeout(() => {
44
+ if (urls.includes(KNOWN)) answered.set(KNOWN, 'nostr:naddr1one')
45
+ resolve()
46
+ }, 600),
47
+ ),
48
+ }),
49
+ ],
50
+ content: {
51
+ type: 'doc',
52
+ content: [
53
+ { type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: `Recognised: ${KNOWN}` }] },
54
+ { type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'Not recognised: https://example.com/elsewhere' }] },
55
+ ],
56
+ },
57
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
58
+ })
59
+ return (
60
+ <Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
61
+ <EditorContent editor={editor} />
62
+ </Card>
63
+ )
64
+ }
65
+
66
+ /** Two URLs: the first turns into its chip once the stand-in answers, the second stays text. */
67
+ export const InAnEditor: Story = { render: () => <Box /> }