@guardian/stand 0.0.0 → 0.0.2

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 (64) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.prettierrc +1 -0
  4. package/.storybook/main.ts +12 -0
  5. package/.storybook/preview.tsx +83 -0
  6. package/CHANGELOG.md +7 -0
  7. package/README.md +15 -0
  8. package/dist/byline/Byline.cjs +375 -0
  9. package/dist/byline/Byline.js +273 -0
  10. package/dist/byline/Preview.cjs +52 -0
  11. package/dist/byline/Preview.js +26 -0
  12. package/dist/byline/lib.cjs +240 -0
  13. package/dist/byline/lib.js +181 -0
  14. package/dist/byline/placeholder.cjs +29 -0
  15. package/dist/byline/placeholder.js +27 -0
  16. package/dist/byline/plugins.cjs +144 -0
  17. package/dist/byline/plugins.js +123 -0
  18. package/dist/byline/schema.cjs +66 -0
  19. package/dist/byline/schema.js +59 -0
  20. package/dist/byline/styles.cjs +244 -0
  21. package/dist/byline/styles.js +234 -0
  22. package/dist/index.cjs +4 -4
  23. package/dist/index.js +1 -5
  24. package/dist/types/.storybook/main.d.ts +3 -0
  25. package/dist/types/.storybook/preview.d.ts +3 -0
  26. package/dist/types/jest-setup-after-env.d.ts +1 -0
  27. package/dist/types/src/byline/Byline.d.ts +17 -0
  28. package/dist/types/src/byline/Byline.stories.d.ts +206 -0
  29. package/dist/types/src/byline/Byline.test.d.ts +1 -0
  30. package/dist/types/src/byline/Preview.d.ts +4 -0
  31. package/dist/types/src/byline/contributors-fixture.d.ts +1 -0
  32. package/dist/types/src/byline/lib.d.ts +48 -0
  33. package/dist/types/src/byline/lib.test.d.ts +1 -0
  34. package/dist/types/src/byline/placeholder.d.ts +2 -0
  35. package/dist/types/src/byline/plugins.d.ts +4 -0
  36. package/dist/types/src/byline/schema.d.ts +2 -0
  37. package/dist/types/src/byline/styles.d.ts +11 -0
  38. package/dist/types/src/byline/theme.d.ts +44 -0
  39. package/dist/types/src/byline/util.d.ts +3 -0
  40. package/dist/types/src/index.d.ts +2 -0
  41. package/dist/types/src/mocks/prosemirror-view.d.ts +10 -0
  42. package/eslint.config.js +14 -0
  43. package/jest-setup-after-env.ts +1 -0
  44. package/jest.config.js +12 -0
  45. package/package.json +60 -129
  46. package/rollup.config.js +49 -0
  47. package/src/byline/Byline.stories.tsx +186 -0
  48. package/src/byline/Byline.test.tsx +450 -0
  49. package/src/byline/Byline.tsx +524 -0
  50. package/src/byline/Preview.tsx +59 -0
  51. package/src/byline/contributors-fixture.ts +1006 -0
  52. package/src/byline/lib.test.ts +179 -0
  53. package/src/byline/lib.ts +426 -0
  54. package/src/byline/placeholder.ts +30 -0
  55. package/src/byline/plugins.ts +186 -0
  56. package/src/byline/schema.ts +62 -0
  57. package/src/byline/styles.ts +246 -0
  58. package/src/byline/theme.ts +45 -0
  59. package/src/byline/util.ts +5 -0
  60. package/src/index.ts +2 -0
  61. package/src/mocks/prosemirror-view.ts +19 -0
  62. package/tsconfig.json +19 -0
  63. package/LICENSE +0 -201
  64. package/dist/index.d.ts +0 -3
