@guardian/stand 0.0.0 → 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 +19 -0
- package/dist/byline/Byline.cjs +375 -0
- package/dist/byline/Byline.js +273 -0
- package/dist/byline/Preview.cjs +52 -0
- package/dist/byline/Preview.js +26 -0
- package/dist/byline/lib.cjs +240 -0
- package/dist/byline/lib.js +181 -0
- package/dist/byline/placeholder.cjs +29 -0
- package/dist/byline/placeholder.js +27 -0
- package/dist/byline/plugins.cjs +144 -0
- package/dist/byline/plugins.js +123 -0
- package/dist/byline/schema.cjs +66 -0
- package/dist/byline/schema.js +59 -0
- package/dist/byline/styles.cjs +244 -0
- package/dist/byline/styles.js +234 -0
- package/dist/index.cjs +4 -4
- package/dist/index.js +1 -5
- package/dist/types/.storybook/main.d.ts +3 -0
- package/dist/types/.storybook/preview.d.ts +3 -0
- package/dist/types/jest-setup-after-env.d.ts +1 -0
- package/dist/types/playwright/byline.mock.d.ts +3 -0
- package/dist/types/playwright/byline.spec.d.ts +1 -0
- package/dist/types/playwright-ct.config.d.ts +5 -0
- package/dist/types/src/byline/Byline.d.ts +17 -0
- package/dist/types/src/byline/Byline.stories.d.ts +206 -0
- package/dist/types/src/byline/Preview.d.ts +4 -0
- package/dist/types/src/byline/contributors-fixture.d.ts +1 -0
- package/dist/types/src/byline/lib.d.ts +48 -0
- package/dist/types/src/byline/lib.test.d.ts +1 -0
- package/dist/types/src/byline/placeholder.d.ts +2 -0
- package/dist/types/src/byline/plugins.d.ts +4 -0
- package/dist/types/src/byline/schema.d.ts +2 -0
- package/dist/types/src/byline/styles.d.ts +11 -0
- package/dist/types/src/byline/theme.d.ts +44 -0
- package/dist/types/src/byline/util.d.ts +3 -0
- package/dist/types/src/index.d.ts +2 -0
- package/package.json +60 -126
- package/LICENSE +0 -201
- package/dist/index.d.ts +0 -3
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Stand - a tools component library
|
|
2
|
+
|
|
3
|
+
_Find what you need on the (news)stand!_
|
|
4
|
+
|
|
5
|
+
Stand is component library for Guardian editorial tools. It is co-located within flexible-content as Composer is expected to be the main consumer of the UI components within Stand. But any editorial tool should be able to make use of the components as an npm package - `@guardian/stand` - and developers should feel comfortable contributing.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
- Run `./setup.sh` in the project root (flexible-content) directory to set up pnpm, install dependencies, and build the project.
|
|
10
|
+
|
|
11
|
+
## Tasks
|
|
12
|
+
|
|
13
|
+
- Run `pnpm install` to install dependencies.
|
|
14
|
+
- Run `pnpm build` to build, this makes any changes available to flexible-frontend
|
|
15
|
+
- Run `pnpm storybook` to run Storybook
|
|
16
|
+
|
|
17
|
+
## Contributing
|
|
18
|
+
|
|
19
|
+
See the [Contributing to Stand](./CONTRIBUTING.md) documentation
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsxRuntime = require('@emotion/react/jsx-runtime');
|
|
4
|
+
var prosemirrorInvisibles = require('@guardian/prosemirror-invisibles');
|
|
5
|
+
var prosemirrorDropcursor = require('prosemirror-dropcursor');
|
|
6
|
+
var prosemirrorHistory = require('prosemirror-history');
|
|
7
|
+
var prosemirrorState = require('prosemirror-state');
|
|
8
|
+
var prosemirrorView = require('prosemirror-view');
|
|
9
|
+
var react = require('react');
|
|
10
|
+
var lib = require('./lib.cjs');
|
|
11
|
+
var placeholder = require('./placeholder.cjs');
|
|
12
|
+
var plugins = require('./plugins.cjs');
|
|
13
|
+
var Preview = require('./Preview.cjs');
|
|
14
|
+
var schema = require('./schema.cjs');
|
|
15
|
+
var styles = require('./styles.cjs');
|
|
16
|
+
|
|
17
|
+
const Byline = ({
|
|
18
|
+
theme,
|
|
19
|
+
allowUntaggedContributors,
|
|
20
|
+
contributorLimit,
|
|
21
|
+
enablePreview,
|
|
22
|
+
placeholder: placeholder$1,
|
|
23
|
+
initialValue,
|
|
24
|
+
readOnly,
|
|
25
|
+
handleSave,
|
|
26
|
+
searchContributors,
|
|
27
|
+
onBlur
|
|
28
|
+
}) => {
|
|
29
|
+
const editorRef = react.useRef(null);
|
|
30
|
+
const viewRef = react.useRef(null);
|
|
31
|
+
const dropdownRef = react.useRef(null);
|
|
32
|
+
const [currentText, setCurrentText] = react.useState("");
|
|
33
|
+
const [currentOptionIndex, setCurrentOptionIndex] = react.useState(0);
|
|
34
|
+
const [taggedContributors, setTaggedContributors] = react.useState([]);
|
|
35
|
+
const [currentDoc, setCurrentDoc] = react.useState(null);
|
|
36
|
+
const [showDropdown, setShowDropdown] = react.useState(false);
|
|
37
|
+
const isTypingFromStartRange = react.useRef(
|
|
38
|
+
void 0
|
|
39
|
+
);
|
|
40
|
+
const trackTypingFromStart = (tr) => {
|
|
41
|
+
const isCursorSelection = tr.selection.from === tr.selection.to;
|
|
42
|
+
const currentPosition = tr.selection.from;
|
|
43
|
+
const hasChanges = tr.steps.length > 0;
|
|
44
|
+
if (!isCursorSelection) {
|
|
45
|
+
isTypingFromStartRange.current = void 0;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!isTypingFromStartRange.current && currentPosition === 1 && hasChanges) {
|
|
49
|
+
isTypingFromStartRange.current = {
|
|
50
|
+
start: 1,
|
|
51
|
+
maxReached: 1,
|
|
52
|
+
lastPosition: 1
|
|
53
|
+
};
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isTypingFromStartRange.current) {
|
|
57
|
+
const { start, maxReached, lastPosition } = isTypingFromStartRange.current;
|
|
58
|
+
const isWithinRange = currentPosition >= start && currentPosition <= maxReached;
|
|
59
|
+
const isExtendingRange = hasChanges && currentPosition === maxReached + 1;
|
|
60
|
+
if (isWithinRange || isExtendingRange) {
|
|
61
|
+
if (hasChanges) {
|
|
62
|
+
const positionDelta = currentPosition - lastPosition;
|
|
63
|
+
if (positionDelta < 0) {
|
|
64
|
+
isTypingFromStartRange.current.maxReached = maxReached - 1;
|
|
65
|
+
} else if (positionDelta > 0) {
|
|
66
|
+
isTypingFromStartRange.current.maxReached = maxReached + 1;
|
|
67
|
+
}
|
|
68
|
+
isTypingFromStartRange.current.lastPosition = currentPosition;
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
isTypingFromStartRange.current = void 0;
|
|
74
|
+
};
|
|
75
|
+
const getShouldShowDropdown = (view, selectedText) => {
|
|
76
|
+
if (readOnly) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const showDropdownBasedOnProps = !!searchContributors || !!allowUntaggedContributors;
|
|
80
|
+
if (lib.hasHitContributorLimit(view.state.doc, contributorLimit)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return selectedText.trim() !== "" && showDropdownBasedOnProps;
|
|
84
|
+
};
|
|
85
|
+
const [enterHit, setEnterHit] = react.useState(false);
|
|
86
|
+
const checkDropdownVisibility = () => {
|
|
87
|
+
if (dropdownRef.current?.checkVisibility) {
|
|
88
|
+
return dropdownRef.current.checkVisibility();
|
|
89
|
+
} else {
|
|
90
|
+
return dropdownRef.current?.offsetParent !== null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
react.useEffect(() => {
|
|
94
|
+
if (!editorRef.current) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const initialDoc = lib.convertBylineModelToNode(initialValue);
|
|
98
|
+
const state = prosemirrorState.EditorState.create({
|
|
99
|
+
schema: schema.bylineEditorSchema,
|
|
100
|
+
plugins: [
|
|
101
|
+
prosemirrorDropcursor.dropCursor(),
|
|
102
|
+
plugins.clipboardPlugin(allowUntaggedContributors, contributorLimit),
|
|
103
|
+
prosemirrorHistory.history(),
|
|
104
|
+
plugins.keybindings(),
|
|
105
|
+
prosemirrorInvisibles.createInvisiblesPlugin([prosemirrorInvisibles.space, prosemirrorInvisibles.nbSpace], {
|
|
106
|
+
displayLineEndSelection: true,
|
|
107
|
+
shouldShowInvisibles: true
|
|
108
|
+
}),
|
|
109
|
+
plugins.bylinePlugin(),
|
|
110
|
+
placeholder.createPlaceholderPlugin(placeholder$1 ?? "Enter a byline...")
|
|
111
|
+
],
|
|
112
|
+
doc: initialDoc
|
|
113
|
+
});
|
|
114
|
+
setCurrentDoc(initialDoc);
|
|
115
|
+
viewRef.current = new prosemirrorView.EditorView(editorRef.current, {
|
|
116
|
+
state,
|
|
117
|
+
editable: () => !readOnly,
|
|
118
|
+
attributes: {
|
|
119
|
+
role: "combobox",
|
|
120
|
+
"aria-label": "byline",
|
|
121
|
+
"aria-controls": "byline-dropdown",
|
|
122
|
+
"aria-expanded": "false",
|
|
123
|
+
"data-testid": "byline-input",
|
|
124
|
+
...readOnly && { "aria-readonly": "true" }
|
|
125
|
+
},
|
|
126
|
+
handleDOMEvents: {
|
|
127
|
+
keydown: (view, event) => {
|
|
128
|
+
if (readOnly) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (event.key === "Escape") {
|
|
132
|
+
setShowDropdown(false);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (event.key === "ArrowDown") {
|
|
136
|
+
if (checkDropdownVisibility()) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
setCurrentOptionIndex((currentOptionIndex2) => {
|
|
139
|
+
return currentOptionIndex2 + 1;
|
|
140
|
+
});
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (event.key === "ArrowUp") {
|
|
146
|
+
if (checkDropdownVisibility()) {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
setCurrentOptionIndex((currentOptionIndex2) => {
|
|
149
|
+
return currentOptionIndex2 - 1;
|
|
150
|
+
});
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (event.key === "Enter") {
|
|
156
|
+
if (checkDropdownVisibility()) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
setEnterHit(true);
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
},
|
|
164
|
+
blur: (_view, event) => {
|
|
165
|
+
if (readOnly) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (!dropdownRef.current?.contains(
|
|
169
|
+
event.relatedTarget
|
|
170
|
+
)) {
|
|
171
|
+
setShowDropdown(false);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
dispatchTransaction(tr) {
|
|
177
|
+
if (readOnly) {
|
|
178
|
+
if (tr.docChanged) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (viewRef.current) {
|
|
182
|
+
const newState = viewRef.current.state.apply(tr);
|
|
183
|
+
viewRef.current.updateState(newState);
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (viewRef.current?.hasFocus()) {
|
|
188
|
+
trackTypingFromStart(tr);
|
|
189
|
+
const newState = viewRef.current.state.apply(tr);
|
|
190
|
+
viewRef.current.updateState(newState);
|
|
191
|
+
const { selectedText } = lib.getCurrentText(
|
|
192
|
+
newState.doc,
|
|
193
|
+
newState.selection.from,
|
|
194
|
+
newState.selection.to,
|
|
195
|
+
isTypingFromStartRange.current
|
|
196
|
+
);
|
|
197
|
+
setCurrentText(selectedText);
|
|
198
|
+
const shouldShowDropdown = getShouldShowDropdown(
|
|
199
|
+
viewRef.current,
|
|
200
|
+
selectedText
|
|
201
|
+
);
|
|
202
|
+
if (shouldShowDropdown && searchContributors) {
|
|
203
|
+
void searchContributors(selectedText).then((contributors) => {
|
|
204
|
+
setTaggedContributors(contributors);
|
|
205
|
+
}).catch((error) => {
|
|
206
|
+
console.error(
|
|
207
|
+
"Error fetching tagged contributors:",
|
|
208
|
+
error
|
|
209
|
+
);
|
|
210
|
+
setTaggedContributors([]);
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
setTaggedContributors([]);
|
|
214
|
+
}
|
|
215
|
+
setShowDropdown(shouldShowDropdown);
|
|
216
|
+
if (tr.docChanged) {
|
|
217
|
+
setCurrentDoc(newState.doc);
|
|
218
|
+
handleSave(lib.convertNodeToBylineModel(newState.doc));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return () => {
|
|
224
|
+
viewRef.current?.destroy();
|
|
225
|
+
};
|
|
226
|
+
}, []);
|
|
227
|
+
react.useEffect(() => {
|
|
228
|
+
const numberOfOptions = taggedContributors.length + (allowUntaggedContributors ? 1 : 0);
|
|
229
|
+
if (numberOfOptions) {
|
|
230
|
+
if (currentOptionIndex < 0) {
|
|
231
|
+
setCurrentOptionIndex(numberOfOptions - 1);
|
|
232
|
+
} else {
|
|
233
|
+
setCurrentOptionIndex(currentOptionIndex % numberOfOptions);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (showDropdown) {
|
|
237
|
+
const editor = document.querySelector("[role=combobox]");
|
|
238
|
+
editor?.setAttribute(
|
|
239
|
+
"aria-activedescendant",
|
|
240
|
+
`contributor-option-${currentOptionIndex}`
|
|
241
|
+
);
|
|
242
|
+
editor?.setAttribute("aria-expanded", "true");
|
|
243
|
+
}
|
|
244
|
+
}, [
|
|
245
|
+
currentOptionIndex,
|
|
246
|
+
showDropdown,
|
|
247
|
+
taggedContributors.length,
|
|
248
|
+
allowUntaggedContributors
|
|
249
|
+
]);
|
|
250
|
+
react.useEffect(() => {
|
|
251
|
+
if (showDropdown && currentOptionIndex >= 0) {
|
|
252
|
+
const selectedOption = document.getElementById(
|
|
253
|
+
`contributor-option-${currentOptionIndex}`
|
|
254
|
+
);
|
|
255
|
+
if (selectedOption && dropdownRef.current) {
|
|
256
|
+
selectedOption.scrollIntoView({
|
|
257
|
+
behavior: "smooth",
|
|
258
|
+
block: "nearest"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}, [currentOptionIndex, showDropdown]);
|
|
263
|
+
react.useEffect(() => {
|
|
264
|
+
if (enterHit) {
|
|
265
|
+
if (allowUntaggedContributors && currentOptionIndex === taggedContributors.length) {
|
|
266
|
+
lib.addUntaggedContributor(
|
|
267
|
+
viewRef,
|
|
268
|
+
setShowDropdown,
|
|
269
|
+
contributorLimit,
|
|
270
|
+
isTypingFromStartRange.current
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
const contributorToAdd = taggedContributors[currentOptionIndex];
|
|
274
|
+
if (contributorToAdd) {
|
|
275
|
+
lib.addTaggedContributor(
|
|
276
|
+
contributorToAdd,
|
|
277
|
+
viewRef,
|
|
278
|
+
setShowDropdown,
|
|
279
|
+
contributorLimit,
|
|
280
|
+
isTypingFromStartRange.current
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
setEnterHit(false);
|
|
285
|
+
}
|
|
286
|
+
}, [
|
|
287
|
+
currentOptionIndex,
|
|
288
|
+
enterHit,
|
|
289
|
+
taggedContributors,
|
|
290
|
+
contributorLimit,
|
|
291
|
+
allowUntaggedContributors
|
|
292
|
+
]);
|
|
293
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { css: styles.bylineContainerStyles, children: [
|
|
294
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { css: styles.bylineEditorStyles(theme?.editor), ref: editorRef, onBlur }),
|
|
295
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
296
|
+
"div",
|
|
297
|
+
{
|
|
298
|
+
ref: dropdownRef,
|
|
299
|
+
tabIndex: 0,
|
|
300
|
+
css: styles.dropdownContainerStyles(
|
|
301
|
+
showDropdown && // show the dropdown if there are tagged contributors to select or untagged contributors are allowed
|
|
302
|
+
(taggedContributors.length > 0 || !!allowUntaggedContributors),
|
|
303
|
+
theme?.dropdown
|
|
304
|
+
),
|
|
305
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("ul", { id: "byline-dropdown", role: "listbox", css: styles.dropdownUlStyles, children: [
|
|
306
|
+
taggedContributors.map((contributor, i) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
307
|
+
"li",
|
|
308
|
+
{
|
|
309
|
+
id: `contributor-option-${i}`,
|
|
310
|
+
role: "option",
|
|
311
|
+
"aria-selected": i === currentOptionIndex,
|
|
312
|
+
css: [
|
|
313
|
+
styles.dropdownLiStyles(theme),
|
|
314
|
+
i === currentOptionIndex && styles.selectedDropdownLiStyles(theme)
|
|
315
|
+
],
|
|
316
|
+
onMouseMove: () => {
|
|
317
|
+
if (currentOptionIndex !== i) {
|
|
318
|
+
setCurrentOptionIndex(i);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
onMouseDown: (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
lib.addTaggedContributor(
|
|
324
|
+
contributor,
|
|
325
|
+
viewRef,
|
|
326
|
+
setShowDropdown,
|
|
327
|
+
contributorLimit,
|
|
328
|
+
isTypingFromStartRange.current
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
children: contributor.internalLabel ?? contributor.label
|
|
332
|
+
},
|
|
333
|
+
contributor.tagId
|
|
334
|
+
)),
|
|
335
|
+
allowUntaggedContributors && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
336
|
+
"li",
|
|
337
|
+
{
|
|
338
|
+
role: "option",
|
|
339
|
+
id: `contributor-option-${taggedContributors.length}`,
|
|
340
|
+
"aria-selected": currentOptionIndex === taggedContributors.length,
|
|
341
|
+
css: [
|
|
342
|
+
styles.dropdownLiStyles(theme),
|
|
343
|
+
currentOptionIndex === taggedContributors.length && styles.selectedDropdownLiStyles(theme)
|
|
344
|
+
],
|
|
345
|
+
onMouseMove: () => {
|
|
346
|
+
if (currentOptionIndex !== taggedContributors.length) {
|
|
347
|
+
setCurrentOptionIndex(
|
|
348
|
+
taggedContributors.length
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
onMouseDown: (e) => {
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
lib.addUntaggedContributor(
|
|
355
|
+
viewRef,
|
|
356
|
+
setShowDropdown,
|
|
357
|
+
contributorLimit,
|
|
358
|
+
isTypingFromStartRange.current
|
|
359
|
+
);
|
|
360
|
+
},
|
|
361
|
+
children: [
|
|
362
|
+
'Add "',
|
|
363
|
+
currentText,
|
|
364
|
+
'" as untagged contributor'
|
|
365
|
+
]
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
] })
|
|
369
|
+
}
|
|
370
|
+
),
|
|
371
|
+
enablePreview && /* @__PURE__ */ jsxRuntime.jsx(Preview.Preview, { doc: currentDoc })
|
|
372
|
+
] });
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
exports.Byline = Byline;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { jsxs, jsx } from '@emotion/react/jsx-runtime';
|
|
2
|
+
import { createInvisiblesPlugin, space, nbSpace } from '@guardian/prosemirror-invisibles';
|
|
3
|
+
import { dropCursor } from 'prosemirror-dropcursor';
|
|
4
|
+
import { history } from 'prosemirror-history';
|
|
5
|
+
import { EditorState } from 'prosemirror-state';
|
|
6
|
+
import { EditorView } from 'prosemirror-view';
|
|
7
|
+
import { useRef, useState, useEffect } from 'react';
|
|
8
|
+
import { convertBylineModelToNode, getCurrentText, convertNodeToBylineModel, addUntaggedContributor, addTaggedContributor, hasHitContributorLimit } from './lib.js';
|
|
9
|
+
import { createPlaceholderPlugin } from './placeholder.js';
|
|
10
|
+
import { clipboardPlugin, keybindings, bylinePlugin } from './plugins.js';
|
|
11
|
+
import { Preview } from './Preview.js';
|
|
12
|
+
import { bylineEditorSchema } from './schema.js';
|
|
13
|
+
import { bylineContainerStyles, bylineEditorStyles, dropdownContainerStyles, dropdownUlStyles, dropdownLiStyles, selectedDropdownLiStyles } from './styles.js';
|
|
14
|
+
|
|
15
|
+
const Byline = ({ theme, allowUntaggedContributors, contributorLimit, enablePreview, placeholder, initialValue, readOnly, handleSave, searchContributors, onBlur }) => {
|
|
16
|
+
const editorRef = useRef(null);
|
|
17
|
+
const viewRef = useRef(null);
|
|
18
|
+
const dropdownRef = useRef(null);
|
|
19
|
+
const [currentText, setCurrentText] = useState("");
|
|
20
|
+
const [currentOptionIndex, setCurrentOptionIndex] = useState(0);
|
|
21
|
+
const [taggedContributors, setTaggedContributors] = useState([]);
|
|
22
|
+
const [currentDoc, setCurrentDoc] = useState(null);
|
|
23
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
24
|
+
const isTypingFromStartRange = useRef(void 0);
|
|
25
|
+
const trackTypingFromStart = (tr) => {
|
|
26
|
+
const isCursorSelection = tr.selection.from === tr.selection.to;
|
|
27
|
+
const currentPosition = tr.selection.from;
|
|
28
|
+
const hasChanges = tr.steps.length > 0;
|
|
29
|
+
if (!isCursorSelection) {
|
|
30
|
+
isTypingFromStartRange.current = void 0;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isTypingFromStartRange.current && currentPosition === 1 && hasChanges) {
|
|
34
|
+
isTypingFromStartRange.current = {
|
|
35
|
+
start: 1,
|
|
36
|
+
maxReached: 1,
|
|
37
|
+
lastPosition: 1
|
|
38
|
+
};
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (isTypingFromStartRange.current) {
|
|
42
|
+
const { start, maxReached, lastPosition } = isTypingFromStartRange.current;
|
|
43
|
+
const isWithinRange = currentPosition >= start && currentPosition <= maxReached;
|
|
44
|
+
const isExtendingRange = hasChanges && currentPosition === maxReached + 1;
|
|
45
|
+
if (isWithinRange || isExtendingRange) {
|
|
46
|
+
if (hasChanges) {
|
|
47
|
+
const positionDelta = currentPosition - lastPosition;
|
|
48
|
+
if (positionDelta < 0) {
|
|
49
|
+
isTypingFromStartRange.current.maxReached = maxReached - 1;
|
|
50
|
+
} else if (positionDelta > 0) {
|
|
51
|
+
isTypingFromStartRange.current.maxReached = maxReached + 1;
|
|
52
|
+
}
|
|
53
|
+
isTypingFromStartRange.current.lastPosition = currentPosition;
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
isTypingFromStartRange.current = void 0;
|
|
59
|
+
};
|
|
60
|
+
const getShouldShowDropdown = (view, selectedText) => {
|
|
61
|
+
if (readOnly) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const showDropdownBasedOnProps = !!searchContributors || !!allowUntaggedContributors;
|
|
65
|
+
if (hasHitContributorLimit(view.state.doc, contributorLimit)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return selectedText.trim() !== "" && showDropdownBasedOnProps;
|
|
69
|
+
};
|
|
70
|
+
const [enterHit, setEnterHit] = useState(false);
|
|
71
|
+
const checkDropdownVisibility = () => {
|
|
72
|
+
if (dropdownRef.current?.checkVisibility) {
|
|
73
|
+
return dropdownRef.current.checkVisibility();
|
|
74
|
+
} else {
|
|
75
|
+
return dropdownRef.current?.offsetParent !== null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!editorRef.current) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const initialDoc = convertBylineModelToNode(initialValue);
|
|
83
|
+
const state = EditorState.create({
|
|
84
|
+
schema: bylineEditorSchema,
|
|
85
|
+
plugins: [
|
|
86
|
+
dropCursor(),
|
|
87
|
+
clipboardPlugin(allowUntaggedContributors, contributorLimit),
|
|
88
|
+
history(),
|
|
89
|
+
keybindings(),
|
|
90
|
+
createInvisiblesPlugin([space, nbSpace], {
|
|
91
|
+
displayLineEndSelection: true,
|
|
92
|
+
shouldShowInvisibles: true
|
|
93
|
+
}),
|
|
94
|
+
bylinePlugin(),
|
|
95
|
+
createPlaceholderPlugin(placeholder ?? "Enter a byline...")
|
|
96
|
+
],
|
|
97
|
+
doc: initialDoc
|
|
98
|
+
});
|
|
99
|
+
setCurrentDoc(initialDoc);
|
|
100
|
+
viewRef.current = new EditorView(editorRef.current, {
|
|
101
|
+
state,
|
|
102
|
+
editable: () => !readOnly,
|
|
103
|
+
attributes: {
|
|
104
|
+
role: "combobox",
|
|
105
|
+
"aria-label": "byline",
|
|
106
|
+
"aria-controls": "byline-dropdown",
|
|
107
|
+
"aria-expanded": "false",
|
|
108
|
+
"data-testid": "byline-input",
|
|
109
|
+
...readOnly && { "aria-readonly": "true" }
|
|
110
|
+
},
|
|
111
|
+
handleDOMEvents: {
|
|
112
|
+
keydown: (view, event) => {
|
|
113
|
+
if (readOnly) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (event.key === "Escape") {
|
|
117
|
+
setShowDropdown(false);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (event.key === "ArrowDown") {
|
|
121
|
+
if (checkDropdownVisibility()) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
setCurrentOptionIndex((currentOptionIndex2) => {
|
|
124
|
+
return currentOptionIndex2 + 1;
|
|
125
|
+
});
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (event.key === "ArrowUp") {
|
|
131
|
+
if (checkDropdownVisibility()) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setCurrentOptionIndex((currentOptionIndex2) => {
|
|
134
|
+
return currentOptionIndex2 - 1;
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (event.key === "Enter") {
|
|
141
|
+
if (checkDropdownVisibility()) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
setEnterHit(true);
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
},
|
|
149
|
+
blur: (_view, event) => {
|
|
150
|
+
if (readOnly) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (!dropdownRef.current?.contains(event.relatedTarget)) {
|
|
154
|
+
setShowDropdown(false);
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
dispatchTransaction(tr) {
|
|
160
|
+
if (readOnly) {
|
|
161
|
+
if (tr.docChanged) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (viewRef.current) {
|
|
165
|
+
const newState = viewRef.current.state.apply(tr);
|
|
166
|
+
viewRef.current.updateState(newState);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (viewRef.current?.hasFocus()) {
|
|
171
|
+
trackTypingFromStart(tr);
|
|
172
|
+
const newState = viewRef.current.state.apply(tr);
|
|
173
|
+
viewRef.current.updateState(newState);
|
|
174
|
+
const { selectedText } = getCurrentText(newState.doc, newState.selection.from, newState.selection.to, isTypingFromStartRange.current);
|
|
175
|
+
setCurrentText(selectedText);
|
|
176
|
+
const shouldShowDropdown = getShouldShowDropdown(viewRef.current, selectedText);
|
|
177
|
+
if (shouldShowDropdown && searchContributors) {
|
|
178
|
+
void searchContributors(selectedText).then((contributors) => {
|
|
179
|
+
setTaggedContributors(contributors);
|
|
180
|
+
}).catch((error) => {
|
|
181
|
+
console.error("Error fetching tagged contributors:", error);
|
|
182
|
+
setTaggedContributors([]);
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
setTaggedContributors([]);
|
|
186
|
+
}
|
|
187
|
+
setShowDropdown(shouldShowDropdown);
|
|
188
|
+
if (tr.docChanged) {
|
|
189
|
+
setCurrentDoc(newState.doc);
|
|
190
|
+
handleSave(convertNodeToBylineModel(newState.doc));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return () => {
|
|
196
|
+
viewRef.current?.destroy();
|
|
197
|
+
};
|
|
198
|
+
}, []);
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const numberOfOptions = taggedContributors.length + (allowUntaggedContributors ? 1 : 0);
|
|
201
|
+
if (numberOfOptions) {
|
|
202
|
+
if (currentOptionIndex < 0) {
|
|
203
|
+
setCurrentOptionIndex(numberOfOptions - 1);
|
|
204
|
+
} else {
|
|
205
|
+
setCurrentOptionIndex(currentOptionIndex % numberOfOptions);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (showDropdown) {
|
|
209
|
+
const editor = document.querySelector("[role=combobox]");
|
|
210
|
+
editor?.setAttribute("aria-activedescendant", `contributor-option-${currentOptionIndex}`);
|
|
211
|
+
editor?.setAttribute("aria-expanded", "true");
|
|
212
|
+
}
|
|
213
|
+
}, [
|
|
214
|
+
currentOptionIndex,
|
|
215
|
+
showDropdown,
|
|
216
|
+
taggedContributors.length,
|
|
217
|
+
allowUntaggedContributors
|
|
218
|
+
]);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (showDropdown && currentOptionIndex >= 0) {
|
|
221
|
+
const selectedOption = document.getElementById(`contributor-option-${currentOptionIndex}`);
|
|
222
|
+
if (selectedOption && dropdownRef.current) {
|
|
223
|
+
selectedOption.scrollIntoView({
|
|
224
|
+
behavior: "smooth",
|
|
225
|
+
block: "nearest"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, [currentOptionIndex, showDropdown]);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (enterHit) {
|
|
232
|
+
if (allowUntaggedContributors && currentOptionIndex === taggedContributors.length) {
|
|
233
|
+
addUntaggedContributor(viewRef, setShowDropdown, contributorLimit, isTypingFromStartRange.current);
|
|
234
|
+
} else {
|
|
235
|
+
const contributorToAdd = taggedContributors[currentOptionIndex];
|
|
236
|
+
if (contributorToAdd) {
|
|
237
|
+
addTaggedContributor(contributorToAdd, viewRef, setShowDropdown, contributorLimit, isTypingFromStartRange.current);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
setEnterHit(false);
|
|
241
|
+
}
|
|
242
|
+
}, [
|
|
243
|
+
currentOptionIndex,
|
|
244
|
+
enterHit,
|
|
245
|
+
taggedContributors,
|
|
246
|
+
contributorLimit,
|
|
247
|
+
allowUntaggedContributors
|
|
248
|
+
]);
|
|
249
|
+
return jsxs("div", { css: bylineContainerStyles, children: [jsx("div", { css: bylineEditorStyles(theme?.editor), ref: editorRef, onBlur }), jsx("div", { ref: dropdownRef, tabIndex: 0, css: dropdownContainerStyles(showDropdown && // show the dropdown if there are tagged contributors to select or untagged contributors are allowed
|
|
250
|
+
(taggedContributors.length > 0 || !!allowUntaggedContributors), theme?.dropdown), children: jsxs("ul", { id: "byline-dropdown", role: "listbox", css: dropdownUlStyles, children: [taggedContributors.map((contributor, i) => jsx("li", { id: `contributor-option-${i}`, role: "option", "aria-selected": i === currentOptionIndex, css: [
|
|
251
|
+
dropdownLiStyles(theme),
|
|
252
|
+
i === currentOptionIndex && selectedDropdownLiStyles(theme)
|
|
253
|
+
], onMouseMove: () => {
|
|
254
|
+
if (currentOptionIndex !== i) {
|
|
255
|
+
setCurrentOptionIndex(i);
|
|
256
|
+
}
|
|
257
|
+
}, onMouseDown: (e) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
addTaggedContributor(contributor, viewRef, setShowDropdown, contributorLimit, isTypingFromStartRange.current);
|
|
260
|
+
}, children: contributor.internalLabel ?? contributor.label }, contributor.tagId)), allowUntaggedContributors && jsxs("li", { role: "option", id: `contributor-option-${taggedContributors.length}`, "aria-selected": currentOptionIndex === taggedContributors.length, css: [
|
|
261
|
+
dropdownLiStyles(theme),
|
|
262
|
+
currentOptionIndex === taggedContributors.length && selectedDropdownLiStyles(theme)
|
|
263
|
+
], onMouseMove: () => {
|
|
264
|
+
if (currentOptionIndex !== taggedContributors.length) {
|
|
265
|
+
setCurrentOptionIndex(taggedContributors.length);
|
|
266
|
+
}
|
|
267
|
+
}, onMouseDown: (e) => {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
addUntaggedContributor(viewRef, setShowDropdown, contributorLimit, isTypingFromStartRange.current);
|
|
270
|
+
}, children: ['Add "', currentText, '" as untagged contributor'] })] }) }), enablePreview && jsx(Preview, { doc: currentDoc })] });
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export { Byline };
|