Skip to content

Commit 82f151e

Browse files
committed
feat: added Jodit editor to frontend as well
1 parent 73a6e8e commit 82f151e

File tree

7 files changed

+262
-10
lines changed

7 files changed

+262
-10
lines changed

phpmyfaq/add.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
'categories' => $category->getCategoryTree(),
113113
'msgNewContentTheme' => Translation::get(key: 'msgNewContentTheme'),
114114
'readonly' => $readonly,
115-
'printQuestion' => $question,
115+
'question' => $question,
116116
'msgNewContentArticle' => Translation::get(key: 'msgNewContentArticle'),
117117
'msgNewContentKeywords' => Translation::get(key: 'msgNewContentKeywords'),
118118
'msgNewContentLink' => Translation::get(key: 'msgNewContentLink'),

phpmyfaq/assets/scss/layout/_faq.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import 'jodit/es2021/jodit.min.css';
2+
13
.pmf-voting-star {
24
background: none;
35
border: none;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Unit tests for the Add FAQ Editor
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-12-22
14+
*/
15+
16+
import { describe, it, expect, beforeEach } from 'vitest';
17+
import { renderFaqEditor, getJoditEditor } from './editor';
18+
19+
describe('renderAddFaqEditor', () => {
20+
beforeEach(() => {
21+
document.body.innerHTML = '';
22+
});
23+
24+
it('should not initialize editor when textarea is missing', () => {
25+
renderFaqEditor();
26+
const editor = getJoditEditor();
27+
expect(editor).toBeNull();
28+
});
29+
30+
it('should find the answer textarea element', () => {
31+
document.body.innerHTML = '<textarea id="answer"></textarea>';
32+
const textarea = document.getElementById('answer');
33+
expect(textarea).not.toBeNull();
34+
expect(textarea?.tagName).toBe('TEXTAREA');
35+
});
36+
37+
it('should initialize with correct textarea id', () => {
38+
document.body.innerHTML = `
39+
<form id="pmf-add-faq-form">
40+
<textarea id="answer" name="answer"></textarea>
41+
</form>
42+
`;
43+
const answerField = document.getElementById('answer');
44+
expect(answerField).toBeDefined();
45+
});
46+
});
47+
48+
describe('getJoditAddEditor', () => {
49+
it('should return null initially', () => {
50+
const editor = getJoditEditor();
51+
expect(editor).toBeNull();
52+
});
53+
});

phpmyfaq/assets/src/faq/editor.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Reduced Jodit Editor for Frontend FAQ Submissions
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-12-22
14+
*/
15+
16+
import { Jodit } from 'jodit';
17+
18+
import 'jodit/esm/plugins/clean-html/clean-html.js';
19+
import 'jodit/esm/plugins/clipboard/clipboard.js';
20+
import 'jodit/esm/plugins/delete/delete.js';
21+
import 'jodit/esm/plugins/fullsize/fullsize.js';
22+
import 'jodit/esm/plugins/image/image.js';
23+
import 'jodit/esm/plugins/indent/indent.js';
24+
import 'jodit/esm/plugins/justify/justify.js';
25+
import 'jodit/esm/plugins/paste-from-word/paste-from-word.js';
26+
import 'jodit/esm/plugins/preview/preview.js';
27+
import 'jodit/esm/plugins/resizer/resizer.js';
28+
import 'jodit/esm/plugins/select/select.js';
29+
import 'jodit/esm/plugins/source/source.js';
30+
31+
let joditEditorInstance: any = null;
32+
33+
export const getJoditEditor = () => joditEditorInstance;
34+
35+
/**
36+
* Renders a reduced Jodit editor for the FAQ add form
37+
* This is a simplified version compared to the admin editor with:
38+
* - Basic formatting only
39+
* - No file uploads (security)
40+
* - No advanced features (tables, media, custom plugins)
41+
*/
42+
export const renderFaqEditor = () => {
43+
const answerField = document.getElementById('answer') as HTMLTextAreaElement | null;
44+
if (!answerField) {
45+
return;
46+
}
47+
48+
// Detect browser color scheme preference (dark/light)
49+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
50+
51+
const joditEditor = Jodit.make(answerField, {
52+
zIndex: 0,
53+
readonly: false,
54+
beautifyHTML: false,
55+
sourceEditor: 'area',
56+
activeButtonsInReadOnly: ['source', 'fullsize', 'preview'],
57+
toolbarButtonSize: 'middle',
58+
theme: prefersDark.matches ? 'dark' : 'default',
59+
saveModeInStorage: false,
60+
spellcheck: true,
61+
editorClassName: false,
62+
triggerChangeEvent: true,
63+
width: 'auto',
64+
height: 'auto',
65+
minHeight: 300,
66+
direction: '',
67+
language: 'auto',
68+
debugLanguage: false,
69+
tabIndex: -1,
70+
toolbar: true,
71+
enter: 'p',
72+
defaultMode: 1, // MODE_WYSIWYG
73+
useSplitMode: false,
74+
askBeforePasteFromWord: true,
75+
processPasteFromWord: true,
76+
defaultActionOnPasteFromWord: 'insert_clear_html',
77+
colors: {
78+
greyscale: [
79+
'#000000',
80+
'#434343',
81+
'#666666',
82+
'#999999',
83+
'#B7B7B7',
84+
'#CCCCCC',
85+
'#D9D9D9',
86+
'#EFEFEF',
87+
'#F3F3F3',
88+
'#FFFFFF',
89+
],
90+
palette: [
91+
'#980000',
92+
'#FF0000',
93+
'#FF9900',
94+
'#FFFF00',
95+
'#00F0F0',
96+
'#00FFFF',
97+
'#4A86E8',
98+
'#0000FF',
99+
'#9900FF',
100+
'#FF00FF',
101+
],
102+
},
103+
colorPickerDefaultTab: 'background',
104+
imageDefaultWidth: 300,
105+
removeButtons: [],
106+
disablePlugins: ['file', 'video', 'media', 'table'],
107+
extraPlugins: [],
108+
extraButtons: [],
109+
buttons: [
110+
'source',
111+
'|',
112+
'bold',
113+
'italic',
114+
'underline',
115+
'strikethrough',
116+
'|',
117+
'ul',
118+
'ol',
119+
'|',
120+
'outdent',
121+
'indent',
122+
'|',
123+
'left',
124+
'center',
125+
'right',
126+
'|',
127+
'link',
128+
'image',
129+
'|',
130+
'undo',
131+
'redo',
132+
'|',
133+
'eraser',
134+
'fullsize',
135+
'preview',
136+
],
137+
events: {},
138+
textIcons: false,
139+
uploader: {
140+
url: '', // Disabled for frontend security
141+
},
142+
filebrowser: {
143+
ajax: {
144+
url: '',
145+
},
146+
},
147+
});
148+
149+
const setJoditTheme = (theme: 'dark' | 'default'): void => {
150+
(joditEditor as any).options.theme = theme;
151+
152+
const container: HTMLDivElement = joditEditor.container;
153+
container.classList.remove('jodit_theme_default', 'jodit_theme_dark');
154+
container.classList.add(theme === 'dark' ? 'jodit_theme_dark' : 'jodit_theme_default');
155+
};
156+
157+
const applyTheme = (): void => {
158+
setJoditTheme(prefersDark.matches ? 'dark' : 'default');
159+
};
160+
161+
if (typeof prefersDark.addEventListener === 'function') {
162+
prefersDark.addEventListener('change', applyTheme);
163+
} else if (typeof (prefersDark as any).addListener === 'function') {
164+
(prefersDark as any).addListener(applyTheme);
165+
}
166+
167+
const applyThemeFromAttribute = (): void => {
168+
const themeAttr: string | null = document.documentElement.getAttribute('data-bs-theme');
169+
setJoditTheme(themeAttr === 'dark' ? 'dark' : 'default');
170+
};
171+
172+
const themeObserver = new MutationObserver((mutations): void => {
173+
for (const m of mutations) {
174+
if (m.type === 'attributes' && m.attributeName === 'data-bs-theme') {
175+
applyThemeFromAttribute();
176+
}
177+
}
178+
});
179+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] });
180+
181+
window.addEventListener('storage', (e: StorageEvent): void => {
182+
if (e.key === 'pmf-theme') {
183+
applyThemeFromAttribute();
184+
}
185+
});
186+
187+
applyThemeFromAttribute();
188+
189+
joditEditorInstance = joditEditor;
190+
};

