playbook_ui 17.2.0.pre.rc.2 → 17.2.0.pre.rc.3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx +31 -1
  3. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_format_selected_display.jsx +191 -0
  4. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_format_selected_display.md +1 -0
  5. data/app/pb_kits/playbook/pb_multi_level_select/docs/_playground.json +31 -2
  6. data/app/pb_kits/playbook/pb_multi_level_select/docs/_playground.overrides.json +31 -2
  7. data/app/pb_kits/playbook/pb_multi_level_select/docs/example.yml +1 -0
  8. data/app/pb_kits/playbook/pb_multi_level_select/docs/index.js +1 -0
  9. data/app/pb_kits/playbook/pb_multi_level_select/kit.schema.json +6 -0
  10. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.test.jsx +39 -1
  11. data/app/pb_kits/playbook/pb_rich_text_editor/TipTap/ToolbarDropdown.tsx +9 -2
  12. data/app/pb_kits/playbook/pb_rich_text_editor/TipTap/listSelection.ts +59 -0
  13. data/app/pb_kits/playbook/pb_rich_text_editor/TipTap/markdownPaste.ts +100 -0
  14. data/app/pb_kits/playbook/pb_rich_text_editor/_rich_text_editor.tsx +31 -0
  15. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_playground.json +5 -1
  16. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_playground.overrides.json +4 -1
  17. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_markdown_support.jsx +31 -0
  18. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_advanced_markdown_support.md +5 -0
  19. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_markdown_support.html.erb +9 -0
  20. data/app/pb_kits/playbook/pb_rich_text_editor/docs/_rich_text_editor_rails_markdown_support.md +5 -0
  21. data/app/pb_kits/playbook/pb_rich_text_editor/docs/example.yml +2 -2
  22. data/app/pb_kits/playbook/pb_rich_text_editor/docs/index.js +1 -0
  23. data/app/pb_kits/playbook/pb_rich_text_editor/kit.schema.json +8 -0
  24. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.html.erb +1 -1
  25. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor.rb +1 -0
  26. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor_advanced.test.js +241 -1
  27. data/app/pb_kits/playbook/pb_rich_text_editor/rich_text_editor_rails.js +109 -0
  28. data/app/pb_kits/playbook/pb_table/_table.tsx +9 -0
  29. data/app/pb_kits/playbook/pb_table/docs/_playground.json +29 -1
  30. data/app/pb_kits/playbook/pb_table/docs/_playground.overrides.json +23 -0
  31. data/app/pb_kits/playbook/pb_table/docs/_sections.yml +1 -1
  32. data/app/pb_kits/playbook/pb_table/docs/_table_colspan.html.erb +32 -0
  33. data/app/pb_kits/playbook/pb_table/docs/_table_colspan.jsx +52 -0
  34. data/app/pb_kits/playbook/pb_table/docs/_table_colspan.md +5 -0
  35. data/app/pb_kits/playbook/pb_table/docs/example.yml +3 -1
  36. data/app/pb_kits/playbook/pb_table/docs/index.js +2 -1
  37. data/app/pb_kits/playbook/pb_table/kit.schema.json +6 -0
  38. data/app/pb_kits/playbook/pb_table/subcomponents/_table_cell.tsx +6 -1
  39. data/app/pb_kits/playbook/pb_table/subcomponents/_table_header.tsx +6 -1
  40. data/app/pb_kits/playbook/pb_table/table.test.js +62 -0
  41. data/app/pb_kits/playbook/pb_table/table_cell.html.erb +1 -1
  42. data/app/pb_kits/playbook/pb_table/table_cell.rb +1 -0
  43. data/dist/chunks/{_table-SGw4kUye.js → _table-CUU7TisO.js} +1 -1
  44. data/dist/chunks/vendor.js +2 -2
  45. data/lib/playbook/version.rb +1 -1
  46. metadata +14 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 672b9baf425e982f8d59fe03e71b4211cd715c2975de0f0c3a30895201d6b780
4
- data.tar.gz: ed5012fd921338fdcc088c046e07e0a44f54b11f61e9921309958307b324fdfa
3
+ metadata.gz: b7287af0b8d2bde74e68c570b2997cae4eccb91cd0e6091b3323cb168fe79b9a
4
+ data.tar.gz: 35a49a7c94cfa00b247e687ee9766f6668ab3e15364a9f975d7b3e1c03c48242
5
5
  SHA512:
