Skip to content

Commit cf0cd1e

Browse files
committed
feat: add Jodit "show-blocks" editor plugin
Adds a toggle plugin that visually outlines block-level elements with dashed borders and tag-name labels, helping content editors understand document structure at a glance.
1 parent 63f9ecb commit cf0cd1e

5 files changed

Lines changed: 419 additions & 1 deletion

File tree

phpmyfaq/admin/assets/src/content/editor.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ vi.mock('jodit/esm/modules/uploader/uploader.js', () => ({}));
9292
vi.mock('jodit/esm/plugins/video/video.js', () => ({}));
9393
vi.mock('../plugins/phpmyfaq/phpmyfaq.js', () => ({}));
9494
vi.mock('../plugins/code-snippet/code-snippet.js', () => ({}));
95+
vi.mock('../plugins/show-blocks/show-blocks.js', () => ({}));
9596

9697
import { getJoditEditor, renderEditor, renderPageEditor } from './editor';
9798
import { Jodit } from 'jodit';

phpmyfaq/admin/assets/src/content/editor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import 'jodit/esm/modules/uploader/uploader.js';
4242
import 'jodit/esm/plugins/video/video.js';
4343
import '../plugins/phpmyfaq/phpmyfaq.js';
4444
import '../plugins/code-snippet/code-snippet.js';
45+
import '../plugins/show-blocks/show-blocks.js';
4546
import hljs from 'highlight.js';
4647

