@ceedcv-maya/shared-editor-react 0.10.0 → 0.11.1
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/package.json +1 -1
- package/src/components/EditorContentHtml.tsx +1 -0
- package/src/components/EditorIcons.tsx +368 -0
- package/src/components/EditorToolbar.tsx +137 -52
- package/src/components/EditorToolbarGroups.tsx +53 -29
- package/src/components/MayaEditor.tsx +12 -0
- package/src/index.ts +3 -4
- package/src/lib/buildToolbarLabels.test.ts +43 -0
- package/src/lib/buildToolbarLabels.ts +38 -0
- package/src/lib/tableMenuActions.test.ts +88 -0
- package/src/lib/tableMenuActions.ts +99 -0
- package/src/styles/maya-content.css +160 -0
- package/src/styles/maya-editor.css +40 -0
- package/src/i18n/en.json +0 -54
- package/src/i18n/es.json +0 -54
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* EditorToolbar button groups extracted into focused subcomponents.
|
|
3
3
|
* Each group handles a logically distinct set of formatting or block operations.
|
|
4
4
|
*/
|
|
5
|
+
import { Fragment } from 'react';
|
|
5
6
|
import type { Editor } from '@tiptap/react';
|
|
6
7
|
import type { ToolbarLabels } from './EditorToolbar';
|
|
7
8
|
import { ColorPicker } from './ColorPicker';
|
|
8
9
|
import { Btn } from './EditorToolbarButton';
|
|
10
|
+
import { EditorIcon } from './EditorIcons';
|
|
11
|
+
import { tableMenuActions } from '../lib/tableMenuActions';
|
|
9
12
|
|
|
10
13
|
export interface ToolbarGroupProps {
|
|
11
14
|
editor: Editor;
|
|
@@ -35,31 +38,31 @@ export function FormattingButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
35
38
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
36
39
|
title={L.bold}
|
|
37
40
|
>
|
|
38
|
-
<
|
|
41
|
+
<EditorIcon name="bold" />
|
|
39
42
|
</Btn>
|
|
40
43
|
<Btn
|
|
41
44
|
active={editor.isActive('italic')}
|
|
42
45
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
43
46
|
title={L.italic}
|
|
44
47
|
>
|
|
45
|
-
<
|
|
48
|
+
<EditorIcon name="italic" />
|
|
46
49
|
</Btn>
|
|
47
50
|
<Btn
|
|
48
51
|
active={editor.isActive('underline')}
|
|
49
52
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
50
53
|
title={L.underline}
|
|
51
54
|
>
|
|
52
|
-
<
|
|
55
|
+
<EditorIcon name="underline" />
|
|
53
56
|
</Btn>
|
|
54
57
|
<Btn
|
|
55
58
|
active={editor.isActive('code')}
|
|
56
59
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
57
60
|
title={L.code}
|
|
58
61
|
>
|
|
59
|
-
|
|
62
|
+
<EditorIcon name="code" />
|
|
60
63
|
</Btn>
|
|
61
64
|
<Btn active={editor.isActive('link')} onClick={setLink} title={L.link}>
|
|
62
|
-
|
|
65
|
+
<EditorIcon name="link" />
|
|
63
66
|
</Btn>
|
|
64
67
|
</>
|
|
65
68
|
);
|
|
@@ -76,13 +79,13 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
|
|
|
76
79
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
77
80
|
title={L.strike}
|
|
78
81
|
>
|
|
79
|
-
<
|
|
82
|
+
<EditorIcon name="strike" />
|
|
80
83
|
</Btn>
|
|
81
84
|
|
|
82
85
|
<ColorPicker
|
|
83
86
|
title={L.textColor}
|
|
84
87
|
value={(editor.getAttributes('textStyle').color as string | undefined) ?? null}
|
|
85
|
-
glyph={<
|
|
88
|
+
glyph={<EditorIcon name="textColor" />}
|
|
86
89
|
clearLabel={L.colorDefault}
|
|
87
90
|
onSelect={(c) => {
|
|
88
91
|
if (c === null) editor.chain().focus().unsetColor().run();
|
|
@@ -92,7 +95,7 @@ export function AdvancedFormattingButtons({ editor, labels: L }: ToolbarGroupPro
|
|
|
92
95
|
<ColorPicker
|
|
93
96
|
title={L.backgroundColor}
|
|
94
97
|
value={(editor.getAttributes('highlight').color as string | undefined) ?? null}
|
|
95
|
-
glyph={<
|
|
98
|
+
glyph={<EditorIcon name="highlight" />}
|
|
96
99
|
clearLabel={L.colorDefault}
|
|
97
100
|
onSelect={(c) => {
|
|
98
101
|
if (c === null) editor.chain().focus().unsetHighlight().run();
|
|
@@ -114,28 +117,28 @@ export function AlignmentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
114
117
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
115
118
|
title={L.alignLeft}
|
|
116
119
|
>
|
|
117
|
-
|
|
120
|
+
<EditorIcon name="alignLeft" />
|
|
118
121
|
</Btn>
|
|
119
122
|
<Btn
|
|
120
123
|
active={editor.isActive({ textAlign: 'center' })}
|
|
121
124
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
122
125
|
title={L.alignCenter}
|
|
123
126
|
>
|
|
124
|
-
|
|
127
|
+
<EditorIcon name="alignCenter" />
|
|
125
128
|
</Btn>
|
|
126
129
|
<Btn
|
|
127
130
|
active={editor.isActive({ textAlign: 'right' })}
|
|
128
131
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
|
129
132
|
title={L.alignRight}
|
|
130
133
|
>
|
|
131
|
-
|
|
134
|
+
<EditorIcon name="alignRight" />
|
|
132
135
|
</Btn>
|
|
133
136
|
<Btn
|
|
134
137
|
active={editor.isActive({ textAlign: 'justify' })}
|
|
135
138
|
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
|
136
139
|
title={L.alignJustify}
|
|
137
140
|
>
|
|
138
|
-
|
|
141
|
+
<EditorIcon name="alignJustify" />
|
|
139
142
|
</Btn>
|
|
140
143
|
</>
|
|
141
144
|
);
|
|
@@ -159,7 +162,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
159
162
|
}}
|
|
160
163
|
title={L.indent}
|
|
161
164
|
>
|
|
162
|
-
|
|
165
|
+
<EditorIcon name="indent" />
|
|
163
166
|
</Btn>
|
|
164
167
|
<Btn
|
|
165
168
|
onClick={() => {
|
|
@@ -173,7 +176,7 @@ export function IndentButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
173
176
|
}}
|
|
174
177
|
title={L.outdent}
|
|
175
178
|
>
|
|
176
|
-
|
|
179
|
+
<EditorIcon name="outdent" />
|
|
177
180
|
</Btn>
|
|
178
181
|
</>
|
|
179
182
|
);
|
|
@@ -221,41 +224,41 @@ export function ListAndBlockButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
|
221
224
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
222
225
|
title={L.bulletList}
|
|
223
226
|
>
|
|
224
|
-
|
|
227
|
+
<EditorIcon name="bulletList" />
|
|
225
228
|
</Btn>
|
|
226
229
|
<Btn
|
|
227
230
|
active={editor.isActive('orderedList')}
|
|
228
231
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
229
232
|
title={L.orderedList}
|
|
230
233
|
>
|
|
231
|
-
|
|
234
|
+
<EditorIcon name="orderedList" />
|
|
232
235
|
</Btn>
|
|
233
236
|
<Btn
|
|
234
237
|
active={editor.isActive('taskList')}
|
|
235
238
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
|
236
239
|
title={L.taskList}
|
|
237
240
|
>
|
|
238
|
-
|
|
241
|
+
<EditorIcon name="taskList" />
|
|
239
242
|
</Btn>
|
|
240
243
|
<Btn
|
|
241
244
|
active={editor.isActive('blockquote')}
|
|
242
245
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
243
246
|
title={L.blockquote}
|
|
244
247
|
>
|
|
245
|
-
|
|
248
|
+
<EditorIcon name="blockquote" />
|
|
246
249
|
</Btn>
|
|
247
250
|
<Btn
|
|
248
251
|
active={editor.isActive('codeBlock')}
|
|
249
252
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
250
253
|
title={L.codeBlock}
|
|
251
254
|
>
|
|
252
|
-
|
|
255
|
+
<EditorIcon name="codeBlock" />
|
|
253
256
|
</Btn>
|
|
254
257
|
<Btn
|
|
255
258
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
256
259
|
title={L.horizontalRule}
|
|
257
260
|
>
|
|
258
|
-
|
|
261
|
+
<EditorIcon name="horizontalRule" />
|
|
259
262
|
</Btn>
|
|
260
263
|
</>
|
|
261
264
|
);
|
|
@@ -287,20 +290,41 @@ export function TableAndMediaButtons({
|
|
|
287
290
|
}
|
|
288
291
|
title={L.table}
|
|
289
292
|
>
|
|
290
|
-
|
|
293
|
+
<EditorIcon name="table" />
|
|
291
294
|
</Btn>
|
|
292
295
|
<Btn onClick={setIframe} title={L.iframe}>
|
|
293
|
-
|
|
296
|
+
<EditorIcon name="iframe" />
|
|
294
297
|
</Btn>
|
|
295
298
|
{onImage && (
|
|
296
299
|
<Btn onClick={onImage} title={L.uploadImage}>
|
|
297
|
-
|
|
300
|
+
<EditorIcon name="image" />
|
|
298
301
|
</Btn>
|
|
299
302
|
)}
|
|
300
303
|
</>
|
|
301
304
|
);
|
|
302
305
|
}
|
|
303
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Contextual table-editing buttons: add/delete rows & columns, toggle header,
|
|
309
|
+
* delete table. Rendered in the main toolbar only while the selection is inside
|
|
310
|
+
* a table (the caller gates on `editor.isActive('table')`), so it appears and
|
|
311
|
+
* hides in place instead of floating over the content.
|
|
312
|
+
*/
|
|
313
|
+
export function TableButtons({ editor, labels: L }: ToolbarGroupProps) {
|
|
314
|
+
return (
|
|
315
|
+
<>
|
|
316
|
+
{tableMenuActions(editor, L).map((a) => (
|
|
317
|
+
<Fragment key={a.key}>
|
|
318
|
+
{a.separatorBefore && <span className="maya-editor-toolbar__sep" aria-hidden />}
|
|
319
|
+
<Btn onClick={a.run} title={a.title} disabled={a.disabled}>
|
|
320
|
+
<EditorIcon name={a.icon} />
|
|
321
|
+
</Btn>
|
|
322
|
+
</Fragment>
|
|
323
|
+
))}
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
/**
|
|
305
329
|
* Import/export and document buttons: Word import/export, comments, find.
|
|
306
330
|
*/
|
|
@@ -323,12 +347,12 @@ export function DocumentButtons({
|
|
|
323
347
|
<>
|
|
324
348
|
{onImportDocx && (
|
|
325
349
|
<Btn onClick={onImportDocx} title={L.importDocx}>
|
|
326
|
-
<
|
|
350
|
+
<EditorIcon name="importDocx" />
|
|
327
351
|
</Btn>
|
|
328
352
|
)}
|
|
329
353
|
{onExportDocx && (
|
|
330
354
|
<Btn onClick={onExportDocx} title={L.exportDocx}>
|
|
331
|
-
<
|
|
355
|
+
<EditorIcon name="exportDocx" />
|
|
332
356
|
</Btn>
|
|
333
357
|
)}
|
|
334
358
|
{onAddComment && (
|
|
@@ -337,12 +361,12 @@ export function DocumentButtons({
|
|
|
337
361
|
disabled={editor.state.selection.empty}
|
|
338
362
|
title={L.addComment}
|
|
339
363
|
>
|
|
340
|
-
|
|
364
|
+
<EditorIcon name="comment" />
|
|
341
365
|
</Btn>
|
|
342
366
|
)}
|
|
343
367
|
{onToggleFind && (
|
|
344
368
|
<Btn onClick={onToggleFind} title={L.find}>
|
|
345
|
-
|
|
369
|
+
<EditorIcon name="find" />
|
|
346
370
|
</Btn>
|
|
347
371
|
)}
|
|
348
372
|
</>
|
|
@@ -385,7 +409,7 @@ export function ViewModeButtons({
|
|
|
385
409
|
title={L.insertHtml}
|
|
386
410
|
active={viewMode === 'html'}
|
|
387
411
|
>
|
|
388
|
-
<
|
|
412
|
+
<EditorIcon name="htmlSource" />
|
|
389
413
|
</Btn>
|
|
390
414
|
)}
|
|
391
415
|
<span className="maya-editor-toolbar__sep" aria-hidden />
|
|
@@ -394,7 +418,7 @@ export function ViewModeButtons({
|
|
|
394
418
|
onClick={onToggleFullscreen}
|
|
395
419
|
title={isFullscreen ? L.exitFullscreen : L.fullscreen}
|
|
396
420
|
>
|
|
397
|
-
{isFullscreen ? '
|
|
421
|
+
<EditorIcon name={isFullscreen ? 'exitFullscreen' : 'fullscreen'} />
|
|
398
422
|
</Btn>
|
|
399
423
|
)}
|
|
400
424
|
</>
|
|
@@ -326,6 +326,18 @@ export function MayaEditor({
|
|
|
326
326
|
onFullscreenChange(isFullscreen);
|
|
327
327
|
}, [isFullscreen, onFullscreenChange]);
|
|
328
328
|
|
|
329
|
+
// Mirror fullscreen to a global `editor-fullscreen` class on <html> so the
|
|
330
|
+
// host AppLayout can hide its fixed sidebar and drop the content margin while
|
|
331
|
+
// the editor covers the viewport. Self-contained: works even when the host
|
|
332
|
+
// doesn't handle `onFullscreenChange` (e.g. the inline editors in the
|
|
333
|
+
// continuous document view) — otherwise the fixed editor sits *under* the
|
|
334
|
+
// still-visible sidebar and the left half is hidden.
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
const root = document.documentElement;
|
|
337
|
+
root.classList.toggle('editor-fullscreen', isFullscreen);
|
|
338
|
+
return () => root.classList.remove('editor-fullscreen');
|
|
339
|
+
}, [isFullscreen]);
|
|
340
|
+
|
|
329
341
|
if (!editor) return null;
|
|
330
342
|
|
|
331
343
|
const editorReady = viewReady && isEditorReady(editor);
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,10 @@ export { MayaEditor } from './components/MayaEditor';
|
|
|
2
2
|
export type { MayaEditorProps } from './components/MayaEditor';
|
|
3
3
|
export { EditorContentHtml } from './components/EditorContentHtml';
|
|
4
4
|
export { EditorContentJson } from './components/EditorContentJson';
|
|
5
|
-
export { EditorToolbar } from './components/EditorToolbar';
|
|
5
|
+
export { EditorToolbar, DEFAULT_LABELS } from './components/EditorToolbar';
|
|
6
6
|
export type { ToolbarLabels } from './components/EditorToolbar';
|
|
7
|
+
export { buildToolbarLabels } from './lib/buildToolbarLabels';
|
|
8
|
+
export type { TranslateFn } from './lib/buildToolbarLabels';
|
|
7
9
|
|
|
8
10
|
export { IframeBlock } from './extensions/IframeBlock';
|
|
9
11
|
export { AlertBlock } from './extensions/AlertBlock';
|
|
@@ -52,6 +54,3 @@ export type {
|
|
|
52
54
|
TiptapDoc,
|
|
53
55
|
AnchoredComment,
|
|
54
56
|
} from './types';
|
|
55
|
-
|
|
56
|
-
export { default as esTranslations } from './i18n/es.json';
|
|
57
|
-
export { default as enTranslations } from './i18n/en.json';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildToolbarLabels } from './buildToolbarLabels';
|
|
3
|
+
import { DEFAULT_LABELS } from '../components/EditorToolbar';
|
|
4
|
+
|
|
5
|
+
describe('buildToolbarLabels', () => {
|
|
6
|
+
it('maps every label key through t("editor.<key>")', () => {
|
|
7
|
+
const dict: Record<string, string> = {
|
|
8
|
+
'editor.bold': 'Negrita',
|
|
9
|
+
'editor.italic': 'Cursiva',
|
|
10
|
+
'editor.groupFont': 'Fuente',
|
|
11
|
+
'editor.tableAddColumnAfter': 'Insertar columna a la derecha',
|
|
12
|
+
};
|
|
13
|
+
const t = (k: string) => dict[k] ?? k;
|
|
14
|
+
|
|
15
|
+
const labels = buildToolbarLabels(t);
|
|
16
|
+
expect(labels.bold).toBe('Negrita');
|
|
17
|
+
expect(labels.italic).toBe('Cursiva');
|
|
18
|
+
expect(labels.groupFont).toBe('Fuente');
|
|
19
|
+
expect(labels.tableAddColumnAfter).toBe('Insertar columna a la derecha');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('falls back to the English default when a key is missing (t returns the key)', () => {
|
|
23
|
+
const t = (k: string) => k; // i18next miss behaviour: returns the key
|
|
24
|
+
const labels = buildToolbarLabels(t);
|
|
25
|
+
expect(labels.bold).toBe(DEFAULT_LABELS.bold);
|
|
26
|
+
expect(labels.groupParagraph).toBe(DEFAULT_LABELS.groupParagraph);
|
|
27
|
+
// never leaks a raw "editor.*" key
|
|
28
|
+
expect(labels.bold).not.toContain('editor.');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('honours a custom prefix', () => {
|
|
32
|
+
const t = (k: string) => (k === 'tt.bold' ? 'B!' : k);
|
|
33
|
+
const labels = buildToolbarLabels(t, 'tt');
|
|
34
|
+
expect(labels.bold).toBe('B!');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a fully-populated object (all default keys present)', () => {
|
|
38
|
+
const labels = buildToolbarLabels((k) => k);
|
|
39
|
+
for (const key of Object.keys(DEFAULT_LABELS)) {
|
|
40
|
+
expect(labels[key as keyof typeof labels]).toBeTruthy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a fully-populated {@link ToolbarLabels} from a translation function.
|
|
3
|
+
*
|
|
4
|
+
* The editor itself is i18n-agnostic (it just takes a `toolbarLabels` prop), so
|
|
5
|
+
* each consumer wires its own i18n. This helper keeps that wiring to one line:
|
|
6
|
+
* pass the app's `t` (bound to the namespace that holds the shared `editor`
|
|
7
|
+
* canon — usually `common`) and it maps every label key to `t('editor.<key>')`.
|
|
8
|
+
*
|
|
9
|
+
* const { t } = useTranslation('common');
|
|
10
|
+
* <MayaEditor toolbarLabels={buildToolbarLabels(t)} … />
|
|
11
|
+
*
|
|
12
|
+
* Any key missing from the active locale falls back to the English default
|
|
13
|
+
* (i18next returns the key unchanged for a miss, which we detect and replace),
|
|
14
|
+
* so a partially-translated locale never shows raw `editor.*` keys.
|
|
15
|
+
*/
|
|
16
|
+
import { DEFAULT_LABELS } from '../components/EditorToolbar';
|
|
17
|
+
import type { ToolbarLabels } from '../components/EditorToolbar';
|
|
18
|
+
|
|
19
|
+
/** Minimal shape of an i18next `t` — only the (key) → string call is used. */
|
|
20
|
+
export type TranslateFn = (key: string) => string;
|
|
21
|
+
|
|
22
|
+
export function buildToolbarLabels(
|
|
23
|
+
t: TranslateFn,
|
|
24
|
+
prefix = 'editor',
|
|
25
|
+
): ToolbarLabels {
|
|
26
|
+
const out: ToolbarLabels = { ...DEFAULT_LABELS };
|
|
27
|
+
|
|
28
|
+
(Object.keys(DEFAULT_LABELS) as (keyof ToolbarLabels)[]).forEach((key) => {
|
|
29
|
+
const fullKey = `${prefix}.${key}`;
|
|
30
|
+
const value = t(fullKey);
|
|
31
|
+
// i18next returns the key itself on a miss → keep the English default.
|
|
32
|
+
if (value && value !== fullKey) {
|
|
33
|
+
out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import { buildMayaEditorExtensions } from './editorExtensions';
|
|
4
|
+
import { tableMenuActions } from './tableMenuActions';
|
|
5
|
+
|
|
6
|
+
function makeEditor(): Editor {
|
|
7
|
+
return new Editor({ extensions: buildMayaEditorExtensions('full') });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Count tableRow / cell nodes in the current doc. */
|
|
11
|
+
function tableShape(editor: Editor): { rows: number; cols: number } {
|
|
12
|
+
let rows = 0;
|
|
13
|
+
let cols = 0;
|
|
14
|
+
editor.state.doc.descendants((node) => {
|
|
15
|
+
if (node.type.name === 'tableRow') {
|
|
16
|
+
rows += 1;
|
|
17
|
+
if (rows === 1) cols = node.childCount; // cells in first row
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
return { rows, cols };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let current: Editor | null = null;
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
current?.destroy();
|
|
27
|
+
current = null;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('tableMenuActions', () => {
|
|
31
|
+
it('returns all actions disabled when the selection is not in a table', () => {
|
|
32
|
+
const editor = (current = makeEditor());
|
|
33
|
+
editor.commands.setContent('<p>plain paragraph</p>');
|
|
34
|
+
const actions = tableMenuActions(editor);
|
|
35
|
+
expect(actions.length).toBe(8);
|
|
36
|
+
expect(actions.every((a) => a.disabled)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('enables actions and adds a column when the cursor is inside a table', () => {
|
|
40
|
+
const editor = (current = makeEditor());
|
|
41
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
42
|
+
const before = tableShape(editor);
|
|
43
|
+
|
|
44
|
+
const addCol = tableMenuActions(editor).find((a) => a.key === 'addColumnAfter')!;
|
|
45
|
+
expect(addCol.disabled).toBe(false);
|
|
46
|
+
addCol.run();
|
|
47
|
+
|
|
48
|
+
expect(tableShape(editor).cols).toBe(before.cols + 1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('adds a row', () => {
|
|
52
|
+
const editor = (current = makeEditor());
|
|
53
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
54
|
+
const before = tableShape(editor);
|
|
55
|
+
|
|
56
|
+
tableMenuActions(editor).find((a) => a.key === 'addRowAfter')!.run();
|
|
57
|
+
|
|
58
|
+
expect(tableShape(editor).rows).toBe(before.rows + 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('deletes a column', () => {
|
|
62
|
+
const editor = (current = makeEditor());
|
|
63
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 3, withHeaderRow: true }).run();
|
|
64
|
+
const before = tableShape(editor);
|
|
65
|
+
|
|
66
|
+
tableMenuActions(editor).find((a) => a.key === 'deleteColumn')!.run();
|
|
67
|
+
|
|
68
|
+
expect(tableShape(editor).cols).toBe(before.cols - 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('deletes the whole table', () => {
|
|
72
|
+
const editor = (current = makeEditor());
|
|
73
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
74
|
+
expect(editor.isActive('table')).toBe(true);
|
|
75
|
+
|
|
76
|
+
tableMenuActions(editor).find((a) => a.key === 'deleteTable')!.run();
|
|
77
|
+
|
|
78
|
+
expect(editor.isActive('table')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses provided labels for tooltips', () => {
|
|
82
|
+
const editor = (current = makeEditor());
|
|
83
|
+
editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
|
|
84
|
+
const actions = tableMenuActions(editor, { tableAddColumnAfter: 'Insertar columna a la derecha' });
|
|
85
|
+
const addCol = actions.find((a) => a.key === 'addColumnAfter')!;
|
|
86
|
+
expect(addCol.title).toBe('Insertar columna a la derecha');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table editing actions exposed by the contextual table toolbar.
|
|
3
|
+
*
|
|
4
|
+
* Pure mapping from an editor instance + labels to a flat, ordered list of
|
|
5
|
+
* buttons. Kept separate from the React component so the command wiring and
|
|
6
|
+
* `can()` gating can be unit-tested against a headless editor.
|
|
7
|
+
*/
|
|
8
|
+
import type { Editor } from '@tiptap/core';
|
|
9
|
+
import type { ToolbarLabels } from '../components/EditorToolbar';
|
|
10
|
+
import type { EditorIconName } from '../components/EditorIcons';
|
|
11
|
+
|
|
12
|
+
export interface TableMenuAction {
|
|
13
|
+
/** Stable key for React lists. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** Tooltip / aria-label. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Name of the SVG icon shown on the button. */
|
|
18
|
+
icon: EditorIconName;
|
|
19
|
+
/** Runs the command. */
|
|
20
|
+
run: () => void;
|
|
21
|
+
/** True when the command can't apply to the current selection. */
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
/** When true, render a visual separator before this action. */
|
|
24
|
+
separatorBefore?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the ordered list of table actions for the current editor state.
|
|
29
|
+
* Recomputed on every render so `disabled` reflects the live selection.
|
|
30
|
+
*/
|
|
31
|
+
export function tableMenuActions(
|
|
32
|
+
editor: Editor,
|
|
33
|
+
labels?: Partial<ToolbarLabels>,
|
|
34
|
+
): TableMenuAction[] {
|
|
35
|
+
const L = labels ?? {};
|
|
36
|
+
const chain = () => editor.chain().focus();
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
key: 'addColumnBefore',
|
|
41
|
+
title: L.tableAddColumnBefore ?? 'Insert column left',
|
|
42
|
+
icon: 'columnAddBefore',
|
|
43
|
+
run: () => chain().addColumnBefore().run(),
|
|
44
|
+
disabled: !editor.can().addColumnBefore(),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'addColumnAfter',
|
|
48
|
+
title: L.tableAddColumnAfter ?? 'Insert column right',
|
|
49
|
+
icon: 'columnAddAfter',
|
|
50
|
+
run: () => chain().addColumnAfter().run(),
|
|
51
|
+
disabled: !editor.can().addColumnAfter(),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: 'addRowBefore',
|
|
55
|
+
title: L.tableAddRowBefore ?? 'Insert row above',
|
|
56
|
+
icon: 'rowAddBefore',
|
|
57
|
+
run: () => chain().addRowBefore().run(),
|
|
58
|
+
disabled: !editor.can().addRowBefore(),
|
|
59
|
+
separatorBefore: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'addRowAfter',
|
|
63
|
+
title: L.tableAddRowAfter ?? 'Insert row below',
|
|
64
|
+
icon: 'rowAddAfter',
|
|
65
|
+
run: () => chain().addRowAfter().run(),
|
|
66
|
+
disabled: !editor.can().addRowAfter(),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'deleteColumn',
|
|
70
|
+
title: L.tableDeleteColumn ?? 'Delete column',
|
|
71
|
+
icon: 'columnDelete',
|
|
72
|
+
run: () => chain().deleteColumn().run(),
|
|
73
|
+
disabled: !editor.can().deleteColumn(),
|
|
74
|
+
separatorBefore: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: 'deleteRow',
|
|
78
|
+
title: L.tableDeleteRow ?? 'Delete row',
|
|
79
|
+
icon: 'rowDelete',
|
|
80
|
+
run: () => chain().deleteRow().run(),
|
|
81
|
+
disabled: !editor.can().deleteRow(),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'toggleHeaderRow',
|
|
85
|
+
title: L.tableToggleHeaderRow ?? 'Toggle header row',
|
|
86
|
+
icon: 'headerRow',
|
|
87
|
+
run: () => chain().toggleHeaderRow().run(),
|
|
88
|
+
disabled: !editor.can().toggleHeaderRow(),
|
|
89
|
+
separatorBefore: true,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'deleteTable',
|
|
93
|
+
title: L.tableDelete ?? 'Delete table',
|
|
94
|
+
icon: 'tableDelete',
|
|
95
|
+
run: () => chain().deleteTable().run(),
|
|
96
|
+
disabled: !editor.can().deleteTable(),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|