6
- metadata.gz: 8291cbace8ba3b7abebdf73b25d577d4a464fc49b1c8de677c1e990b42409a90e3b59a1b76f11312a7443c45ddb18bae51e481e501e602bfc3c735aa8ff98422
7
- data.tar.gz: e1277c1934e5ecd79e9740070ca25dee5966f39916501d5cd257055bb96aaade4fa9d5ed924badab1a53666e725caaaef2644a6ec55393d1f06032cf5b250b6f
6
+ metadata.gz: a1ee4f80a2e9a88affce23654cd842e4c2a8e25de724d74b9a2836073cb9fda8048cc245d2f755ba3d43c79de0b479a6cb39bde761bea22dc22fe9c63ef3a316
7
+ data.tar.gz: 91ad77dd27483c9f3f91568a5ad3c8161f69f8aa72c6a71c196f93914cf34aa55292d0b90b3f71b13446b831592a8ae5dcfc5327e9ddeb4a5d11aa45ebcb656c
@@ -49,12 +49,19 @@ interface MultiLevelSelectComponent extends React.ForwardRefExoticComponent<
49
49
  Options: typeof MultiLevelSelectOptions;
50
50
  }
51
51
 
52
+ type MultiLevelSelectItem = { [key: string]: any };
53
+ type SelectedDisplayContext = {
54
+ ancestors: MultiLevelSelectItem[];
55
+ path: MultiLevelSelectItem[];
56
+ };
57
+
52
58
  type MultiLevelSelectProps = {
53
59
  aria?: { [key: string]: string };
54
60
  className?: string;
55
61
  data?: { [key: string]: string };
56
62
  disabled?: boolean;
57
63
  error?: string;
64
+ formatSelectedDisplay?: (item: MultiLevelSelectItem, context: SelectedDisplayContext) => string;
58
65
  htmlOptions?: { [key: string]: string | number | boolean | (() => void) };
59
66
  id?: string;
60
67
  inputDisplay?: "pills" | "none";
@@ -105,6 +112,7 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
105
112
  data = {},
106
113
  disabled = false,
107
114
  error,
115
+ formatSelectedDisplay,
108
116
  htmlOptions = {},
109
117
  id,
110
118
  inputDisplay = "pills",
@@ -175,6 +183,26 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
175
183
  item: [],
176
184
  });
177
185
 
186
+ const getSelectedDisplay = (
187
+ item: MultiLevelSelectItem,
188
+ data: MultiLevelSelectItem[],
189
+ ) => {
190
+ const ancestors: MultiLevelSelectItem[] = [];
191
+ let parentId = item.parent_id;
192
+
193
+ while (parentId) {
194
+ const parent = filterFormattedDataById(data, parentId)[0];
195
+ if (!parent) break;
196
+
197
+ ancestors.unshift(parent);
198
+ parentId = parent.parent_id;
199
+ }
200
+
201
+ return formatSelectedDisplay
202
+ ? formatSelectedDisplay(item, { ancestors, path: [...ancestors, item] })
203
+ : item.label;
204
+ };
205
+
178
206
  const arrowDownElementId = `arrow_down_${id}`;
179
207
  const arrowUpElementId = `arrow_up_${id}`;
180
208
  // Control id for label htmlFor: use suffix to avoid conflict with outer div's id
@@ -771,7 +799,9 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
771
799
  : placeholderText
772
800
  }
773
801
  required={required}
774
- value={singleSelectedItem.value || filterItem}
802
+ value={singleSelectedItem.item.length
803
+ ? getSelectedDisplay(singleSelectedItem.item[0], formattedData)
804
+ : singleSelectedItem.value || filterItem}
775
805
  />
776
806
  </div>
777
807
 
