@estiva-app/ui 0.44.1 → 0.45.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.
- package/dist/SelectionToolbar.d.ts +62 -0
- package/dist/SelectionToolbar.d.ts.map +1 -0
- package/dist/SuggestionMenu.d.ts +75 -0
- package/dist/SuggestionMenu.d.ts.map +1 -0
- package/dist/editor.d.ts +12 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +357 -0
- package/dist/editor.js.map +7 -0
- package/dist/eslint/index.js +2 -2
- package/dist/eslint/index.js.map +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +201 -99
- package/dist/index.js.map +4 -4
- package/dist/registry/{app-YMABQII6.js → app-LDGOH6UW.js} +3 -3
- package/dist/registry/{build-3EANARLQ.js → build-P6X3PTZP.js} +6 -2
- package/dist/registry/build.d.ts +4 -0
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-SIB44CES.js → chunk-P55P2QFD.js} +9 -4
- package/dist/registry/chunk-P55P2QFD.js.map +7 -0
- package/dist/registry/{chunk-EVJW26UR.js → chunk-TQ6UQER3.js} +10 -10
- package/dist/registry/chunk-TQ6UQER3.js.map +7 -0
- package/dist/registry/cli.js +2 -2
- package/dist/registry/index.js +2 -2
- package/dist/shortcuts.d.ts +27 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/suggestionPopup.d.ts +41 -0
- package/dist/suggestionPopup.d.ts.map +1 -0
- package/package.json +27 -2
- package/registry.json +341 -3
- package/src/SelectionToolbar.mdx +78 -0
- package/src/SelectionToolbar.stories.tsx +63 -0
- package/src/SelectionToolbar.test.ts +105 -0
- package/src/SelectionToolbar.tsx +487 -0
- package/src/SuggestionMenu.mdx +107 -0
- package/src/SuggestionMenu.stories.tsx +202 -0
- package/src/SuggestionMenu.test.tsx +128 -0
- package/src/SuggestionMenu.tsx +189 -0
- package/src/editor.ts +11 -0
- package/src/eslint/no-rebuilt-behaviour.test.ts +1 -1
- package/src/eslint/no-rebuilt-behaviour.ts +2 -2
- package/src/how.test.ts +1 -1
- package/src/index.ts +2 -0
- package/src/keptSelection.test.ts +67 -0
- package/src/registry/app.ts +8 -8
- package/src/registry/build.ts +10 -2
- package/src/registry/registry.test.ts +14 -9
- package/src/shortcuts.ts +40 -0
- package/src/suggestionPopup.tsx +119 -0
- package/stories/Choosing.mdx +2 -0
- package/dist/registry/chunk-EVJW26UR.js.map +0 -7
- package/dist/registry/chunk-SIB44CES.js.map +0 -7
- /package/dist/registry/{app-YMABQII6.js.map → app-LDGOH6UW.js.map} +0 -0
- /package/dist/registry/{build-3EANARLQ.js.map → build-P6X3PTZP.js.map} +0 -0
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
7
|
+
"packageVersion": "0.45.0",
|
|
8
|
+
"exports": 100,
|
|
9
|
+
"typeExports": 109,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -2651,6 +2651,63 @@
|
|
|
2651
2651
|
"text-caption font-medium text-text-primary truncate"
|
|
2652
2652
|
]
|
|
2653
2653
|
},
|
|
2654
|
+
{
|
|
2655
|
+
"name": "isApplePlatform",
|
|
2656
|
+
"repo": "estiva-ui",
|
|
2657
|
+
"kind": "helper",
|
|
2658
|
+
"importPath": "@estiva-app/ui",
|
|
2659
|
+
"sourceFile": "src/shortcuts.ts",
|
|
2660
|
+
"purpose": "Whether this machine calls the modifier Cmd rather than Ctrl.",
|
|
2661
|
+
"purposeFrom": "comment",
|
|
2662
|
+
"props": [],
|
|
2663
|
+
"variants": [],
|
|
2664
|
+
"ownsBehaviours": [],
|
|
2665
|
+
"status": "stable",
|
|
2666
|
+
"migrationStage": null,
|
|
2667
|
+
"docsId": null,
|
|
2668
|
+
"storyId": null,
|
|
2669
|
+
"docPage": null,
|
|
2670
|
+
"app": null,
|
|
2671
|
+
"looks": []
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
"name": "isSuggestionActive",
|
|
2675
|
+
"repo": "estiva-ui",
|
|
2676
|
+
"kind": "helper",
|
|
2677
|
+
"importPath": "@estiva-app/ui/editor",
|
|
2678
|
+
"sourceFile": "src/suggestionPopup.tsx",
|
|
2679
|
+
"purpose": "Open, or closed less than 100ms ago — what an Enter handler asks.",
|
|
2680
|
+
"purposeFrom": "comment",
|
|
2681
|
+
"props": [],
|
|
2682
|
+
"variants": [],
|
|
2683
|
+
"ownsBehaviours": [],
|
|
2684
|
+
"status": "stable",
|
|
2685
|
+
"migrationStage": null,
|
|
2686
|
+
"docsId": null,
|
|
2687
|
+
"storyId": null,
|
|
2688
|
+
"docPage": null,
|
|
2689
|
+
"app": null,
|
|
2690
|
+
"looks": []
|
|
2691
|
+
},
|
|
2692
|
+
{
|
|
2693
|
+
"name": "isSuggestionOpen",
|
|
2694
|
+
"repo": "estiva-ui",
|
|
2695
|
+
"kind": "helper",
|
|
2696
|
+
"importPath": "@estiva-app/ui/editor",
|
|
2697
|
+
"sourceFile": "src/suggestionPopup.tsx",
|
|
2698
|
+
"purpose": "Open *now*, with no grace period — what an Escape handler asks.",
|
|
2699
|
+
"purposeFrom": "comment",
|
|
2700
|
+
"props": [],
|
|
2701
|
+
"variants": [],
|
|
2702
|
+
"ownsBehaviours": [],
|
|
2703
|
+
"status": "stable",
|
|
2704
|
+
"migrationStage": null,
|
|
2705
|
+
"docsId": null,
|
|
2706
|
+
"storyId": null,
|
|
2707
|
+
"docPage": null,
|
|
2708
|
+
"app": null,
|
|
2709
|
+
"looks": []
|
|
2710
|
+
},
|
|
2654
2711
|
{
|
|
2655
2712
|
"name": "Kbd",
|
|
2656
2713
|
"repo": "estiva-ui",
|
|
@@ -2704,6 +2761,25 @@
|
|
|
2704
2761
|
"border-border-tooltip-key bg-bg-tooltip-key text-text-tooltip-key signal:bg-bg-tooltip-key ship:bg-bg-tooltip-key"
|
|
2705
2762
|
]
|
|
2706
2763
|
},
|
|
2764
|
+
{
|
|
2765
|
+
"name": "KeptSelection",
|
|
2766
|
+
"repo": "estiva-ui",
|
|
2767
|
+
"kind": "component",
|
|
2768
|
+
"importPath": "@estiva-app/ui/editor",
|
|
2769
|
+
"sourceFile": "src/SelectionToolbar.tsx",
|
|
2770
|
+
"purpose": "The extension an editor registers so a selection can stay drawn while a field has the focus.",
|
|
2771
|
+
"purposeFrom": "comment",
|
|
2772
|
+
"props": [],
|
|
2773
|
+
"variants": [],
|
|
2774
|
+
"ownsBehaviours": [],
|
|
2775
|
+
"status": "stable",
|
|
2776
|
+
"migrationStage": null,
|
|
2777
|
+
"docsId": "primitives-selectiontoolbar--docs",
|
|
2778
|
+
"storyId": "primitives-selectiontoolbar--default",
|
|
2779
|
+
"docPage": null,
|
|
2780
|
+
"app": null,
|
|
2781
|
+
"looks": []
|
|
2782
|
+
},
|
|
2707
2783
|
{
|
|
2708
2784
|
"name": "Lightbox",
|
|
2709
2785
|
"repo": "estiva-ui",
|
|
@@ -3394,6 +3470,25 @@
|
|
|
3394
3470
|
"text-center font-mono text-caption tabular-nums text-text-muted"
|
|
3395
3471
|
]
|
|
3396
3472
|
},
|
|
3473
|
+
{
|
|
3474
|
+
"name": "normalizeHref",
|
|
3475
|
+
"repo": "estiva-ui",
|
|
3476
|
+
"kind": "helper",
|
|
3477
|
+
"importPath": "@estiva-app/ui/editor",
|
|
3478
|
+
"sourceFile": "src/SelectionToolbar.tsx",
|
|
3479
|
+
"purpose": "What somebody typed into the link field → an `href`, or null when it cannot be one.",
|
|
3480
|
+
"purposeFrom": "comment",
|
|
3481
|
+
"props": [],
|
|
3482
|
+
"variants": [],
|
|
3483
|
+
"ownsBehaviours": [],
|
|
3484
|
+
"status": "stable",
|
|
3485
|
+
"migrationStage": null,
|
|
3486
|
+
"docsId": "primitives-selectiontoolbar--docs",
|
|
3487
|
+
"storyId": "primitives-selectiontoolbar--default",
|
|
3488
|
+
"docPage": null,
|
|
3489
|
+
"app": null,
|
|
3490
|
+
"looks": []
|
|
3491
|
+
},
|
|
3397
3492
|
{
|
|
3398
3493
|
"name": "Panel",
|
|
3399
3494
|
"repo": "estiva-ui",
|
|
@@ -4658,6 +4753,84 @@
|
|
|
4658
4753
|
"rounded-lg border bg-bg-field shadow-field text-left"
|
|
4659
4754
|
]
|
|
4660
4755
|
},
|
|
4756
|
+
{
|
|
4757
|
+
"name": "SelectionToolbar",
|
|
4758
|
+
"repo": "estiva-ui",
|
|
4759
|
+
"kind": "component",
|
|
4760
|
+
"importPath": "@estiva-app/ui/editor",
|
|
4761
|
+
"sourceFile": "src/SelectionToolbar.tsx",
|
|
4762
|
+
"purpose": "Bold, italic and underline — and code or a link where the editor has them — over text you have already selected in an editor.",
|
|
4763
|
+
"purposeFrom": "page",
|
|
4764
|
+
"props": [
|
|
4765
|
+
{
|
|
4766
|
+
"name": "editor",
|
|
4767
|
+
"takes": "Editor | null",
|
|
4768
|
+
"required": true,
|
|
4769
|
+
"note": "The editor whose selection the strip follows."
|
|
4770
|
+
},
|
|
4771
|
+
{
|
|
4772
|
+
"name": "marks",
|
|
4773
|
+
"takes": "readonly MarkId[]",
|
|
4774
|
+
"required": false,
|
|
4775
|
+
"note": "Which marks the strip toggles, in order."
|
|
4776
|
+
},
|
|
4777
|
+
{
|
|
4778
|
+
"name": "link",
|
|
4779
|
+
"takes": "true/false",
|
|
4780
|
+
"required": false,
|
|
4781
|
+
"note": "The Link button and its address field. Needs a link mark in the editor and `KeptSelection`."
|
|
4782
|
+
}
|
|
4783
|
+
],
|
|
4784
|
+
"variants": [],
|
|
4785
|
+
"ownsBehaviours": [
|
|
4786
|
+
{
|
|
4787
|
+
"id": "portal",
|
|
4788
|
+
"behaviour": "Floats on top of the page"
|
|
4789
|
+
},
|
|
4790
|
+
{
|
|
4791
|
+
"id": "press-outside",
|
|
4792
|
+
"behaviour": "Closes on a press outside"
|
|
4793
|
+
},
|
|
4794
|
+
{
|
|
4795
|
+
"id": "page-keys",
|
|
4796
|
+
"behaviour": "Takes its keys by itself"
|
|
4797
|
+
},
|
|
4798
|
+
{
|
|
4799
|
+
"id": "focus",
|
|
4800
|
+
"behaviour": "Holds focus inside while open, and gives it back"
|
|
4801
|
+
},
|
|
4802
|
+
{
|
|
4803
|
+
"id": "follow",
|
|
4804
|
+
"behaviour": "Stays attached to its anchor on scroll and resize"
|
|
4805
|
+
}
|
|
4806
|
+
],
|
|
4807
|
+
"status": "stable",
|
|
4808
|
+
"migrationStage": null,
|
|
4809
|
+
"docsId": "primitives-selectiontoolbar--docs",
|
|
4810
|
+
"storyId": "primitives-selectiontoolbar--default",
|
|
4811
|
+
"docPage": "src/SelectionToolbar.mdx",
|
|
4812
|
+
"app": null,
|
|
4813
|
+
"looks": []
|
|
4814
|
+
},
|
|
4815
|
+
{
|
|
4816
|
+
"name": "shortcutLabel",
|
|
4817
|
+
"repo": "estiva-ui",
|
|
4818
|
+
"kind": "helper",
|
|
4819
|
+
"importPath": "@estiva-app/ui",
|
|
4820
|
+
"sourceFile": "src/shortcuts.ts",
|
|
4821
|
+
"purpose": "`Mod-B` → `Cmd+B` on a Mac, `Ctrl+B` elsewhere.",
|
|
4822
|
+
"purposeFrom": "comment",
|
|
4823
|
+
"props": [],
|
|
4824
|
+
"variants": [],
|
|
4825
|
+
"ownsBehaviours": [],
|
|
4826
|
+
"status": "stable",
|
|
4827
|
+
"migrationStage": null,
|
|
4828
|
+
"docsId": null,
|
|
4829
|
+
"storyId": null,
|
|
4830
|
+
"docPage": null,
|
|
4831
|
+
"app": null,
|
|
4832
|
+
"looks": []
|
|
4833
|
+
},
|
|
4661
4834
|
{
|
|
4662
4835
|
"name": "Sidebar",
|
|
4663
4836
|
"repo": "estiva-ui",
|
|
@@ -4786,6 +4959,171 @@
|
|
|
4786
4959
|
"app": null,
|
|
4787
4960
|
"looks": []
|
|
4788
4961
|
},
|
|
4962
|
+
{
|
|
4963
|
+
"name": "SuggestionMenu",
|
|
4964
|
+
"repo": "estiva-ui",
|
|
4965
|
+
"kind": "component",
|
|
4966
|
+
"importPath": "@estiva-app/ui",
|
|
4967
|
+
"sourceFile": "src/SuggestionMenu.tsx",
|
|
4968
|
+
"purpose": "The list a text editor opens as you type — `/` for commands, `@` for people — while focus stays in the text.",
|
|
4969
|
+
"purposeFrom": "page",
|
|
4970
|
+
"props": [
|
|
4971
|
+
{
|
|
4972
|
+
"name": "rect",
|
|
4973
|
+
"takes": "DOMRect | null",
|
|
4974
|
+
"required": true,
|
|
4975
|
+
"note": "The caret's rect, from the editor. `null` closes the box."
|
|
4976
|
+
},
|
|
4977
|
+
{
|
|
4978
|
+
"name": "editorElement",
|
|
4979
|
+
"takes": "HTMLElement | null",
|
|
4980
|
+
"required": false,
|
|
4981
|
+
"note": "The editor's own element — told which row is active."
|
|
4982
|
+
},
|
|
4983
|
+
{
|
|
4984
|
+
"name": "ariaLabel",
|
|
4985
|
+
"takes": "text",
|
|
4986
|
+
"required": true,
|
|
4987
|
+
"note": "Names the list — \"Commands\", \"People\"."
|
|
4988
|
+
},
|
|
4989
|
+
{
|
|
4990
|
+
"name": "width",
|
|
4991
|
+
"takes": "text",
|
|
4992
|
+
"required": true,
|
|
4993
|
+
"note": "The box's width, e.g. `w-[300px]`."
|
|
4994
|
+
},
|
|
4995
|
+
{
|
|
4996
|
+
"name": "maxHeight",
|
|
4997
|
+
"takes": "text",
|
|
4998
|
+
"required": true,
|
|
4999
|
+
"note": "The cap before the rows scroll, e.g. `max-h-[400px]`."
|
|
5000
|
+
},
|
|
5001
|
+
{
|
|
5002
|
+
"name": "sections",
|
|
5003
|
+
"takes": "readonly SuggestionMenuSection<T>[]",
|
|
5004
|
+
"required": true,
|
|
5005
|
+
"note": null
|
|
5006
|
+
},
|
|
5007
|
+
{
|
|
5008
|
+
"name": "itemKey",
|
|
5009
|
+
"takes": "a handler",
|
|
5010
|
+
"required": true,
|
|
5011
|
+
"note": "A stable key for a row."
|
|
5012
|
+
},
|
|
5013
|
+
{
|
|
5014
|
+
"name": "row",
|
|
5015
|
+
"takes": "a handler",
|
|
5016
|
+
"required": true,
|
|
5017
|
+
"note": "What a row shows."
|
|
5018
|
+
},
|
|
5019
|
+
{
|
|
5020
|
+
"name": "onSelect",
|
|
5021
|
+
"takes": "a handler",
|
|
5022
|
+
"required": true,
|
|
5023
|
+
"note": null
|
|
5024
|
+
},
|
|
5025
|
+
{
|
|
5026
|
+
"name": "handle",
|
|
5027
|
+
"takes": "Ref<SuggestionMenuHandle>",
|
|
5028
|
+
"required": false,
|
|
5029
|
+
"note": null
|
|
5030
|
+
}
|
|
5031
|
+
],
|
|
5032
|
+
"variants": [],
|
|
5033
|
+
"ownsBehaviours": [
|
|
5034
|
+
{
|
|
5035
|
+
"id": "portal",
|
|
5036
|
+
"behaviour": "Floats on top of the page"
|
|
5037
|
+
},
|
|
5038
|
+
{
|
|
5039
|
+
"id": "press-outside",
|
|
5040
|
+
"behaviour": "Closes on a press outside"
|
|
5041
|
+
},
|
|
5042
|
+
{
|
|
5043
|
+
"id": "page-keys",
|
|
5044
|
+
"behaviour": "Takes its keys by itself"
|
|
5045
|
+
},
|
|
5046
|
+
{
|
|
5047
|
+
"id": "focus",
|
|
5048
|
+
"behaviour": "Holds focus inside while open, and gives it back"
|
|
5049
|
+
},
|
|
5050
|
+
{
|
|
5051
|
+
"id": "follow",
|
|
5052
|
+
"behaviour": "Stays attached to its anchor on scroll and resize"
|
|
5053
|
+
},
|
|
5054
|
+
{
|
|
5055
|
+
"id": "walking",
|
|
5056
|
+
"behaviour": "Moves through its items with the arrow keys"
|
|
5057
|
+
},
|
|
5058
|
+
{
|
|
5059
|
+
"id": "role",
|
|
5060
|
+
"behaviour": "Says what it is to assistive technology"
|
|
5061
|
+
}
|
|
5062
|
+
],
|
|
5063
|
+
"status": "stable",
|
|
5064
|
+
"migrationStage": null,
|
|
5065
|
+
"docsId": "primitives-suggestionmenu--docs",
|
|
5066
|
+
"storyId": "primitives-suggestionmenu--default",
|
|
5067
|
+
"docPage": "src/SuggestionMenu.mdx",
|
|
5068
|
+
"app": null,
|
|
5069
|
+
"looks": []
|
|
5070
|
+
},
|
|
5071
|
+
{
|
|
5072
|
+
"name": "suggestionPopup",
|
|
5073
|
+
"repo": "estiva-ui",
|
|
5074
|
+
"kind": "helper",
|
|
5075
|
+
"importPath": "@estiva-app/ui/editor",
|
|
5076
|
+
"sourceFile": "src/suggestionPopup.tsx",
|
|
5077
|
+
"purpose": "The `render` of a Tiptap suggestion plugin, drawn as a `SuggestionMenu` — the popup controller Peek wrote four times (`mention.tsx`, `slashCommands.tsx`), once.",
|
|
5078
|
+
"purposeFrom": "comment",
|
|
5079
|
+
"props": [
|
|
5080
|
+
{
|
|
5081
|
+
"name": "ariaLabel",
|
|
5082
|
+
"takes": "text",
|
|
5083
|
+
"required": true,
|
|
5084
|
+
"note": "Names the list — \"Commands\", \"People\"."
|
|
5085
|
+
},
|
|
5086
|
+
{
|
|
5087
|
+
"name": "width",
|
|
5088
|
+
"takes": "text",
|
|
5089
|
+
"required": true,
|
|
5090
|
+
"note": "The box's width, e.g. `w-[300px]`."
|
|
5091
|
+
},
|
|
5092
|
+
{
|
|
5093
|
+
"name": "maxHeight",
|
|
5094
|
+
"takes": "text",
|
|
5095
|
+
"required": true,
|
|
5096
|
+
"note": "The cap before the rows scroll, e.g. `max-h-[400px]`."
|
|
5097
|
+
},
|
|
5098
|
+
{
|
|
5099
|
+
"name": "sections",
|
|
5100
|
+
"takes": "a handler",
|
|
5101
|
+
"required": false,
|
|
5102
|
+
"note": "The plugin's items, and the typed query, → the sections to draw."
|
|
5103
|
+
},
|
|
5104
|
+
{
|
|
5105
|
+
"name": "itemKey",
|
|
5106
|
+
"takes": "a handler",
|
|
5107
|
+
"required": true,
|
|
5108
|
+
"note": null
|
|
5109
|
+
},
|
|
5110
|
+
{
|
|
5111
|
+
"name": "row",
|
|
5112
|
+
"takes": "a handler",
|
|
5113
|
+
"required": true,
|
|
5114
|
+
"note": null
|
|
5115
|
+
}
|
|
5116
|
+
],
|
|
5117
|
+
"variants": [],
|
|
5118
|
+
"ownsBehaviours": [],
|
|
5119
|
+
"status": "stable",
|
|
5120
|
+
"migrationStage": null,
|
|
5121
|
+
"docsId": null,
|
|
5122
|
+
"storyId": null,
|
|
5123
|
+
"docPage": null,
|
|
5124
|
+
"app": null,
|
|
5125
|
+
"looks": []
|
|
5126
|
+
},
|
|
4789
5127
|
{
|
|
4790
5128
|
"name": "Tabs",
|
|
4791
5129
|
"repo": "estiva-ui",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as SelectionToolbarStories from './SelectionToolbar.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={SelectionToolbarStories} />
|
|
5
|
+
|
|
6
|
+
# SelectionToolbar
|
|
7
|
+
|
|
8
|
+
Bold, italic and underline — and code or a link where the editor has them —
|
|
9
|
+
over text you have already selected in an editor.
|
|
10
|
+
|
|
11
|
+
<Canvas of={SelectionToolbarStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **Inside any Tiptap editor.** It mounts with the editor and decides for
|
|
16
|
+
itself when to appear: once a selection has stood still, centred over it.
|
|
17
|
+
- `marks` chooses the buttons, in order: `bold`, `italic`, `underline`, `code`.
|
|
18
|
+
The default is the first three.
|
|
19
|
+
- `link` adds the Link button, which turns the strip into one field for the
|
|
20
|
+
address. Select words that are already a link and the field opens by itself,
|
|
21
|
+
their address in it.
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- In the `/` list → **SuggestionMenu**. Typing `/` replaces the selection, so
|
|
26
|
+
there is nothing selected to apply a mark to.
|
|
27
|
+
- A strip of actions on a card or an image → **Toolbar** in a **Popover**. This
|
|
28
|
+
one's placement exists to centre over a text selection.
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
34
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
35
|
+
import { SelectionToolbar, KeptSelection } from '@estiva-app/ui/editor'
|
|
36
|
+
|
|
37
|
+
const editor = useEditor({ extensions: [StarterKit, KeptSelection], content: text })
|
|
38
|
+
|
|
39
|
+
<EditorContent editor={editor} />
|
|
40
|
+
<SelectionToolbar editor={editor} marks={['bold', 'italic', 'underline', 'code']} link />
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `editor` is the Tiptap editor, or `null` while it loads. There is nothing to
|
|
44
|
+
place: it anchors itself to the selection.
|
|
45
|
+
- **`link` needs two things in the editor**: a link mark, and `KeptSelection`,
|
|
46
|
+
which keeps the words highlighted while the address field has the focus.
|
|
47
|
+
Without a link mark, leave `link` off.
|
|
48
|
+
- **Each mark must be in the editor's schema.** A button for a mark the editor
|
|
49
|
+
lacks does nothing.
|
|
50
|
+
- **The address field:** Enter applies it, and so does clicking away. Enter on
|
|
51
|
+
an empty field removes the link; clicking away with an empty field changes
|
|
52
|
+
nothing. Escape drops what was typed. `http`, `https` and `mailto` only; a
|
|
53
|
+
bare host gets `https://`.
|
|
54
|
+
|
|
55
|
+
<Canvas of={SelectionToolbarStories.WithALink} />
|
|
56
|
+
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
| It does | So you never write | Refused when written by hand |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Appears once a selection has stood still, never mid-drag or on a plain click | a selection listener, a timer | |
|
|
62
|
+
| Centres itself over the selection, above it | position arithmetic | |
|
|
63
|
+
| Keeps the caret in the text while a button is pressed | `preventDefault` on each button | |
|
|
64
|
+
| Keeps the words highlighted while the address field has focus | a second selection | |
|
|
65
|
+
|
|
66
|
+
## Keys
|
|
67
|
+
|
|
68
|
+
| Input | What happens |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Cmd/Ctrl+B, I, U, E | bold, italic, underline, code — the editor's own keys, named in each tooltip |
|
|
71
|
+
| Tab | onto the strip, as any `Toolbar` |
|
|
72
|
+
| ← / → | between the buttons |
|
|
73
|
+
| Enter, in the address field | applies it; empty removes the link |
|
|
74
|
+
| Escape, in the address field | drops what was typed and puts the caret back |
|
|
75
|
+
|
|
76
|
+
## Props
|
|
77
|
+
|
|
78
|
+
<Controls of={SelectionToolbarStories.Default} />
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { richTextClassName } from './RichText'
|
|
5
|
+
import { KeptSelection, SelectionToolbar, type MarkId } from './SelectionToolbar'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bold, italic, underline — and code or link where the editor has them — over
|
|
9
|
+
* text you have already selected. It follows the selection by itself.
|
|
10
|
+
*/
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Primitives/SelectionToolbar',
|
|
13
|
+
component: SelectionToolbar,
|
|
14
|
+
parameters: { layout: 'padded' },
|
|
15
|
+
} satisfies Meta
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A real editor with a few words selected as it opens, so the strip is on
|
|
22
|
+
* screen without a drag — it appears the way it always does, once the
|
|
23
|
+
* selection has stood still. Select other words to move it.
|
|
24
|
+
*/
|
|
25
|
+
function WithAnEditor({ marks, link, html, select }: { marks?: readonly MarkId[]; link?: boolean; html: string; select: [number, number] }) {
|
|
26
|
+
const editor = useEditor({
|
|
27
|
+
extensions: [StarterKit, KeptSelection],
|
|
28
|
+
content: html,
|
|
29
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
30
|
+
onCreate: ({ editor: e }) => {
|
|
31
|
+
e.commands.focus()
|
|
32
|
+
e.commands.setTextSelection({ from: select[0], to: select[1] })
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
return (
|
|
36
|
+
<div className="pt-16">
|
|
37
|
+
<EditorContent editor={editor} className="max-w-[560px]" />
|
|
38
|
+
<SelectionToolbar editor={editor} marks={marks} link={link} />
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TEXT = '<p>Select a few words in this line, and the strip appears over them.</p>'
|
|
44
|
+
|
|
45
|
+
/** The default strip: bold, italic, underline. */
|
|
46
|
+
export const Default: Story = {
|
|
47
|
+
render: () => <WithAnEditor html={TEXT} select={[10, 19]} />,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `marks` chooses the buttons: here code as well. */
|
|
51
|
+
export const WithCode: Story = {
|
|
52
|
+
render: () => <WithAnEditor html={TEXT} select={[10, 19]} marks={['bold', 'italic', 'underline', 'code']} />,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `link` adds the Link button, which opens one field for the address. */
|
|
56
|
+
export const WithALink: Story = {
|
|
57
|
+
render: () => <WithAnEditor html={TEXT} select={[10, 19]} link />,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Words that are already a link open straight on the field, their address in it. */
|
|
61
|
+
export const OnALink: Story = {
|
|
62
|
+
render: () => <WithAnEditor html={'<p>Select <a href="https://example.com">these words</a> to change where they go.</p>'} select={[8, 19]} link />,
|
|
63
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The selection toolbar's helpers (UIG-31), carried over with it: how a key is
|
|
4
|
+
* named in a tooltip, and what the address field does to the text. Drawing the
|
|
5
|
+
* strip needs a real selection standing still, which jsdom cannot make; the
|
|
6
|
+
* stories show it in a browser.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { Editor } from '@tiptap/core'
|
|
10
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
11
|
+
import { KeptSelection, applyLink, currentLink, normalizeHref } from './SelectionToolbar'
|
|
12
|
+
import { shortcutLabel } from './shortcuts'
|
|
13
|
+
|
|
14
|
+
let editor: Editor
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
editor = new Editor({ extensions: [StarterKit, KeptSelection], content: '<p>read the linked words first</p>' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
editor.destroy()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('shortcutLabel', () => {
|
|
25
|
+
it('says Cmd on Apple platforms', () => {
|
|
26
|
+
expect(shortcutLabel('Mod-B', true)).toBe('Cmd+B')
|
|
27
|
+
expect(shortcutLabel('Mod-Shift-B', true)).toBe('Cmd+Shift+B')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('says Ctrl everywhere else', () => {
|
|
31
|
+
expect(shortcutLabel('Mod-B', false)).toBe('Ctrl+B')
|
|
32
|
+
expect(shortcutLabel('Mod-Shift-B', false)).toBe('Ctrl+Shift+B')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('upper-cases a single-character key so Ctrl+b never reaches a tooltip', () => {
|
|
36
|
+
expect(shortcutLabel('Mod-b', false)).toBe('Ctrl+B')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('the marks the strip can toggle are in a standard editor', () => {
|
|
41
|
+
it.each(['bold', 'italic', 'underline', 'code', 'link'])('%s', (mark) => {
|
|
42
|
+
expect(Object.keys(editor.schema.marks)).toContain(mark)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('normalizeHref — what typing in the field becomes', () => {
|
|
47
|
+
const cases: Array<[string, string | null]> = [
|
|
48
|
+
['https://example.com/a/b', 'https://example.com/a/b'],
|
|
49
|
+
['http://example.com', 'http://example.com'],
|
|
50
|
+
['mailto:someone@example.com', 'mailto:someone@example.com'],
|
|
51
|
+
['example.com', 'https://example.com'],
|
|
52
|
+
[' www.example.com/a?b=c ', 'https://www.example.com/a?b=c'],
|
|
53
|
+
['localhost:5173/page', 'https://localhost:5173/page'],
|
|
54
|
+
['javascript:alert(1)', null],
|
|
55
|
+
['ftp://files.example.com', null],
|
|
56
|
+
['two words', null],
|
|
57
|
+
['', null],
|
|
58
|
+
[' ', null],
|
|
59
|
+
]
|
|
60
|
+
it.each(cases)('%j → %j', (raw, href) => {
|
|
61
|
+
expect(normalizeHref(raw)).toBe(href)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('applyLink — over "linked words" in "read the linked words first"', () => {
|
|
66
|
+
/** The one linked run in the document, or null. */
|
|
67
|
+
const linkOf = (): { text: string; href: string } | null => {
|
|
68
|
+
let found: { text: string; href: string } | null = null
|
|
69
|
+
editor.state.doc.descendants((node) => {
|
|
70
|
+
const link = node.marks.find((m) => m.type.name === 'link')
|
|
71
|
+
if (node.isText && link) found = { text: node.text ?? '', href: link.attrs.href as string }
|
|
72
|
+
})
|
|
73
|
+
return found
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
editor.commands.setTextSelection({ from: 10, to: 22 })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('an address links the selected words, completing a bare host', () => {
|
|
81
|
+
expect(applyLink(editor, 'example.com')).toBe(true)
|
|
82
|
+
expect(linkOf()).toEqual({ text: 'linked words', href: 'https://example.com' })
|
|
83
|
+
expect(currentLink(editor)).toBe('https://example.com')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('an empty field removes the link', () => {
|
|
87
|
+
applyLink(editor, 'https://example.com')
|
|
88
|
+
expect(applyLink(editor, '')).toBe(true)
|
|
89
|
+
expect(linkOf()).toBeNull()
|
|
90
|
+
expect(currentLink(editor)).toBe('')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('a scheme a reader may not follow is refused, and nothing changes', () => {
|
|
94
|
+
expect(applyLink(editor, 'javascript:alert(1)')).toBe(false)
|
|
95
|
+
expect(linkOf()).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('the caret inside a link re-addresses the whole link', () => {
|
|
99
|
+
applyLink(editor, 'https://example.com')
|
|
100
|
+
editor.commands.setTextSelection(15)
|
|
101
|
+
expect(currentLink(editor)).toBe('https://example.com')
|
|
102
|
+
expect(applyLink(editor, 'https://example.org')).toBe(true)
|
|
103
|
+
expect(linkOf()).toEqual({ text: 'linked words', href: 'https://example.org' })
|
|
104
|
+
})
|
|
105
|
+
})
|