phpmyfaq/assets/src/faq/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './comments';
2+
export * from './editor';
23
export * from './faq';
34
export * from './highlight';
45
export * from './voting';

phpmyfaq/assets/src/frontend.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
handleShareLinkButton,
2323
handleShowFaq,
2424
handleUserVoting,
25+
renderFaqEditor,
2526
} from './faq';
2627
import { handleAutoComplete, handleCategorySelection, handleQuestion } from './search';
2728
import {
@@ -63,6 +64,12 @@ document.addEventListener('DOMContentLoaded', (): void => {
6364
// Handle Adds a FAQ
6465
handleAddFaq();
6566

67+
// Initialize Jodit editor for FAQ add form if WYSIWYG is enabled
68+
const addFaqForm: HTMLFormElement | null = document.querySelector('#pmf-add-faq-form');
69+
if (addFaqForm && addFaqForm.dataset.wysiwygEnabled === 'true') {
70+
renderFaqEditor();
71+
}
72+
6673
// Handle show FAQ
6774
handleShowFaq();
6875
handleShareLinkButton();

phpmyfaq/assets/templates/default/add.twig

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
</div>
2222
</div>
2323

24-
<form id="pmf-add-faq-form" action="#" method="post" accept-charset="utf-8" class="needs-validation" novalidate>
24+
<form id="pmf-add-faq-form" action="#" method="post" accept-charset="utf-8" class="needs-validation"
25+
data-wysiwyg-enabled="{{ enableWysiwygEditor ? 'true' : 'false' }}" novalidate>
2526
<input type="hidden" name="lang" id="lang" value="{{ lang }}">
2627
<input type="hidden" value="{{ openQuestionID }}" id="openQuestionID" name="openQuestionID">
2728

@@ -31,7 +32,8 @@
3132
{% if id3_required == 'required' %}<span class="pmf-required-asterisk"> *</span>{% endif %}
3233
</label>
3334
<div class="col-sm-9">
34-
<input type="text" class="form-control" name="name" id="name" value="{{ defaultContentName }}" {{ id3_required }}>
35+
<input type="text" class="form-control" name="name" id="name" value="{{ defaultContentName }}"
36+
{{ id3_required }}>
3537
</div>
3638
</div>
3739
{% endif %}
@@ -42,7 +44,8 @@
4244
{% if id4_required == 'required' %}<span class="pmf-required-asterisk"> *</span>{% endif %}
4345
</label>
4446
<div class="col-sm-9">
45-
<input type="email" class="form-control" name="email" id="email" value="{{ defaultContentMail }}" {{ id4_required }}>
47+
<input type="email" class="form-control" name="email" id="email" value="{{ defaultContentMail }}"
48+
{{ id4_required }}>
4649
</div>
4750
</div>
4851
{% endif %}
@@ -71,9 +74,8 @@
7174
{% if id6_required == 'required' %}<span class="pmf-required-asterisk"> *</span>{% endif %}
7275
</label>
7376
<div class="col-sm-9">
74-
<textarea class="form-control" cols="37" rows="3" name="question" id="question" {{ id6_required }} {{ readonly }}>
75-
{{ printQuestion }}</textarea
76-
>
77+
<textarea class="form-control" cols="37" rows="3" name="question" id="question" {{ id6_required }}
78+
{{ readonly }}>{{ question }}</textarea>
7779
</div>
7880
</div>
7981
{% endif %}
@@ -113,9 +115,6 @@
113115
</form>
114116
</section>
115117

116-
{% if enableWysiwygEditor == true %}
117-
<script src="{{ baseHref }}admin/assets/js/editor/tinymce.min.js?{{ currentTimestamp }}"></script>
118-
{% endif %}
119118
{% endif %}
120119

121120
{% endblock %}

0 commit comments

Comments
 (0)