Skip to content

Commit 3d9ab82

Browse files
committed
Enhance rich text editor: add required field validation for content and implement Markdown to Quill Delta conversion
fixes #913
1 parent 845e037 commit 3d9ab82

File tree

2 files changed

+150
-4
lines changed

2 files changed

+150
-4
lines changed

examples/rich-text-editor/index.sql

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ select 'form' as component,
99
'Create' as validate;
1010

1111
select 'title' as name, 'Blog post title' as label, 'My new post' as value;
12-
select 'content' as name, 'textarea' as type, 'Your blog post here' as label, 'Your blog post here' as value;
12+
select 'content' as name, 'textarea' as type, 'Your blog post here' as label, 'Your blog post here' as value, true as required;
1313

1414
select 'list' as component,
1515
'Blog posts' as title;

examples/rich-text-editor/rich_text_editor.js

+149-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { toMarkdown as mdastUtilToMarkdown } from "https://esm.sh/[email protected]";
22
import Quill from "https://esm.sh/[email protected]";
3+
import { fromMarkdown } from "https://esm.sh/[email protected]";
34

45
/**
56
* @typedef {Object} QuillAttributes
@@ -61,7 +62,8 @@ function createAndReplaceTextarea(textarea) {
6162
} else {
6263
label.parentNode.insertBefore(editorDiv, label.nextSibling);
6364
}
64-
textarea.style.display = "none";
65+
// Hide the original textarea, but keep it focusable for validation
66+
textarea.style = "transform: scale(0); position: absolute; opacity: 0;";
6567
return editorDiv;
6668
}
6769

@@ -105,11 +107,146 @@ function initializeQuillEditor(editorDiv, toolbarOptions, initialValue) {
105107
],
106108
});
107109
if (initialValue) {
108-
quill.setText(initialValue);
110+
const delta = markdownToDelta(initialValue);
111+
quill.setContents(delta);
109112
}
110113
return quill;
111114
}
112115

116+
/**
117+
* Converts Markdown string to a Quill Delta object.
118+
* @param {string} markdown - The markdown string to convert.
119+
* @returns {QuillDelta} - Quill Delta representation.
120+
*/
121+
function markdownToDelta(markdown) {
122+
try {
123+
const mdastTree = fromMarkdown(markdown);
124+
return mdastToDelta(mdastTree);
125+
} catch (error) {
126+
console.error("Error parsing markdown:", error);
127+
return { ops: [{ insert: markdown }] };
128+
}
129+
}
130+
131+
/**
132+
* Converts MDAST to Quill Delta.
133+
* @param {MdastNode} tree - The MDAST tree to convert.
134+
* @returns {QuillDelta} - Quill Delta representation.
135+
*/
136+
function mdastToDelta(tree) {
137+
const delta = { ops: [] };
138+
if (!tree || !tree.children) return delta;
139+
140+
for (const node of tree.children) {
141+
traverseMdastNode(node, delta);
142+
}
143+
144+
return delta;
145+
}
146+
147+
/**
148+
* Recursively traverse MDAST nodes and convert to Delta operations.
149+
* @param {MdastNode} node - The MDAST node to process.
150+
* @param {QuillDelta} delta - The Delta object to append operations to.
151+
* @param {QuillAttributes} [attributes={}] - The current attributes to apply.
152+
*/
153+
function traverseMdastNode(node, delta, attributes = {}) {
154+
if (!node) return;
155+
156+
switch (node.type) {
157+
case 'root':
158+
for (const child of node.children || []) {
159+
traverseMdastNode(child, delta);
160+
}
161+
break;
162+
163+
case 'paragraph':
164+
for (const child of node.children || []) {
165+
traverseMdastNode(child, delta, attributes);
166+
}
167+
delta.ops.push({ insert: '\n' });
168+
break;
169+
170+
case 'heading':
171+
for (const child of node.children || []) {
172+
traverseMdastNode(child, delta, { header: node.depth });
173+
}
174+
delta.ops.push({ insert: '\n', attributes: { header: node.depth } });
175+
break;
176+
177+
case 'text':
178+
delta.ops.push({ insert: node.value || '', attributes });
179+
break;
180+
181+
case 'strong':
182+
for (const child of node.children || []) {
183+
traverseMdastNode(child, delta, { ...attributes, bold: true });
184+
}
185+
break;
186+
187+
case 'emphasis':
188+
for (const child of node.children || []) {
189+
traverseMdastNode(child, delta, { ...attributes, italic: true });
190+
}
191+
break;
192+
193+
case 'link':
194+
for (const child of node.children || []) {
195+
traverseMdastNode(child, delta, { ...attributes, link: node.url });
196+
}
197+
break;
198+
199+
case 'image':
200+
delta.ops.push({
201+
insert: { image: node.url },
202+
attributes: { alt: node.alt || '' }
203+
});
204+
break;
205+
206+
case 'list':
207+
for (const child of node.children || []) {
208+
traverseMdastNode(child, delta, {
209+
...attributes,
210+
list: node.ordered ? 'ordered' : 'bullet'
211+
});
212+
}
213+
break;
214+
215+
case 'listItem':
216+
for (const child of node.children || []) {
217+
traverseMdastNode(child, delta, attributes);
218+
}
219+
break;
220+
221+
case 'blockquote':
222+
for (const child of node.children || []) {
223+
traverseMdastNode(child, delta, { ...attributes, blockquote: true });
224+
}
225+
break;
226+
227+
case 'code':
228+
delta.ops.push({
229+
insert: node.value || '',
230+
attributes: { 'code-block': node.lang || 'plain' }
231+
});
232+
delta.ops.push({ insert: '\n', attributes: { 'code-block': node.lang || 'plain' } });
233+
break;
234+
235+
case 'inlineCode':
236+
delta.ops.push({ insert: node.value || '', attributes: { code: true } });
237+
break;
238+
239+
default:
240+
if (node.children) {
241+
for (const child of node.children) {
242+
traverseMdastNode(child, delta, attributes);
243+
}
244+
} else if (node.value) {
245+
delta.ops.push({ insert: node.value, attributes });
246+
}
247+
}
248+
}
249+
113250
/**
114251
* Attaches a submit event listener to the form to update the hidden textarea.
115252
* @param {HTMLFormElement|null} form - The form containing the editor.
@@ -125,10 +262,19 @@ function updateTextareaOnSubmit(form, textarea, quill) {
125262
);
126263
return;
127264
}
128-
form.addEventListener("submit", () => {
265+
form.addEventListener("submit", (event) => {
129266
const delta = quill.getContents();
130267
const markdownContent = deltaToMarkdown(delta);
131268
textarea.value = markdownContent;
269+
if (textarea.required && !markdownContent) {
270+
textarea.setCustomValidity(`${textarea.name} cannot be empty`);
271+
quill.once("text-change", (delta) => {
272+
textarea.value = deltaToMarkdown(delta);
273+
textarea.setCustomValidity("");
274+
});
275+
quill.focus();
276+
event.preventDefault();
277+
}
132278
});
133279
}
134280

0 commit comments

Comments
 (0)