@@ -0,0 +1,191 @@
1
+ import React from "react";
2
+ import MultiLevelSelect from "../_multi_level_select";
3
+
4
+ const treeData = [
5
+ {
6
+ label: "HQ",
7
+ value: "hQ",
8
+ id: "hq1",
9
+ },
10
+ {
11
+ label: "Philadelphia",
12
+ value: "philadelphia",
13
+ id: "phl1",
14
+ expanded: true,
15
+ children: [
16
+ {
17
+ label: "Marketing & Sales PHL",
18
+ value: "marketingAndSalesPhl",
19
+ id: "marketingPHL1",
20
+ },
21
+ {
22
+ label: "Installation Office PHL",
23
+ value: "installationOfficePhl",
24
+ id: "installationPHL1",
25
+ },
26
+ {
27
+ label: "Warehouse PHL",
28
+ value: "warehousePhl",
29
+ id: "warehousePHL1",
30
+ },
31
+ ]
32
+ },
33
+ {
34
+ label: "New Jersey",
35
+ value: "newJersey",
36
+ id: "nj1",
37
+ children: [
38
+ {
39
+ label: "New Jersey",
40
+ value: "newJersey",
41
+ id: "nj11",
42
+ children: [
43
+ {
44
+ label: "Marketing & Sales NJ",
45
+ value: "marketingAndSalesNj",
46
+ id: "marketingNJ1",
47
+ },
48
+ {
49
+ label: "Installation Office NJ",
50
+ value: "installationOfficeNj",
51
+ id: "installationNJ1",
52
+ },
53
+ {
54
+ label: "Warehouse NJ",
55
+ value: "warehouseNj",
56
+ id: "warehouseNJ1",
57
+ },
58
+ ],
59
+ },
60
+ {
61
+ label: "Princeton",
62
+ value: "princeton",
63
+ id: "princeton1",
64
+ children: [
65
+ {
66
+ label: "Marketing & Sales Princeton",
67
+ value: "marketingAndSalesPrinceton",
68
+ id: "marketingPR1",
69
+ },
70
+ {
71
+ label: "Installation Office Princeton",
72
+ value: "installationOfficePrinceton",
73
+ id: "installationPR1",
74
+ },
75
+ {
76
+ label: "Warehouse Princeton",
77
+ value: "warehousePrinceton",
78
+ id: "warehousePR1",
79
+ },
80
+ ]
81
+ },
82
+ ]
83
+ },
84
+ ];
85
+
86
+ const treeData2 = [
87
+ {
88
+ label: "HQ",
89
+ value: "hQ",
90
+ id: "hq2",
91
+ },
92
+ {
93
+ label: "Philadelphia",
94
+ value: "philadelphia",
95
+ id: "phl2",
96
+ expanded: true,
97
+ children: [
98
+ {
99
+ label: "Marketing & Sales PHL",
100
+ value: "marketingAndSalesPhl",
101
+ id: "marketingPHL2",
102
+ },
103
+ {
104
+ label: "Installation Office PHL",
105
+ value: "installationOfficePhl",
106
+ id: "installationPHL2",
107
+ },
108
+ {
109
+ label: "Warehouse PHL",
110
+ value: "warehousePhl",
111
+ id: "warehousePHL2",
112
+ },
113
+ ]
114
+ },
115
+ {
116
+ label: "New Jersey",
117
+ value: "newJersey",
118
+ id: "nj2",
119
+ children: [
120
+ {
121
+ label: "New Jersey",
122
+ value: "newJersey",
123
+ id: "nj22",
124
+ children: [
125
+ {
126
+ label: "Marketing & Sales NJ",
127
+ value: "marketingAndSalesNj",
128
+ id: "marketingNJ2",
129
+ },
130
+ {
131
+ label: "Installation Office NJ",
132
+ value: "installationOfficeNj",
133
+ id: "installationNJ2",
134
+ },
135
+ {
136
+ label: "Warehouse NJ",
137
+ value: "warehouseNj",
138
+ id: "warehouseNJ2",
139
+ },
140
+ ],
141
+ },
142
+ {
143
+ label: "Princeton",
144
+ value: "princeton",
145
+ id: "princeton2",
146
+ children: [
147
+ {
148
+ label: "Marketing & Sales Princeton",
149
+ value: "marketingAndSalesPrinceton",
150
+ id: "marketingPR2",
151
+ },
152
+ {
153
+ label: "Installation Office Princeton",
154
+ value: "installationOfficePrinceton",
155
+ id: "installationPR2",
156
+ },
157
+ {
158
+ label: "Warehouse Princeton",
159
+ value: "warehousePrinceton",
160
+ id: "warehousePR2",
161
+ },
162
+ ]
163
+ },
164
+ ]
165
+ },
166
+ ];
167
+
168
+ const MultiLevelSelectFormatSelectedDisplay = (props) => (
169
+ <>
170
+ <MultiLevelSelect
171
+ formatSelectedDisplay={(_, { path }) =>
172
+ path.map(({ label }) => label).join(" / ")
173
+ }
174
+ treeData={treeData}
175
+ variant="single"
176
+ {...props}
177
+ />
178
+ <br />
179
+ <MultiLevelSelect
180
+ formatSelectedDisplay={(item, { ancestors }) => {
181
+ const parent = ancestors[ancestors.length - 1];
182
+ return parent ? `${item.label} (${parent.label})` : item.label;
183
+ }}
184
+ treeData={treeData2}
185
+ variant="single"
186
+ {...props}
187
+ />
188
+ </>
189
+ );
190
+
191
+ export default MultiLevelSelectFormatSelectedDisplay;
@@ -0,0 +1 @@
1
+ Use `formatSelectedDisplay` to customize the string shown in the input after a single-select option is chosen. The formatter receives the selected item and a context object containing `ancestors` (ordered from the root through the immediate parent) and `path` (the ancestors plus the selected item). This changes only the displayed string; selection callbacks and form values remain unchanged.
@@ -31,7 +31,8 @@
31
31
  "name",
