@guardian/stand 0.0.2 → 0.0.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.
- package/README.md +4 -0
- package/dist/types/playwright/byline.mock.d.ts +3 -0
- package/dist/types/playwright-ct.config.d.ts +5 -0
- package/package.json +8 -5
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.prettierrc +0 -1
- package/.storybook/main.ts +0 -12
- package/.storybook/preview.tsx +0 -83
- package/CHANGELOG.md +0 -7
- package/dist/types/src/mocks/prosemirror-view.d.ts +0 -10
- package/eslint.config.js +0 -14
- package/jest-setup-after-env.ts +0 -1
- package/jest.config.js +0 -12
- package/rollup.config.js +0 -49
- package/src/byline/Byline.stories.tsx +0 -186
- package/src/byline/Byline.test.tsx +0 -450
- package/src/byline/Byline.tsx +0 -524
- package/src/byline/Preview.tsx +0 -59
- package/src/byline/contributors-fixture.ts +0 -1006
- package/src/byline/lib.test.ts +0 -179
- package/src/byline/lib.ts +0 -426
- package/src/byline/placeholder.ts +0 -30
- package/src/byline/plugins.ts +0 -186
- package/src/byline/schema.ts +0 -62
- package/src/byline/styles.ts +0 -246
- package/src/byline/theme.ts +0 -45
- package/src/byline/util.ts +0 -5
- package/src/index.ts +0 -2
- package/src/mocks/prosemirror-view.ts +0 -19
- package/tsconfig.json +0 -19
- /package/dist/types/{src/byline/Byline.test.d.ts → playwright/byline.spec.d.ts} +0 -0
package/src/byline/lib.test.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
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
|
-
});
|
package/src/byline/lib.ts
DELETED
|
@@ -1,426 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
};
|