4748
interface UploaderResponse {
@@ -187,7 +188,7 @@ export const renderEditor = () => {
187188
imageProcessor: { replaceDataURIToBlobIdInView: false },
188189
removeButtons: [],
189190
disablePlugins: [],
190-
extraPlugins: ['phpMyFAQ', 'codeSnippet'],
191+
extraPlugins: ['phpMyFAQ', 'codeSnippet', 'showBlocks'],
191192
extraButtons: [],
192193
buttons: [
193194
'source',
@@ -240,6 +241,7 @@ export const renderEditor = () => {
240241
'|',
241242
'phpMyFAQ',
242243
'codeSnippet',
244+
'showBlocks',
243245
],
244246
events: {},
245247
textIcons: false,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const showBlocks: string = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
2+
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
3+
<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="3 2"/>
4+
<line x1="3" y1="9" x2="21" y2="9" stroke-dasharray="3 2"/>
5+
<line x1="3" y1="15" x2="21" y2="15" stroke-dasharray="3 2"/>
6+
<text x="5" y="7.5" font-size="3.5" font-family="sans-serif" fill="currentColor" stroke="none">P</text>
7+
<text x="5" y="13.5" font-size="3.5" font-family="sans-serif" fill="currentColor" stroke="none">DIV</text>
8+
<text x="5" y="19.5" font-size="3.5" font-family="sans-serif" fill="currentColor" stroke="none">H1</text>
9+
</svg>`;
10+
11+
export default showBlocks;
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
const mockIconSet = vi.fn();
4+
const mockPluginsAdd = vi.fn();
5+
6+
vi.mock('jodit', () => ({
7+
Jodit: {
8+
modules: {
9+
Icon: {
10+
set: mockIconSet,
11+
},
12+
},
13+
plugins: {
14+
add: mockPluginsAdd,
15+
},
16+
},
17+
}));
18+
19+
describe('show-blocks.svg', () => {
20+
it('should export a string containing SVG markup', async () => {
21+
const { default: svgContent } = await import('./show-blocks.svg.js');
22+
expect(typeof svgContent).toBe('string');
23+
expect(svgContent).toContain('<svg');
24+
});
25+
26+
it('should contain visual elements', async () => {
27+
const { default: svgContent } = await import('./show-blocks.svg.js');
28+
expect(svgContent).toContain('<rect');
29+
expect(svgContent).toContain('<line');
30+
});
31+
});
32+
33+
describe('show-blocks plugin', () => {
34+
const mockEditorDoc = {
35+
createElement: vi.fn(),
36+
head: { appendChild: vi.fn() },
37+
};
38+
39+
const mockClassList = {
40+
add: vi.fn(),
41+
remove: vi.fn(),
42+
};
43+
44+
const mockEditor = {
45+
registerButton: vi.fn(),
46+
registerCommand: vi.fn(),
47+
editor: {
48+
ownerDocument: mockEditorDoc,
49+
classList: mockClassList,
50+
},
51+
events: { fire: vi.fn() },
52+
o: { theme: 'default' },
53+
};
54+
55+
let mockStyleElement: {
56+
setAttribute: ReturnType<typeof vi.fn>;
57+
textContent: string | null;
58+
remove: ReturnType<typeof vi.fn>;
59+
};
60+
61+
beforeEach(() => {
62+
vi.clearAllMocks();
63+
vi.resetModules();
64+
65+
mockStyleElement = {
66+
setAttribute: vi.fn(),
67+
textContent: null,
68+
remove: vi.fn(),
69+
};
70+
71+
mockEditorDoc.createElement.mockReturnValue(mockStyleElement);
72+
});
73+
74+
const importPlugin = async () => {
75+
await import('./show-blocks');
76+
};
77+
78+
const getPluginCallback = (): ((editor: typeof mockEditor) => void) => {
79+
return mockPluginsAdd.mock.calls[0][1] as (editor: typeof mockEditor) => void;
80+
};
81+
82+
const getCommandFn = (): (() => void) => {
83+
return mockEditor.registerCommand.mock.calls[0][1] as () => void;
84+
};
85+
86+
it('should register icon via Jodit.modules.Icon.set with name showBlocks', async () => {
87+
await importPlugin();
88+
89+
expect(mockIconSet).toHaveBeenCalledWith('showBlocks', expect.any(String));
90+
});
91+
92+
it('should register plugin via Jodit.plugins.add with name showBlocks', async () => {
93+
await importPlugin();
94+
95+
expect(mockPluginsAdd).toHaveBeenCalledWith('showBlocks', expect.any(Function));
96+
});
97+
98+
it('should register button with tooltip Show Blocks', async () => {
99+
await importPlugin();
100+
101+
const pluginCallback = getPluginCallback();
102+
pluginCallback(mockEditor);
103+
104+
expect(mockEditor.registerButton).toHaveBeenCalledWith({
105+
name: 'showBlocks',
106+
group: 'other',
107+
options: {
108+
tooltip: 'Show Blocks',
109+
isActive: expect.any(Function),
110+
},
111+
});
112+
});
113+
114+
it('should register command named showBlocks', async () => {
115+
await importPlugin();
116+
117+
const pluginCallback = getPluginCallback();
118+
pluginCallback(mockEditor);
119+
120+
expect(mockEditor.registerCommand).toHaveBeenCalledWith('showBlocks', expect.any(Function));
121+
});
122+
123+
it('should report inactive state initially via isActive', async () => {
124+
await importPlugin();
125+
126+
const pluginCallback = getPluginCallback();
127+
pluginCallback(mockEditor);
128+
129+
const buttonConfig = mockEditor.registerButton.mock.calls[0][0] as {
130+
options: { isActive: () => boolean };
131+
};
132+
expect(buttonConfig.options.isActive()).toBe(false);
133+
});
134+
135+
it('should add show-blocks class to editor on first toggle', async () => {
136+
await importPlugin();
137+
138+
const pluginCallback = getPluginCallback();
139+
pluginCallback(mockEditor);
140+
141+
const commandFn = getCommandFn();
142+
commandFn();
143+
144+
expect(mockClassList.add).toHaveBeenCalledWith('jodit-show-blocks');
145+
});
146+
147+
it('should inject a style element on first toggle', async () => {
148+
await importPlugin();
149+
150+
const pluginCallback = getPluginCallback();
151+
pluginCallback(mockEditor);
152+
153+
const commandFn = getCommandFn();
154+
commandFn();
155+
156+
expect(mockEditorDoc.createElement).toHaveBeenCalledWith('style');
157+
expect(mockStyleElement.setAttribute).toHaveBeenCalledWith('data-jodit-show-blocks', '');
158+
expect(mockStyleElement.textContent).toBeTruthy();
159+
expect(mockEditorDoc.head.appendChild).toHaveBeenCalledWith(mockStyleElement);
160+
});
161+
162+
it('should include CSS rules for common block tags', async () => {
163+
await importPlugin();
164+
165+
const pluginCallback = getPluginCallback();
166+
pluginCallback(mockEditor);
167+
168+
const commandFn = getCommandFn();
169+
commandFn();
170+
171+
const css = mockStyleElement.textContent as string;
172+
expect(css).toContain('.jodit-show-blocks p');
173+
expect(css).toContain('.jodit-show-blocks div');
174+
expect(css).toContain('.jodit-show-blocks h1');
175+
expect(css).toContain('.jodit-show-blocks blockquote');
176+
expect(css).toContain('.jodit-show-blocks table');
177+
expect(css).toContain('border: 1px dashed');
178+
expect(css).toContain('content: "p"');
179+
expect(css).toContain('content: "h1"');
180+
});
181+
182+
it('should fire updateToolbar event on toggle', async () => {
183+
await importPlugin();
184+
185+
const pluginCallback = getPluginCallback();
186+
pluginCallback(mockEditor);
187+
188+
const commandFn = getCommandFn();
189+
commandFn();
190+
191+
expect(mockEditor.events.fire).toHaveBeenCalledWith('updateToolbar');
192+
});
193+
194+
it('should report active state after first toggle via isActive', async () => {
195+
await importPlugin();
196+
197+
const pluginCallback = getPluginCallback();
198+
pluginCallback(mockEditor);
199+
200+
const commandFn = getCommandFn();
201+
commandFn();
202+
203+
const buttonConfig = mockEditor.registerButton.mock.calls[0][0] as {
204+
options: { isActive: () => boolean };
205+
};
206+
expect(buttonConfig.options.isActive()).toBe(true);
207+
});
208+
209+
it('should remove show-blocks class and styles on second toggle', async () => {
210+
await importPlugin();
211+
212+
const pluginCallback = getPluginCallback();
213+
pluginCallback(mockEditor);
214+
215+
const commandFn = getCommandFn();
216+
217+
// Toggle on
218+
commandFn();
219+
// Toggle off
220+
commandFn();
221+
222+
expect(mockClassList.remove).toHaveBeenCalledWith('jodit-show-blocks');
223+
expect(mockStyleElement.remove).toHaveBeenCalled();
224+
});
225+
226+
it('should report inactive state after toggling off via isActive', async () => {
227+
await importPlugin();
228+
229+
const pluginCallback = getPluginCallback();
230+
pluginCallback(mockEditor);
231+
232+
const commandFn = getCommandFn();
233+
234+
// Toggle on then off
235+
commandFn();
236+
commandFn();
237+
238+
const buttonConfig = mockEditor.registerButton.mock.calls[0][0] as {
239+
options: { isActive: () => boolean };
240+
};
241+
expect(buttonConfig.options.isActive()).toBe(false);
242+
});
243+
244+
it('should not create duplicate style elements on repeated activations', async () => {
245+
await importPlugin();
246+
247+
const pluginCallback = getPluginCallback();
248+
pluginCallback(mockEditor);
249+
250+
const commandFn = getCommandFn();
251+
252+
// Toggle on, off, on
253+
commandFn();
254+
commandFn();
255+
commandFn();
256+
257+
// createElement should be called twice (once for first on, once for second on after removal)
258+
expect(mockEditorDoc.createElement).toHaveBeenCalledTimes(2);
259+
});
260+
});

0 commit comments

Comments
 (0)