32
32
  "placeholder",
33
33
  "selectedIds",
34
- "inputName"
34
+ "inputName",
35
+ "formatSelectedDisplay"
35
36
  ]
36
37
  },
37
38
  {
@@ -48,7 +49,8 @@
48
49
  "props": [
49
50
  "inputDisplay",
50
51
  "variant",
51
- "wrapped"
52
+ "wrapped",
53
+ "pillColor"
52
54
  ]
53
55
  },
54
56
  {
@@ -92,6 +94,20 @@
92
94
  "onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
93
95
  }
94
96
  },
97
+ {
98
+ "name": "Custom Single Select Display",
99
+ "structureMode": "standard",
100
+ "props": {
101
+ "variant": "single",
102
+ "label": "Pick one team",
103
+ "placeholder": "Choose...",
104
+ "selectedIds": [
105
+ "initiative1"
106
+ ],
107
+ "formatSelectedDisplay": "(_, { path }) => path.map(({ label }) => label).join(' / ')",
108
+ "onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
109
+ }
110
+ },
95
111
  {
96
112
  "name": "Selected IDs",
97
113
  "structureMode": "standard",
@@ -246,6 +262,11 @@
246
262
  }
247
263
  ],
248
264
  "conditionals": {
265
+ "formatSelectedDisplay": {
266
+ "requires": {
267
+ "variant": "single"
268
+ }
269
+ },
249
270
  "requiredIndicator": {
250
271
  "requires": "label"
251
272
  }
@@ -266,6 +287,11 @@
266
287
  "message": "Single Select uses radios and should receive at most one selectedIds value because only one node can be selected.",
267
288
  "type": "info"
268
289
  },
290
+ "custom_single_select_display": {
291
+ "presetName": "Custom Single Select Display",
292
+ "message": "formatSelectedDisplay receives the selected item plus ancestors and path context, and must return the string shown in the single-select input. Edit the function in Content to choose which hierarchy details to display.",
293
+ "type": "info"
294
+ },
269
295
  "selected_ids": {
270
296
  "presetName": "Selected IDs",
271
297
  "message": "selectedIds checks matching treeData node ids on load. In multi mode, multiple ids can be passed.",
@@ -319,6 +345,9 @@
319
345
  "editable": false,
320
346
  "default": ""
321
347
  },
348
+ "editableFunctionProps": [
349
+ "formatSelectedDisplay"
350
+ ],
322
351
  "customProps": {
323
352
  "treeData": {
324
353
  "type": "array",
@@ -16,6 +16,9 @@
16
16
  "editable": false,
17
17
  "default": ""
18
18
  },
19
+ "editableFunctionProps": [
20
+ "formatSelectedDisplay"
21
+ ],
19
22
  "customProps": {
20
23
  "treeData": {
21
24
  "type": "array",
@@ -135,7 +138,8 @@
135
138
  "name",
136
139
  "placeholder",
137
140
  "selectedIds",
138
- "inputName"
141
+ "inputName",
142
+ "formatSelectedDisplay"
139
143
  ]
140
144
  },
141
145
  {
@@ -152,7 +156,8 @@
152
156
  "props": [
153
157
  "inputDisplay",
154
158
  "variant",
155
- "wrapped"
159
+ "wrapped",
160
+ "pillColor"
156
161
  ]
157
162
  },
158
163
  {
@@ -196,6 +201,20 @@
196
201
  "onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
197
202
  }
198
203
  },
