@livepreso/react-plugin-textfield 0.6.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.lintstagedrc.js +4 -4
- package/.rush/temp/shrinkwrap-deps.json +344 -49
- package/CHANGELOG.json +34 -0
- package/CHANGELOG.md +14 -1
- package/babel.config.js +7 -0
- package/components/BubbleMenu.js +33 -4
- package/components/LinkEditDialog.js +3 -1
- package/components/editor-toolbars/SubMenu.js +3 -1
- package/components/tiptap/Table.js +54 -18
- package/components/tiptap/TableCells.js +12 -4
- package/configs/table-toolbar-configuration.js +3 -1
- package/configs/toolbar-configuration.js +8 -3
- package/configs/toolbar-configuration.spec.js +70 -0
- package/index.js +141 -54
- package/jest.config.js +8 -0
- package/package.json +10 -4
- package/permits-table.js +59 -0
- package/test/style-stub.js +1 -0
- package/text-actions.js +147 -0
- package/text-actions.spec.js +37 -0
- package/utils/generate-border-icon.js +6 -2
package/index.js
CHANGED
|
@@ -10,9 +10,9 @@ import {
|
|
|
10
10
|
useFloating,
|
|
11
11
|
} from "@floating-ui/react-dom";
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
useCanEditCMSValKey,
|
|
14
|
+
useActions,
|
|
14
15
|
useModes,
|
|
15
|
-
usePrepEditable,
|
|
16
16
|
useSlide,
|
|
17
17
|
} from "@livepreso/content-react";
|
|
18
18
|
import Document from "@tiptap/extension-document";
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
import { PrimaryToolbar } from "./components/PrimaryToolbar.js";
|
|
31
31
|
import { TableToolbar } from "./components/TableToolbar.js";
|
|
32
32
|
import { generateToolbarOptions } from "./configs/generate-toolbar-options.js";
|
|
33
|
+
import { defineEditableSource, prop } from "@livepreso/webpack-deck";
|
|
34
|
+
|
|
35
|
+
import { PERMITS } from "./permits-table.js";
|
|
36
|
+
import { text } from "./text-actions.js";
|
|
33
37
|
import {
|
|
34
38
|
DEFAULT_TOOLBAR_CONFIG,
|
|
35
39
|
DEFAULT_TABLE_TOOLBAR_CONFIG,
|
|
@@ -81,9 +85,17 @@ function EditableTextField({
|
|
|
81
85
|
const { isUserTemplateLibrary } = useSlide();
|
|
82
86
|
const [textColor, setTextColor] = useState(null);
|
|
83
87
|
const initialValue = useRef(value);
|
|
88
|
+
|
|
89
|
+
// Values handed on and not yet seen coming back. One of them arriving as
|
|
90
|
+
// `value` is a late round trip, and setting it would undo what was typed
|
|
91
|
+
// since. Focus is no guide: blurring right after typing leaves one in flight.
|
|
92
|
+
const inFlight = useRef([initialValue.current]);
|
|
93
|
+
|
|
84
94
|
const tokens = allTokens.reduce((acc, token) => {
|
|
85
95
|
const { id } = token;
|
|
86
|
-
if (acc.some((accToken) => accToken.id === id))
|
|
96
|
+
if (acc.some((accToken) => accToken.id === id)) {
|
|
97
|
+
return acc;
|
|
98
|
+
}
|
|
87
99
|
return [...acc, token];
|
|
88
100
|
}, []);
|
|
89
101
|
|
|
@@ -178,12 +190,36 @@ function EditableTextField({
|
|
|
178
190
|
convertTokensToTextNodes(editor, tokenOptions);
|
|
179
191
|
}
|
|
180
192
|
},
|
|
181
|
-
onUpdate:
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
onUpdate: ({ editor }) => {
|
|
194
|
+
const html = editor.getHTML();
|
|
195
|
+
|
|
196
|
+
inFlight.current = [...inFlight.current, html];
|
|
197
|
+
onChange?.(html);
|
|
198
|
+
},
|
|
184
199
|
parseOptions: { preserveWhitespace: true },
|
|
185
200
|
});
|
|
186
201
|
|
|
202
|
+
// Show a value set from anywhere other than this editor.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!editor || editor.isDestroyed || typeof value !== "string") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (inFlight.current.includes(value)) {
|
|
209
|
+
inFlight.current = inFlight.current.filter((sent) => sent !== value);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (value === editor.getHTML()) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
editor.commands.setContent(value, {
|
|
218
|
+
emitUpdate: false,
|
|
219
|
+
parseOptions: { preserveWhitespace: true },
|
|
220
|
+
});
|
|
221
|
+
}, [editor, value]);
|
|
222
|
+
|
|
187
223
|
const { refs } = useFloating({
|
|
188
224
|
whileElementsMounted: autoUpdate,
|
|
189
225
|
placement: "top-start",
|
|
@@ -192,7 +228,9 @@ function EditableTextField({
|
|
|
192
228
|
|
|
193
229
|
useEffect(() => {
|
|
194
230
|
const preventPropagation = (event) => {
|
|
195
|
-
if (!isEditable || disableSpellcheck)
|
|
231
|
+
if (!isEditable || disableSpellcheck) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
196
234
|
event.stopPropagation();
|
|
197
235
|
};
|
|
198
236
|
|
|
@@ -319,47 +357,25 @@ function EditableTextField({
|
|
|
319
357
|
);
|
|
320
358
|
}
|
|
321
359
|
|
|
322
|
-
function PrepEditableField({ id,
|
|
360
|
+
function PrepEditableField({ id, ...props }) {
|
|
323
361
|
const { isPresomanager } = useModes();
|
|
324
362
|
const { isUserTemplateLibrary, isUserTemplate } = useSlide();
|
|
325
363
|
|
|
326
|
-
const [defaultCmsValue] = useCMSVal(id, {
|
|
327
|
-
enabled: isCompany && !isPresomanager,
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
const [prepValue, setPrepValue] = usePrepEditable(prepId);
|
|
331
|
-
|
|
332
|
-
let value = isCompany && prepValue === null ? defaultCmsValue : prepValue;
|
|
333
|
-
|
|
334
364
|
const isEditable =
|
|
335
365
|
props.isEditable &&
|
|
336
366
|
(!isPresomanager || isUserTemplateLibrary || isUserTemplate);
|
|
337
367
|
|
|
338
368
|
return (
|
|
339
|
-
<EditableTextField
|
|
340
|
-
{...props}
|
|
341
|
-
value={value ?? renderToString(children)}
|
|
342
|
-
onChange={setPrepValue}
|
|
343
|
-
isEditable={isEditable}
|
|
344
|
-
mode={MODE_PREP}
|
|
345
|
-
/>
|
|
369
|
+
<EditableTextField {...props} isEditable={isEditable} mode={MODE_PREP} />
|
|
346
370
|
);
|
|
347
371
|
}
|
|
348
372
|
|
|
349
|
-
function CMSValField({ id,
|
|
373
|
+
function CMSValField({ id, value, defaultValue, ...props }) {
|
|
350
374
|
const { isPresomanager, isThumbnailScreenshot } = useModes();
|
|
351
|
-
const { isUserTemplateLibrary, isUserTemplate
|
|
352
|
-
|
|
353
|
-
const [cmsValue, setCMSValue] = useCMSVal(id);
|
|
354
|
-
const value = !isThumbnailScreenshot && cmsValue;
|
|
375
|
+
const { isUserTemplateLibrary, isUserTemplate } = useSlide();
|
|
355
376
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// this slide rather than presenter's current slide.
|
|
359
|
-
const userCanEdit =
|
|
360
|
-
isUserTemplate ||
|
|
361
|
-
isUserTemplateLibrary ||
|
|
362
|
-
(Bridge.CMSVals?.canEditKey?.(id, { slidePath }) ?? true);
|
|
377
|
+
const canEditKey = useCanEditCMSValKey(id);
|
|
378
|
+
const userCanEdit = isUserTemplate || isUserTemplateLibrary || canEditKey;
|
|
363
379
|
|
|
364
380
|
const isEditable =
|
|
365
381
|
props.isEditable &&
|
|
@@ -369,8 +385,9 @@ function CMSValField({ id, children, ...props }) {
|
|
|
369
385
|
return (
|
|
370
386
|
<EditableTextField
|
|
371
387
|
{...props}
|
|
372
|
-
|
|
373
|
-
|
|
388
|
+
// A thumbnail shows the copy the usage wrote rather than the stored
|
|
389
|
+
// value, so a slide looks the same in every tenancy's picker.
|
|
390
|
+
value={isThumbnailScreenshot ? defaultValue : value}
|
|
374
391
|
isEditable={isEditable}
|
|
375
392
|
mode={MODE_CMS}
|
|
376
393
|
/>
|
|
@@ -391,41 +408,111 @@ export function TextField(props) {
|
|
|
391
408
|
);
|
|
392
409
|
}
|
|
393
410
|
|
|
411
|
+
/** How long a run of keystrokes settles for before the value is stored. */
|
|
412
|
+
const SETTLE_MS = 100;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Sends the value to the store once a run of keystrokes stops.
|
|
416
|
+
*
|
|
417
|
+
* The field shows every keystroke regardless, and only the write waits. A
|
|
418
|
+
* mutation carries the whole value rather than a change, so the one that lands
|
|
419
|
+
* says everything the skipped ones would have.
|
|
420
|
+
*/
|
|
421
|
+
function useDebouncedSet(mutations) {
|
|
422
|
+
const store = useRef(mutations.set);
|
|
423
|
+
|
|
424
|
+
store.current = mutations.set;
|
|
425
|
+
|
|
426
|
+
return useMemo(
|
|
427
|
+
() => debounce((html) => store.current({ html }), SETTLE_MS),
|
|
428
|
+
[],
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
394
432
|
export function ManagedTextField(props) {
|
|
395
|
-
const { isPresomanager, isPreview,
|
|
396
|
-
const { isUserTemplateLibrary } = useSlide();
|
|
433
|
+
const { isPresomanager, isPreview, isReadOnly: isPageReadOnly } = useModes();
|
|
397
434
|
const {
|
|
435
|
+
id,
|
|
436
|
+
children,
|
|
398
437
|
isReadOnly = false,
|
|
399
438
|
isPrep = false,
|
|
400
439
|
isCompany = false,
|
|
401
|
-
//
|
|
440
|
+
// Kept out of `other`, which is spread onto the element. The declaration
|
|
441
|
+
// reads these off `props` directly.
|
|
402
442
|
prepId,
|
|
443
|
+
scope,
|
|
403
444
|
...other
|
|
404
445
|
} = props;
|
|
405
446
|
|
|
406
|
-
const isEditable = !isReadOnly && (isPresomanager || isPreview);
|
|
407
|
-
const sharedProps = { ...other, isEditable };
|
|
408
|
-
|
|
409
447
|
// occasionally, we want to lock the field and pass a value
|
|
410
448
|
// without it ever being configured to allow updates
|
|
411
449
|
if (!isReadOnly && !isPrep && !isCompany) {
|
|
412
450
|
throw new Error("Must set isPrep or isCompany or both");
|
|
413
451
|
}
|
|
414
452
|
|
|
415
|
-
const
|
|
453
|
+
const defaultValue = renderToString(children);
|
|
416
454
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
455
|
+
const { channel, queries, mutations } = useActions(editable, props, {
|
|
456
|
+
defaultValue,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const sharedProps = {
|
|
460
|
+
...other,
|
|
461
|
+
value: queries.get(),
|
|
462
|
+
// The debounce sits here because this component stores the value, so it
|
|
463
|
+
// decides how often the store hears about it.
|
|
464
|
+
onChange: useDebouncedSet(mutations),
|
|
465
|
+
// Two read-only checks: this field, and the page the host says stores
|
|
466
|
+
// nothing.
|
|
467
|
+
isEditable: !isReadOnly && !isPageReadOnly && (isPresomanager || isPreview),
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<ManagedField
|
|
472
|
+
id={id}
|
|
473
|
+
channel={channel}
|
|
474
|
+
defaultValue={defaultValue}
|
|
475
|
+
{...sharedProps}
|
|
476
|
+
/>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function ManagedField({ id, channel, defaultValue, ...props }) {
|
|
481
|
+
// The channel arrives once the presenter has read the deck's index. Picking a
|
|
482
|
+
// field kind before then asks the wrong store about this key.
|
|
483
|
+
if (!channel) {
|
|
421
484
|
return (
|
|
422
|
-
<
|
|
423
|
-
isCompany={isCompany}
|
|
424
|
-
prepId={prepId}
|
|
425
|
-
{...sharedProps}
|
|
426
|
-
/>
|
|
485
|
+
<EditableTextField {...props} isEditable={false} mode={MODE_UNMANAGED} />
|
|
427
486
|
);
|
|
428
487
|
}
|
|
429
488
|
|
|
430
|
-
|
|
489
|
+
if (channel === "personal") {
|
|
490
|
+
return <PrepEditableField id={id} {...props} />;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return <CMSValField id={id} defaultValue={defaultValue} {...props} />;
|
|
431
494
|
}
|
|
495
|
+
|
|
496
|
+
// ManagedTextField renders no marker attribute and stores through
|
|
497
|
+
// Bridge.CMSVals, so the build finds no `data-toolbar` and would call a field
|
|
498
|
+
// that takes headings and lists plain text. The actions say what these fields
|
|
499
|
+
// permit instead, named in the toolbar groups this plugin's permits table lists.
|
|
500
|
+
const editable = defineEditableSource(ManagedTextField, {
|
|
501
|
+
actions: text,
|
|
502
|
+
props: {
|
|
503
|
+
toolbar: prop("toolbarConfig", { default: DEFAULT_TOOLBAR_CONFIG }),
|
|
504
|
+
permits: prop("permits", { default: PERMITS }),
|
|
505
|
+
tag: prop("tag", { default: null }),
|
|
506
|
+
},
|
|
507
|
+
everyone: {
|
|
508
|
+
when: prop("isCompany"),
|
|
509
|
+
key: "{id}-{instance}",
|
|
510
|
+
scope: prop("scope", { default: "instance" }),
|
|
511
|
+
},
|
|
512
|
+
personal: {
|
|
513
|
+
when: prop("isPrep"),
|
|
514
|
+
id: prop("prepId"),
|
|
515
|
+
key: "{id}-{instance}",
|
|
516
|
+
scope: prop("scope", { default: "instance" }),
|
|
517
|
+
},
|
|
518
|
+
});
|
package/jest.config.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livepreso/react-plugin-textfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"publishConfig": {
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"react-dom": "~19.2.0",
|
|
17
17
|
"@babel/parser": "^7.26.10",
|
|
18
18
|
"@babel/traverse": "^7.26.10",
|
|
19
|
+
"@babel/core": "~7.26.0",
|
|
20
|
+
"@babel/preset-env": "^7.14.9",
|
|
21
|
+
"babel-jest": "~29.7.0",
|
|
22
|
+
"jest": "~29.7.0",
|
|
19
23
|
"@livepreso/eslint-config": "1.0.0"
|
|
20
24
|
},
|
|
21
25
|
"peerDependencies": {
|
|
@@ -57,10 +61,12 @@
|
|
|
57
61
|
"@tiptap/extension-mention": "~3.31.0",
|
|
58
62
|
"@tiptap/suggestion": "~3.31.0",
|
|
59
63
|
"fuse.js": "~7.1.0",
|
|
60
|
-
"@livepreso/content-react": "
|
|
61
|
-
"@livepreso/
|
|
64
|
+
"@livepreso/content-react": "3.0.0",
|
|
65
|
+
"@livepreso/webpack-deck": "4.0.0",
|
|
66
|
+
"@livepreso/react-plugin-ui": "2.0.0"
|
|
62
67
|
},
|
|
63
68
|
"scripts": {
|
|
64
|
-
"build": ""
|
|
69
|
+
"build": "",
|
|
70
|
+
"test": "jest --maxWorkers=2 --workerIdleMemoryLimit=512MB"
|
|
65
71
|
}
|
|
66
72
|
}
|
package/permits-table.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each toolbar group lets a person put in a field, as the markup it comes
|
|
3
|
+
* out as.
|
|
4
|
+
*
|
|
5
|
+
* Data only, importing nothing, because a target's interface module reaches
|
|
6
|
+
* this through the declaration and is loaded for slides a reader never opens.
|
|
7
|
+
*
|
|
8
|
+
* The keys are the toolbar group names from `constants.js`, written out, since
|
|
9
|
+
* they are the strings a deck already types in its own `toolbarConfig`.
|
|
10
|
+
*
|
|
11
|
+
* A null group adds no markup at all.
|
|
12
|
+
*
|
|
13
|
+
* Keep this beside the `extensions` each item lists in
|
|
14
|
+
* `configs/toolbar-configuration.js`, which are what decide the markup.
|
|
15
|
+
*/
|
|
16
|
+
export const PERMITS = Object.freeze({
|
|
17
|
+
"text-style": {
|
|
18
|
+
tags: ["strong", "em", "u", "s", "sup", "sub", "span"],
|
|
19
|
+
attributes: ["style"],
|
|
20
|
+
coloursFromPalette: true,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
block: {
|
|
24
|
+
tags: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
list: {
|
|
28
|
+
tags: ["ul", "ol", "li"],
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
link: {
|
|
32
|
+
tags: ["a"],
|
|
33
|
+
attributes: ["href", "target", "rel", "title"],
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
alignment: {
|
|
37
|
+
attributes: ["style"],
|
|
38
|
+
align: ["left", "center", "right", "justify"],
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
table: {
|
|
42
|
+
tags: ["table", "thead", "tbody", "colgroup", "col", "th", "tr", "td"],
|
|
43
|
+
attributes: ["colspan", "rowspan", "colwidth", "style", "class"],
|
|
44
|
+
styles: ["width"],
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// A token renders through a node view rather than a renderHTML, so what is
|
|
48
|
+
// stored is the span that view built.
|
|
49
|
+
token: {
|
|
50
|
+
tags: ["span"],
|
|
51
|
+
attributes: ["id", "label", "class", "contenteditable"],
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
undo: null,
|
|
55
|
+
"clear-formatting": null,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Every field keeps these, whatever its toolbar permits. */
|
|
59
|
+
export const ALWAYS_PERMITTED = Object.freeze(["p", "br"]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
package/text-actions.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineActions,
|
|
3
|
+
defineRule,
|
|
4
|
+
findNotPermitted,
|
|
5
|
+
listPermitted,
|
|
6
|
+
} from "@livepreso/webpack-deck";
|
|
7
|
+
|
|
8
|
+
import { ALWAYS_PERMITTED } from "./permits-table.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a field with this toolbar permits, as one table the description and the
|
|
12
|
+
* check both read.
|
|
13
|
+
*
|
|
14
|
+
* A toolbar carrying a group this table does not list answers null, which stands
|
|
15
|
+
* for an unanswered field. A caller reading that as "permits nothing" would
|
|
16
|
+
* refuse writes the field takes every day.
|
|
17
|
+
*/
|
|
18
|
+
function derivePermits(toolbar, permits, tag) {
|
|
19
|
+
if (!Array.isArray(toolbar) || toolbar.some((group) => !(group in permits))) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const groups = toolbar.map((group) => permits[group]).filter(Boolean);
|
|
24
|
+
const tags = [
|
|
25
|
+
...ALWAYS_PERMITTED,
|
|
26
|
+
...groups.flatMap((group) => group.tags ?? []),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The editor wraps every value in the field's tag, so `<h4>` is stored even
|
|
30
|
+
// where the toolbar offers no headings.
|
|
31
|
+
if (tag) {
|
|
32
|
+
tags.push(tag);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
tags: [...new Set(tags)],
|
|
37
|
+
attributes: [...new Set(groups.flatMap((group) => group.attributes ?? []))],
|
|
38
|
+
styles: [...new Set(groups.flatMap((group) => group.styles ?? []))],
|
|
39
|
+
// A palette narrows which colours may be written, and a deck without one
|
|
40
|
+
// leaves them unchecked rather than refusing every colour.
|
|
41
|
+
colours: null,
|
|
42
|
+
align: groups.find((group) => group.align)?.align ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What kind of field this is, which a caller reading one wants as much as one writing. */
|
|
47
|
+
function describeKind(toolbar, permits) {
|
|
48
|
+
if (!derivePermits(toolbar, permits)) {
|
|
49
|
+
return "Text. What formatting it takes is not known here, so nothing is refused on that ground.";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return "Rich text, stored as HTML.";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The markup a value may use, which only something writing one needs. It rides
|
|
57
|
+
* on the rule rather than the declaration, so it reaches `set` and not `get`.
|
|
58
|
+
*
|
|
59
|
+
* Named as tags rather than described as formatting, because whatever is driving
|
|
60
|
+
* the deck writes HTML and would otherwise have to translate "headings" into
|
|
61
|
+
* `<h1>` for itself.
|
|
62
|
+
*/
|
|
63
|
+
function describeMarkup(toolbar, permits, tag) {
|
|
64
|
+
const derived = derivePermits(toolbar, permits, tag);
|
|
65
|
+
|
|
66
|
+
if (!derived) {
|
|
67
|
+
return "Nothing is refused, since what this field takes is not known here.";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const written = [`Allowed tags: ${derived.tags.join(", ")}.`];
|
|
71
|
+
|
|
72
|
+
if (derived.attributes.length > 0) {
|
|
73
|
+
written.push(`Allowed attributes: ${derived.attributes.join(", ")}.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return written.join(" ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Answers for what a managed text field holds and what may be written into it.
|
|
81
|
+
*
|
|
82
|
+
* `permits` is the extension point: a deck adding a tiptap extension widens the
|
|
83
|
+
* table through the prop rather than copying this declaration, and the description
|
|
84
|
+
* widens with it because it is a function of props.
|
|
85
|
+
*/
|
|
86
|
+
export const text = defineActions({
|
|
87
|
+
// Only where a usage wrote no copy between its tags. Where it did, the build
|
|
88
|
+
// reads that and the binding starts the field with it instead.
|
|
89
|
+
defaultState: "",
|
|
90
|
+
|
|
91
|
+
// Stores HTML in a text/plain slot, deliberately: it is how a decade of
|
|
92
|
+
// data-editable content is already stored, so an existing field becomes a
|
|
93
|
+
// rich text field without moving any of it. The cost is that it can never
|
|
94
|
+
// carry a version.
|
|
95
|
+
mime: "text/plain",
|
|
96
|
+
|
|
97
|
+
description: (props) => describeKind(props.toolbar, props.permits),
|
|
98
|
+
|
|
99
|
+
rules: {
|
|
100
|
+
"not-permitted": defineRule({
|
|
101
|
+
description: (props) =>
|
|
102
|
+
describeMarkup(props.toolbar, props.permits, props.tag),
|
|
103
|
+
check: ({ state, props }) => {
|
|
104
|
+
const permits = derivePermits(props.toolbar, props.permits, props.tag);
|
|
105
|
+
|
|
106
|
+
// A toolbar this table cannot read permits everything rather than
|
|
107
|
+
// nothing. The editor still enforces the real one, so refusing here
|
|
108
|
+
// would block writes the field takes every day.
|
|
109
|
+
if (!permits) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const refused = findNotPermitted(state, permits);
|
|
114
|
+
|
|
115
|
+
if (refused.length === 0) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
message: `The value uses ${refused.join(", ")}. ${listPermitted(permits)}`,
|
|
121
|
+
notPermitted: refused,
|
|
122
|
+
permits,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
queries: {
|
|
129
|
+
/**
|
|
130
|
+
* What this field currently holds.
|
|
131
|
+
*
|
|
132
|
+
* @returns {string} the text, as HTML
|
|
133
|
+
*/
|
|
134
|
+
get: ({ state }) => state,
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
mutations: {
|
|
138
|
+
/**
|
|
139
|
+
* Replace the text.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} args
|
|
142
|
+
* @param {object} args.input
|
|
143
|
+
* @param {string} args.input.html the new text, as HTML
|
|
144
|
+
*/
|
|
145
|
+
set: ({ input }) => input.html,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { text } from "./text-actions.js";
|
|
2
|
+
import { PERMITS } from "./permits-table.js";
|
|
3
|
+
|
|
4
|
+
function checkPermitted(state, props) {
|
|
5
|
+
return text.rules["not-permitted"].check({
|
|
6
|
+
state,
|
|
7
|
+
props: { toolbar: ["text-style"], permits: PERMITS, ...props },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("text", () => {
|
|
12
|
+
it("should permit the heading its tag draws", () => {
|
|
13
|
+
expect(
|
|
14
|
+
checkPermitted("<h4>Due <strong>soon</strong></h4>", { tag: "h4" }),
|
|
15
|
+
).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should permit the table its toolbar draws", () => {
|
|
19
|
+
const table = `<table data-density="normal" class="tiptap-table-density-normal" style="width: 600px;">`;
|
|
20
|
+
const column = `<colgroup><col style="width: 300px;"></colgroup>`;
|
|
21
|
+
const stored = `${table}${column}<tbody><tr><th colwidth="300"><p>Due</p></th></tr></tbody></table>`;
|
|
22
|
+
|
|
23
|
+
expect(
|
|
24
|
+
checkPermitted(stored, { toolbar: ["text-style", "table", "token"] }),
|
|
25
|
+
).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should permit the span a chosen token stores", () => {
|
|
29
|
+
const attributes = `id="date" label="Date" data-mention-suggestion-char="@"`;
|
|
30
|
+
const rest = `data-mode="cms" class="token" contenteditable="false"`;
|
|
31
|
+
const stored = `<h1><span ${attributes} ${rest}>@Date</span></h1>`;
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
checkPermitted(stored, { tag: "h1", toolbar: ["text-style", "token"] }),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -7,8 +7,12 @@ import style from "../index.module.scss";
|
|
|
7
7
|
export function generateBorderIcon(editor) {
|
|
8
8
|
const sides = getCellBorderSides(editor);
|
|
9
9
|
|
|
10
|
-
if (sides.length === BORDER_SIDES.length)
|
|
11
|
-
|
|
10
|
+
if (sides.length === BORDER_SIDES.length) {
|
|
11
|
+
return <icons.BorderOuter />;
|
|
12
|
+
}
|
|
13
|
+
if (sides.length === 0) {
|
|
14
|
+
return <icons.BorderClear />;
|
|
15
|
+
}
|
|
12
16
|
|
|
13
17
|
return (
|
|
14
18
|
<div className={style.borderIconContainer}>
|