@@ -0,0 +1,179 @@
1
+ import { Node } from 'prosemirror-model';
2
+ import { detectNameInText, hasHitContributorLimit } from './lib';
3
+ import { bylineEditorSchema } from './schema';
4
+
5
+ describe('hasHitContributorLimit', () => {
6
+ it('returns false when the limit does not exist', () => {
7
+ const testDoc = new Node();
8
+
9
+ const result = hasHitContributorLimit(testDoc, undefined);
10
+
11
+ expect(result).toBe(false);
12
+ });
13
+ it('returns false when there are fewer nodes than the limit', () => {
14
+ const testDoc = bylineEditorSchema.nodes.doc.create({}, [
15
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 1' }),
16
+ bylineEditorSchema.text('Test'),
17
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 2' }),
18
+ ]);
19
+
20
+ const result = hasHitContributorLimit(testDoc, 3);
21
+
22
+ expect(result).toBe(false);
23
+ });
24
+ it('returns true when there are as many nodes as the limit', () => {
25
+ const testDoc = bylineEditorSchema.nodes.doc.create({}, [
26
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 1' }),
27
+ bylineEditorSchema.text('Test'),
28
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 2' }),
29
+ ]);
30
+
31
+ const result = hasHitContributorLimit(testDoc, 2);
32
+
33
+ expect(result).toBe(true);
34
+ });
35
+ it('returns true when there are more nodes than the limit', () => {
36
+ const testDoc = bylineEditorSchema.nodes.doc.create({}, [
37
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 1' }),
38
+ bylineEditorSchema.text('Test'),
39
+ bylineEditorSchema.nodes.chip.create({ label: 'Test Chip 2' }),
40
+ ]);
41
+
42
+ const result = hasHitContributorLimit(testDoc, 2);
43
+
44
+ expect(result).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('detectNameInText', () => {
49
+ it('matches basic names with spaces', () => {
50
+ const text = 'John Smith and Mary Jane Watson';
51
+
52
+ const result1 = detectNameInText(text, 0);
53
+ expect(result1?.name).toBe('John Smith');
54
+ expect(result1?.startIndex).toBe(0);
55
+ expect(result1?.endIndex).toBe(11);
56
+
57
+ const result2 = detectNameInText(text, 15);
58
+ expect(result2?.name).toBe('Mary Jane Watson');
59
+ expect(result2?.startIndex).toBe(15);
60
+ expect(result2?.endIndex).toBe(31);
61
+ });
62
+
63
+ it('matches names with periods (initials)', () => {
64
+ const text = 'A.C Skinner and J.R.R Tolkien';
65
+
66
+ const result1 = detectNameInText(text, 0);
67
+ expect(result1?.name).toBe('A.C Skinner');
68
+ expect(result1?.startIndex).toBe(0);
69
+ expect(result1?.endIndex).toBe(12);
70
+
71
+ const result2 = detectNameInText(text, 16);
72
+ expect(result2?.name).toBe('J.R.R Tolkien');
73
+ expect(result2?.startIndex).toBe(16);
74
+ expect(result2?.endIndex).toBe(29);
75
+ });
76
+
77
+ it('matches names with apostrophes (both straight and curly)', () => {
78
+ const text = "A'Name B Surname and M.D O’Neill";
79
+
80
+ const result1 = detectNameInText(text, 0);
81
+ expect(result1?.name).toBe("A'Name B Surname");
82
+ expect(result1?.startIndex).toBe(0);
83
+ expect(result1?.endIndex).toBe(17);
84
+
85
+ const result2 = detectNameInText(text, 21);
86
+ expect(result2?.name).toBe('M.D O’Neill');
87
+ expect(result2?.startIndex).toBe(21);
88
+ expect(result2?.endIndex).toBe(32);
89
+ });
90
+
91
+ it('matches names with ampersands', () => {
92
+ const text = 'Waz&Lenny';
93
+
94
+ const result = detectNameInText(text, 0);
95
+ expect(result?.name).toBe('Waz&Lenny');
96
+ expect(result?.startIndex).toBe(0);
97
+ expect(result?.endIndex).toBe(9);
98
+ });
99
+
100
+ it('matches names with hyphens', () => {
101
+ const text = 'Mary-Jane Watson played by Jean-Claude Van Damme';
102
+
103
+ const result1 = detectNameInText(text, 0);
104
+ expect(result1?.name).toBe('Mary-Jane Watson');
105
+ expect(result1?.startIndex).toBe(0);
106
+ expect(result1?.endIndex).toBe(17);
107
+
108
+ const result2 = detectNameInText(text, 27);
109
+ expect(result2?.name).toBe('Jean-Claude Van Damme');
110
+ expect(result2?.startIndex).toBe(27);
111
+ expect(result2?.endIndex).toBe(48);
112
+ });
113
+
114
+ it('handles multiple names in a string correctly when there is a . followed by a space', () => {
115
+ const text = 'A.C Skinner. J.R.R Tolkien';
116
+ const result1 = detectNameInText(text, 0);
117
+ expect(result1?.name).toBe('A.C Skinner');
118
+ expect(result1?.startIndex).toBe(0);
119
+ expect(result1?.endIndex).toBe(11);
120
+
121
+ const result2 = detectNameInText(text, 13);
122
+ expect(result2?.name).toBe('J.R.R Tolkien');
123
+ expect(result2?.startIndex).toBe(13);
124
+ expect(result2?.endIndex).toBe(26);
125
+ });
126
+
127
+ it('handles complex combinations', () => {
128
+ const text =
129
+ "A.B.C D’Artagnan-Jones. E.F.G Huckleberry Finn and H'G Wells";
130
+
131
+ const result1 = detectNameInText(text, 0);
132
+ expect(result1?.name).toBe('A.B.C D’Artagnan-Jones');
133
+ expect(result1?.startIndex).toBe(0);
134
+ expect(result1?.endIndex).toBe(22);
135
+
136
+ const result2 = detectNameInText(text, 24);
137
+ expect(result2?.name).toBe('E.F.G Huckleberry Finn');
138
+ expect(result2?.startIndex).toBe(24);
139
+ expect(result2?.endIndex).toBe(47);
140
+
141
+ const result3 = detectNameInText(text, 51);
142
+ expect(result3?.name).toBe("H'G Wells");
143
+ expect(result3?.startIndex).toBe(51);
144
+ expect(result3?.endIndex).toBe(60);
145
+ });
146
+
147
+ it('does not match lowercase starting words', () => {
148
+ const result1 = detectNameInText(
149
+ 'a.b.c d’artagnan-jones. e.f.g huckleberry finn and john smith',
150
+ 0,
151
+ );
152
+ expect(result1).toBeUndefined();
153
+ });
154
+
155
+ it('returns undefined when cursor is not within a name', () => {
156
+ const text = 'A.C Skinner. And then';
157
+ const result = detectNameInText(text, 12);
158
+ expect(result).toBeUndefined();
159
+ });
160
+
161
+ it('handles cursor at different positions within the same name', () => {
162
+ const text = 'John Smith and Jane Doe';
163
+
164
+ const result1 = detectNameInText(text, 0);
165
+ expect(result1?.name).toBe('John Smith');
166
+
167
+ const result2 = detectNameInText(text, 5);
168
+ expect(result2?.name).toBe('John Smith');
169
+
170
+ const result3 = detectNameInText(text, 10);
171
+ expect(result3?.name).toBe('John Smith');
172
+
173
+ const result4 = detectNameInText(text, 12);
174
+ expect(result4?.name).toBeUndefined();
175
+
176
+ const result5 = detectNameInText(text, 15);
177
+ expect(result5?.name).toBe('Jane Doe');
178
+ });
179
+ });
@@ -0,0 +1,426 @@
1
+ import type { Node } from 'prosemirror-model';
2
+ import type { Command } from 'prosemirror-state';
3
+ import type { EditorView } from 'prosemirror-view';
4
+ import { bylineEditorSchema } from './schema';
5
+
6
+ type ContributorType = 'tagged' | 'untagged';
7
+
8
+ export type TypingFromStartRange = {
9
+ start: number;
10
+ maxReached: number;
11
+ lastPosition: number;
12
+ };
13
+
14
+ // Name detection algorithm
15
+ export const detectNameInText = (
16
+ text: string,
17
+ cursorOffset: number,
18
+ isTypingFromStartRange?: TypingFromStartRange,
19
+ ): { name: string; startIndex: number; endIndex: number } | undefined => {
20
+ /**
21
+ * Name detection regex pattern that matches:
22
+ * - Names starting with uppercase letters followed optionally by 0 or more any letters ([\p{Lu}*][\p{L}*]*)
23
+ * - Connected by hyphens, apostrophes (straight ' and curly ’), periods, or ampersands ([-'.&’]+)
24
+ * - Or separated by spaces, but not when a period is followed by space ((?!\.\s)\s+)
25
+ * - With optional trailing space ([ ]?) so search doesn't break between first name and following names
26
+ * - * character anywhere in the string for match any in search
27
+ *
28
+ * Examples:
29
+ * ✓ "John Smith" - basic name with space
30
+ * ✓ "A.C Skinner" - initials with periods
31
+ * ✓ "J.R.R Tolkien" - multiple initials
32
+ * ✓ "D'Artagnan-Jones" - apostrophe in name
33
+ * ✓ "O’Connor" - curly apostrophe
34
+ * ✓ "Waz&Lenny" - ampersand connection
35
+ * ✓ "Mary-Jane Watson" - hyphenated name
36
+ * ✗ "A.C Skinner. Produced by" - stops at ". " (period + space)
37
+ */
38
+ const namePattern =
39
+ /[\p{Lu}*][\p{L}*]*(?:[-'.&’]+[\p{Lu}*][\p{L}*]*|(?!\.\s)\s+[\p{Lu}*][\p{L}*]*)*[ ]?/gu;
40
+
41
+ // When typing from start, only search in the text up to maxReached position
42
+ const searchText = isTypingFromStartRange
43
+ ? text.substring(0, isTypingFromStartRange.maxReached)
44
+ : text;
45
+
46
+ // Find all matches in the search text
47
+ // Using flat() to flatten the array of matches
48
+ // And then map to find index positions
49
+ const matches = Array.from(searchText.matchAll(namePattern))
50
+ .flat()
51
+ .map((match) => ({
52
+ name: match.trimEnd(),
53
+ startIndex: searchText.indexOf(match),
54
+ endIndex: searchText.indexOf(match) + match.length,
55
+ }));
56
+
57
+ if (matches.length === 0) {
58
+ return undefined;
59
+ }
60
+
61
+ // Find the name that contains the cursor position
62
+ const nameContainingCursor = matches.find(
63
+ (match) =>
64
+ cursorOffset >= 0 &&
65
+ cursorOffset >= match.startIndex &&
66
+ cursorOffset <= match.endIndex,
67
+ );
68
+
69
+ if (nameContainingCursor) {
70
+ return nameContainingCursor;
71
+ }
72
+
73
+ // if the cursor isn't within a name, then don't return a name
74
+ return undefined;
75
+ };
76
+
77
+ // Refocus the editor after inserting the chip, the setTimeout is used to ensure the focus happens after the DOM update
78
+ function refocusEditor(viewRef: React.MutableRefObject<EditorView | null>) {
79
+ setTimeout(() => {
80
+ viewRef.current?.focus();
81
+ }, 0);
82
+ }
83
+
84
+ // Function overloads to enforce type safety
85
+ export function insertChip(
86
+ text: string,
87
+ from: number,
88
+ to: number,
89
+ type: 'tagged',
90
+ tagId: string,
91
+ path?: string,
92
+ meta?: unknown,
93
+ ): Command;
94
+ export function insertChip(
95
+ text: string,
96
+ from: number,
97
+ to: number,
98
+ type: 'untagged',
99
+ tagId?: undefined,
100
+ meta?: undefined,
101
+ ): Command;
102
+ export function insertChip(
103
+ text: string,
104
+ from: number,
105
+ to: number,
106
+ type: ContributorType,
107
+ tagId?: string,
108
+ path?: string,
109
+ meta?: unknown,
110
+ ): Command {
111
+ const command: Command = (state, dispatch) => {
112
+ const chipNode = bylineEditorSchema.nodes.chip.create({
113
+ label: text,
114
+ type,
115
+ tagId,
116
+ path,
117
+ meta,
118
+ });
119
+
120
+ const tr = state.tr.replaceRangeWith(from, to, chipNode);
121
+
122
+ if (dispatch) {
123
+ dispatch(tr);
124
+ }
125
+ return true;
126
+ };
127
+
128
+ return command;
129
+ }
130
+
131
+ export const getCurrentText = (
132
+ doc: Node,
133
+ currentOffset: number,
134
+ toOffset: number,
135
+ isTypingFromStartRange?: TypingFromStartRange,
136
+ ): {
137
+ currentTextNode: Node | null;
138
+ startOffset: number;
139
+ endOffset: number;
140
+ selectedText: string;
141
+ hasSelection: boolean;
142
+ } => {
143
+ const hasSelection = currentOffset !== toOffset;
144
+ const selectedText = hasSelection
145
+ ? doc.textBetween(currentOffset, toOffset, ' ')
146
+ : '';
147
+
148
+ // If there's a selection, return the selected text info
149
+ if (hasSelection) {
150
+ return {
151
+ currentTextNode: null,
152
+ startOffset: -1,
153
+ endOffset: -1,
154
+ selectedText,
155
+ hasSelection: true,
156
+ };
157
+ }
158
+
159
+ // Otherwise, find the last text node and plausible name before the current position
160
+ let currentTextNode: Node | null = null;
161
+ let startOffset = -1;
162
+ let endOffset = -1;
163
+ let lastTextContent = '';
164
+
165
+ doc.descendants((node, pos) => {
166
+ // Stop traversing if we reach or pass the current offset
167
+ if (pos >= currentOffset) {
168
+ return false;
169
+ }
170
+
171
+ // If the node is a text node, check for a name
172
+ if (node.isText && node.textContent.trim()) {
173
+ // Calculate the relative cursor position within this text node
174
+ const relativeCursorOffset = currentOffset - pos;
175
+
176
+ // detect a name in the text content, and update the lastTextContent, startOffset, and endOffset accordingly
177
+ const detectedName = detectNameInText(
178
+ node.textContent,
179
+ relativeCursorOffset,
180
+ isTypingFromStartRange,
181
+ );
182
+
183
+ // If a name is detected, update the currentTextNode and offsets
184
+ if (detectedName) {
185
+ currentTextNode = node;
186
+ lastTextContent = detectedName.name;
187
+ startOffset = pos + detectedName.startIndex;
188
+ endOffset = pos + detectedName.endIndex;
189
+ }
190
+ } else if (node.type.name === 'chip') {
191
+ // Reset on chip - we want text after the last chip
192
+ currentTextNode = null;
193
+ startOffset = -1;
194
+ endOffset = -1;
195
+ lastTextContent = '';
196
+ }
197
+
198
+ return true; // continue traversing
199
+ });
200
+
201
+ return {
202
+ currentTextNode,
203
+ startOffset,
204
+ endOffset,
205
+ selectedText: lastTextContent,
206
+ hasSelection: false,
207
+ };
208
+ };
209
+
210
+ export const hasHitContributorLimit = (
211
+ doc: Node,
212
+ contributorLimit?: number,
213
+ ) => {
214
+ if (contributorLimit === undefined) {
215
+ return false;
216
+ }
217
+
218
+ const numberOfContributors = doc.children.filter(
219
+ (c) => c.type.name === 'chip',
220
+ ).length;
221
+
222
+ return numberOfContributors >= contributorLimit;
223
+ };
224
+
225
+ export type TaggedContributor = {
226
+ tagId: string; // unique id for the contributor
227
+ label: string; // display text for the contributor, usually their name
228
+ internalLabel?: string; // used internally for differentiation between same-name contributors, priority over label in search
229
+ path?: string; // optional path parameter linking to their Guardian profile, e.g. profile/joebloggs
230
+ // additional metadata e.g. the tag object from tag manager/capi
231
+ // this allows us to persist the meta data back to the consumer
232
+ // so it makes it possible to avoid additional network requests
233
+ // to load the full tag object
234
+ // use type guards, validation library (like zod), or an `as` assertion when using this
235
+ meta?: unknown;
236
+ };
237
+
238
+ export const addUntaggedContributor = (
239
+ viewRef: React.MutableRefObject<EditorView | null>,
240
+ setShowDropdown: React.Dispatch<React.SetStateAction<boolean>>,
241
+ contributorLimit?: number,
242
+ isTypingFromStartRange?: TypingFromStartRange,
243
+ ) => {
244
+ if (!viewRef.current) {
245
+ return;
246
+ }
247
+
248
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- fix avoid unbound method
249
+ const { state, dispatch } = viewRef.current;
250
+
251
+ const doc = state.doc;
252
+
253
+ if (hasHitContributorLimit(doc, contributorLimit)) {
254
+ return;
255
+ }
256
+
257
+ const {
258
+ currentTextNode,
259
+ startOffset,
260
+ endOffset,
261
+ selectedText,
262
+ hasSelection,
263
+ } = getCurrentText(
264
+ doc,
265
+ state.selection.from,
266
+ state.selection.to,
267
+ isTypingFromStartRange,
268
+ );
269
+
270
+ // If there's a selection, convert the selected text to a chip
271
+ if (hasSelection) {
272
+ setShowDropdown(false);
273
+ const result = insertChip(
274
+ selectedText,
275
+ state.selection.from,
276
+ state.selection.to,
277
+ 'untagged',
278
+ )(state, dispatch);
279
+
280
+ refocusEditor(viewRef);
281
+
282
+ return result;
283
+ }
284
+
285
+ // Otherwise, convert the last text node to a chip
286
+ if (!currentTextNode || startOffset === -1) {
287
+ console.warn('No text node found in the document');
288
+ return;
289
+ }
290
+
291
+ setShowDropdown(false);
292
+
293
+ const result = insertChip(
294
+ selectedText,
295
+ startOffset,
296
+ endOffset,
297
+ 'untagged',
298
+ )(state, dispatch);
299
+
300
+ refocusEditor(viewRef);
301
+
302
+ return result;
303
+ };
304
+
305
+ export const addTaggedContributor = (
306
+ contributor: TaggedContributor,
307
+ viewRef: React.MutableRefObject<EditorView | null>,
308
+ setShowDropdown: React.Dispatch<React.SetStateAction<boolean>>,
309
+ contributorLimit?: number,
310
+ isTypingFromStartRange?: TypingFromStartRange,
311
+ ) => {
312
+ if (!viewRef.current) {
313
+ return;
314
+ }
315
+
316
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- fix avoid unbound method
317
+ const { state, dispatch } = viewRef.current;
318
+
319
+ const doc = state.doc;
320
+
321
+ if (hasHitContributorLimit(doc, contributorLimit)) {
322
+ return;
323
+ }
324
+
325
+ const { currentTextNode, startOffset, endOffset, hasSelection } =
326
+ getCurrentText(
327
+ doc,
328
+ state.selection.from,
329
+ state.selection.to,
330
+ isTypingFromStartRange,
331
+ );
332
+
333
+ // If there's a selection, replace it with the tagged contributor
334
+ if (hasSelection) {
335
+ setShowDropdown(false);
336
+ const result = insertChip(
337
+ contributor.label,
338
+ state.selection.from,
339
+ state.selection.to,
340
+ 'tagged',
341
+ contributor.tagId,
342
+ contributor.path,
343
+ contributor.meta,
344
+ )(state, dispatch);
345
+
346
+ refocusEditor(viewRef);
347
+
348
+ return result;
349
+ }
350
+
351
+ // Otherwise, replace the last text node with the tagged contributor
352
+ if (!currentTextNode || startOffset === -1) {
353
+ console.warn('No text node found in the document');
354
+ return;
355
+ }
356
+
357
+ setShowDropdown(false);
358
+ const result = insertChip(
359
+ contributor.label,
360
+ startOffset,
361
+ endOffset,
362
+ 'tagged',
363
+ contributor.tagId,
364
+ contributor.path,
365
+ contributor.meta,
366
+ )(state, dispatch);
367
+
368
+ refocusEditor(viewRef);
369
+
370
+ return result;
371
+ };
372
+
373
+ type BylineText = {
374
+ type: 'text';
375
+ value: string;
376
+ };
377
+
378
+ type BylineContributor = {
379
+ type: 'contributor';
380
+ value: string; // display text for the contributor, usually their name
381
+ tagId?: string; // if tagId doesn't exist then it's an untagged contributor, usually a unique id for the tagged contributor
382
+ path?: string; // optional path parameter linking to their Guardian profile, e.g. profile/joebloggs
383
+ meta?: unknown; // additional metadata e.g. the tag object from tag manager/capi, use type guards, validation library (like zod), or an `as` assertion when using this
384
+ };
385
+
386
+ type BylinePart = BylineText | BylineContributor;
387
+
388
+ export type BylineModel = BylinePart[];
389
+
390
+ export const convertBylineModelToNode = (value?: BylineModel): Node => {
391
+ const nodes: Node[] = (value ?? []).map((part) => {
392
+ if (part.type === 'contributor') {
393
+ return bylineEditorSchema.nodes.chip.create({
394
+ label: part.value,
395
+ type: part.tagId ? 'tagged' : 'untagged',
396
+ tagId: part.tagId,
397
+ path: part.path,
398
+ meta: part.meta,
399
+ });
400
+ } else {
401
+ return bylineEditorSchema.text(part.value);
402
+ }
403
+ });
404
+ return bylineEditorSchema.node('doc', null, nodes);
405
+ };
406
+
407
+ export const convertNodeToBylineModel = (doc: Node): BylineModel => {
408
+ const model: BylineModel = [];
409
+ doc.forEach((node) => {
410
+ if (node.isText) {
411
+ model.push({
412
+ type: 'text',
413
+ value: node.text ?? '',
414
+ });
415
+ } else if (node.type.name === 'chip') {
416
+ model.push({
417
+ type: 'contributor',
418
+ value: node.attrs.label as string,
419
+ tagId: node.attrs.tagId as string,
420
+ path: node.attrs.path as string,
421
+ meta: node.attrs.meta as unknown,
422
+ });
423
+ }
424
+ });
425
+ return model;
426
+ };
@@ -0,0 +1,30 @@
1
+ import type { EditorState } from 'prosemirror-state';
2
+ import { Plugin } from 'prosemirror-state';
3
+ import { Decoration, DecorationSet } from 'prosemirror-view';
4
+
5
+ const getPlaceholder = (text: string) => {
6
+ const span = document.createElement('span');
7
+ span.innerHTML = text;
8
+ span.className = 'placeholder';
9
+
10
+ return span;
11
+ };
12
+
13
+ export const createPlaceholderPlugin = (text: string) => {
14
+ const shouldDisplayPlaceholder = (state: EditorState) =>
15
+ text !== '' && state.doc.childCount < 1;
16
+
17
+ return new Plugin({
18
+ props: {
19
+ decorations: (editorState) => {
20
+ const { doc } = editorState;
21
+ if (shouldDisplayPlaceholder(editorState)) {
22
+ return DecorationSet.create(doc, [
23
+ Decoration.widget(0, getPlaceholder(text)),
24
+ ]);
25
+ }
26
+ return DecorationSet.empty;
27
+ },
28
+ },
29
+ });
30
+ };