204
+ {
205
+ "name": "Custom Single Select Display",
206
+ "structureMode": "standard",
207
+ "props": {
208
+ "variant": "single",
209
+ "label": "Pick one team",
210
+ "placeholder": "Choose...",
211
+ "selectedIds": [
212
+ "initiative1"
213
+ ],
214
+ "formatSelectedDisplay": "(_, { path }) => path.map(({ label }) => label).join(' / ')",
215
+ "onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
216
+ }
217
+ },
199
218
  {
200
219
  "name": "Selected IDs",
201
220
  "structureMode": "standard",
@@ -350,6 +369,11 @@
350
369
  }
351
370
  ],
352
371
  "conditionals": {
372
+ "formatSelectedDisplay": {
373
+ "requires": {
374
+ "variant": "single"
375
+ }
376
+ },
353
377
  "requiredIndicator": {
354
378
  "requires": "label"
355
379
  }
@@ -370,6 +394,11 @@
370
394
  "message": "Single Select uses radios and should receive at most one selectedIds value because only one node can be selected.",
371
395
  "type": "info"
372
396
  },
397
+ "custom_single_select_display": {
398
+ "presetName": "Custom Single Select Display",
399
+ "message": "formatSelectedDisplay receives the selected item plus ancestors and path context, and must return the string shown in the single-select input. Edit the function in Content to choose which hierarchy details to display.",
400
+ "type": "info"
401
+ },
373
402
  "selected_ids": {
374
403
  "presetName": "Selected IDs",
375
404
  "message": "selectedIds checks matching treeData node ids on load. In multi mode, multiple ids can be passed.",
@@ -24,6 +24,7 @@ examples:
24
24
  - multi_level_select_default: Default
25
25
  - multi_level_select_react_hook: React Hook
26
26
  - multi_level_select_single: Single Select
27
+ - multi_level_select_format_selected_display: Custom Single Select Display
27
28
  - multi_level_select_single_children_only: Single Select w/ Hidden Radios
28
29
  - multi_level_select_return_all_selected: Return All Selected
29
30
  - multi_level_select_input_display: With Input Display None
@@ -1,5 +1,6 @@
1
1
  export { default as MultiLevelSelectDefault } from "./_multi_level_select_default.jsx"
2
2
  export { default as MultiLevelSelectSingle } from "./_multi_level_select_single.jsx"
3
+ export { default as MultiLevelSelectFormatSelectedDisplay } from "./_multi_level_select_format_selected_display.jsx"
3
4
  export { default as MultiLevelSelectSingleChildrenOnly } from "./_multi_level_select_single_children_only.jsx"
4
5
  export { default as MultiLevelSelectReturnAllSelected } from "./_multi_level_select_return_all_selected.jsx"
5
6
  export { default as MultiLevelSelectSelectedIdsReact } from "./_multi_level_select_selected_ids_react.jsx"
@@ -23,6 +23,12 @@
23
23
  ],
24
24
  "default": ""
25
25
  },
26
+ "formatSelectedDisplay": {
27
+ "type": "function",
28
+ "platforms": [
29
+ "react"
30
+ ]
31
+ },
26
32
  "inputDisplay": {
27
33
  "type": "enum",
28
34
  "platforms": [
@@ -384,8 +384,47 @@ describe('MultiLevelSelect single variant', () => {
384
384
 
385
385
  expect(input.value).toBe('Power Home Remodeling')
386
386
  })
387
+
388
+ test('should format the selected input display with hierarchy context', () => {
389
+ render(
390
+ <MultiLevelSelect
391
+ data={{ testid: testId }}
392
+ formatSelectedDisplay={(item, { path }) =>
393
+ path.map(({ label }) => label).join(' / ')
394
+ }
395
+ treeData={treeData}
396
+ variant="single"
397
+ />
398
+ )
399
+ const kit = screen.getByTestId(testId)
400
+ const input = kit.querySelector('#multiselect_input')
401
+ fireEvent.click(input)
402
+ fireEvent.click(kit.querySelector('#people1'))
403
+
404
+ expect(input.value).toBe('Power Home Remodeling / People')
405
+ })
406
+
407
+ test('should format the selected input display from selectedIds', () => {
408
+ render(
409
+ <MultiLevelSelect
410
+ data={{ testid: testId }}
411
+ formatSelectedDisplay={(item, { ancestors }) => {
412
+ const parent = ancestors[ancestors.length - 1]
413
+ return `${parent.label}: ${item.label}`
414
+ }}
415
+ selectedIds={['talent1']}
416
+ treeData={treeData}
417
+ variant="single"
418
+ />
419
+ )
420
+ const kit = screen.getByTestId(testId)
421
+ const input = kit.querySelector('#multiselect_input')
422
+
423
+ expect(input.value).toBe('People: Talent Acquisition')
424
+ })
387
425
  })
388
426
 
427
+
389
428
  describe('MultiLevelSelect disabled parent behavior', () => {
390
429
  test('children of disabled parent should also be disabled in single variant', () => {
391
430
  render(
@@ -833,4 +872,3 @@ describe('PbMultiLevelSelect Rails enhanced element', () => {
833
872
  expect(element.querySelectorAll('.dropdown_item').length).toBeGreaterThan(0)
834
873
  })
835
874
  })
836
-
@@ -8,6 +8,7 @@ import Nav from '../../pb_nav/_nav'
8
8
  import NavItem from '../../pb_nav/_item'
9
9
 
10
10
  import { ToolbarTypes } from './EditorTypes'
11
+ import { normalizeListSelection } from './listSelection'
11
12
 
12
13
  const ToolbarDropdown = ({editor}: any): React.ReactElement => {
13
14
  const [showPopover, setShowPopover] = useState(false)
@@ -46,14 +47,20 @@ const toolbarDropdownItems = [
46
47
  icon: "list",
47
48
  isActive: editor.isActive("bulletList"),
48
49
  text: "Bullet List",
49
- onclick: () => editor.chain().focus().toggleBulletList().run(),
50
+ onclick: () => {
51
+ normalizeListSelection(editor)
52
+ editor.chain().focus().toggleBulletList().run()
53
+ },
50
54
  },
51
55
  {
52
56
  node: "orderedList",
53
57
  icon: "list-ol",
54
58
  isActive: editor.isActive("orderedList"),
55
59
  text: "Ordered List",
56
- onclick: () => editor.chain().focus().toggleOrderedList().run()
60
+ onclick: () => {
61
+ normalizeListSelection(editor)
62
+ editor.chain().focus().toggleOrderedList().run()
63
+ }
57
64
  ,
58
65
  },
59
66
  {
@@ -0,0 +1,59 @@
1
+ import { Editor } from "@tiptap/core"
2
+
3
+ type ListSelection = { from: number, to: number } | null
4
+
5
+ const getNormalizedListSelection = (editor: Editor): ListSelection => {
6
+ const { selection } = editor.state
7
+
8
+ if (selection.empty) return null
9
+
10
+ const { $from, $to } = selection
11
+ let { from, to } = selection
12
+
13
+ if (
14
+ $from.depth > 0 &&
15
+ $from.parent.isTextblock &&
16
+ $from.parentOffset === $from.parent.content.size
17
+ ) {
18
+ const nextBlockStart = $from.after($from.depth) + 1
19
+ if (nextBlockStart < to) from = nextBlockStart
20
+ }
21
+
22
+ if (
23
+ $to.depth > 0 &&
24
+ $to.parent.isTextblock &&
25
+ $to.parentOffset === 0
26
+ ) {
27
+ const previousBlockEnd = $to.before($to.depth) - 1
28
+ if (previousBlockEnd > from) to = previousBlockEnd
29
+ }
30
+
31
+ return from === selection.from && to === selection.to ? null : { from, to }
32
+ }
33
+
34
+ const normalizeListSelection = (editor: Editor): void => {
35
+ const initialSelection = editor.state.selection
36
+
37
+ if (
38
+ !initialSelection.empty &&
39
+ initialSelection.$from.nodeBefore?.type.name === "hardBreak"
40
+ ) {
41
+ const selectedSize = initialSelection.to - initialSelection.from
42
+ const breakPosition = initialSelection.from - 1
43
+
44
+ editor.chain()
45
+ .deleteRange({ from: breakPosition, to: initialSelection.from })
46
+ .setTextSelection(breakPosition)
47
+ .splitBlock()
48
+ .run()
49
+
50
+ const from = editor.state.selection.from
51
+ editor.commands.setTextSelection({ from, to: from + selectedSize })
52
+ }
53
+
54
+ const selection = getNormalizedListSelection(editor)
55
+
56
+ if (selection) editor.commands.setTextSelection(selection)
57
+ }
58
+
59
+ export { getNormalizedListSelection, normalizeListSelection }
@@ -0,0 +1,100 @@
1
+ const NEUTRAL_CLIPBOARD_TAGS = new Set(["html", "head", "body", "meta", "div", "span", "p"])
2
+ const MARKDOWN_SOURCE_TAGS = new Set([...NEUTRAL_CLIPBOARD_TAGS, "pre", "code"])
3
+
4
+ type MarkdownJSONNode = {
5
+ content?: MarkdownJSONNode[],
6
+ marks?: unknown[],
7
+ type: string,
8
+ }
9
+
10
+ type MarkdownDocumentNode = {
11
+ content: unknown,
12
+ toJSON: () => MarkdownJSONNode,
13
+ type: { schema: unknown },
14
+ }
15
+
16
+ type MarkdownParser = {
17
+ parse: (text: string) => MarkdownDocumentNode | null,
18
+ }
19
+
20
+ type MarkdownDOMSerializer = {
21
+ fromSchema: (schema: unknown) => {
22
+ serializeFragment: (content: unknown) => DocumentFragment,
23
+ },
24
+ }
25
+
26
+ type MarkdownDependencies = {
27
+ parser: MarkdownParser,
28
+ serializer: MarkdownDOMSerializer,
29
+ }
30
+
31
+ const hasRichTextFormatting = (html: string, text: string): boolean => {
32
+ if (!html) return false
33
+
34
+ const clipboardDocument = new DOMParser().parseFromString(html, "text/html")
35
+ const elements = [...clipboardDocument.querySelectorAll("*")]
36
+ const hasStyledElement = elements.some((element) => element.hasAttribute("style"))
37
+ if (hasStyledElement) return true
38
+
39
+ const hasOnlyNeutralTags = elements.every((element) =>
40
+ NEUTRAL_CLIPBOARD_TAGS.has(element.tagName.toLowerCase())
41
+ )
42
+ if (hasOnlyNeutralTags) return false
43
+
44
+ const hasOnlyMarkdownSourceTags = elements.every((element) =>
45
+ MARKDOWN_SOURCE_TAGS.has(element.tagName.toLowerCase())
46
+ )
47
+ const sourceTextMatches = clipboardDocument.body.textContent?.trim() === text.trim()
48
+ return !(hasOnlyMarkdownSourceTags && sourceTextMatches)
49
+ }
50
+
51
+ const hasMarkdownFormatting = (node: MarkdownJSONNode): boolean => {
52
+ const isFormattedNode = !["doc", "paragraph", "text"].includes(node.type)
53
+ if (isFormattedNode || node.marks?.length) return true
54
+
55
+ return node.content?.some(hasMarkdownFormatting) || false
56
+ }
57
+
58
+ const createMarkdownToHTML = (
59
+ markdownParser: MarkdownParser,
60
+ domSerializer: MarkdownDOMSerializer
61
+ ) => (text: string): string | null => {
62
+ const documentNode = markdownParser.parse(text)
63
+ if (!documentNode || !hasMarkdownFormatting(documentNode.toJSON())) return null
64
+
65
+ const container = document.createElement("div")
66
+ const serializer = domSerializer.fromSchema(documentNode.type.schema)
67
+ container.appendChild(serializer.serializeFragment(documentNode.content))
68
+ container.querySelectorAll("[data-tight]").forEach((node) => node.removeAttribute("data-tight"))
69
+
70
+ return container.innerHTML
71
+ }
72
+
73
+ const handleMarkdownPaste = (
74
+ event: ClipboardEvent,
75
+ insertHTML: (html: string) => void,
76
+ markdownToHTML: (text: string) => string | null
77
+ ): boolean => {
78
+ const text = event.clipboardData?.getData("text/plain")
79
+ const richHtml = event.clipboardData?.getData("text/html")
80
+ if (!text || hasRichTextFormatting(richHtml, text)) return false
81
+
82
+ let html
83
+ try {
84
+ html = markdownToHTML(text)
85
+ } catch (_error) {
86
+ return false
87
+ }
88
+ if (!html) return false
89
+
90
+ event.preventDefault()
91
+ event.stopImmediatePropagation()
92
+ insertHTML(html)
93
+ return true
94
+ }
95
+
96
+ export {
97
+ createMarkdownToHTML,
98
+ handleMarkdownPaste,
99
+ }
100
+ export type { MarkdownDependencies }