Skip to content

Commit b92e560

Browse files
committed
add a rich text editor example
1 parent ae10f37 commit b92e560

File tree

6 files changed

+339
-0
lines changed

6 files changed

+339
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SQLPage rich text editor
2+
3+
This demo shows how to build an application where users can
4+
input and safely store rich text (with titles, bold, italics, and images).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
insert into blog_posts (title, content)
2+
values (:title, :content)
3+
returning
4+
'redirect' as component,
5+
'/' as link;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
select 'shell' as component,
2+
'Rich text editor' as title,
3+
'/rich_text_editor.js' as javascript_module;
4+
5+
6+
select 'form' as component,
7+
'Create a new blog post' as title,
8+
'create_blog_post' as action,
9+
'Create' as validate;
10+
11+
select 'title' as name, 'Blog post title' as label;
12+
select 'content' as name, 'textarea' as type, 'Your blog post here' as label;
13+
14+
select 'list' as component,
15+
'Blog posts' as title;
16+
17+
select title, sqlpage.link('post', json_object('id', id)) as link
18+
from blog_posts;
19+

examples/rich-text-editor/post.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
select 'shell' as component,
2+
title
3+
from blog_posts
4+
where id = $id;
5+
6+
select 'text' as component,
7+
content as contents_md
8+
from blog_posts
9+
where id = $id;
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import Quill from "https://esm.sh/[email protected]";
2+
import { toMarkdown as mdastUtilToMarkdown } from "https://esm.sh/[email protected]";
3+
4+
/**
5+
* Converts Quill Delta object to a Markdown string using mdast.
6+
* @param {object} delta - Quill Delta object (https://quilljs.com/docs/delta/).
7+
* @returns {string} - Markdown representation.
8+
*/
9+
function deltaToMarkdown(delta) {
10+
try {
11+
console.log("delta", delta);
12+
const mdastTree = deltaToMdast(delta); // Convert Delta to MDAST
13+
console.log("mdastTree", mdastTree);
14+
const options = {
15+
bullet: "*",
16+
listItemIndent: "one",
17+
};
18+
const markdown = mdastUtilToMarkdown(mdastTree, options); // Convert MDAST to Markdown
19+
console.log("markdown", markdown);
20+
return markdown;
21+
} catch (error) {
22+
console.error("Error during Delta to Markdown conversion:", error);
23+
try {
24+
return delta.ops
25+
.map((op) => (typeof op.insert === "string" ? op.insert : ""))
26+
.join("")
27+
.trim();
28+
} catch (e) {
29+
return "";
30+
}
31+
}
32+
}
33+
34+
/**
35+
* Creates a div to replace the textarea and prepares it for Quill.
36+
* @param {HTMLTextAreaElement} textarea - The original textarea.
37+
* @returns {HTMLDivElement} - The div element created for the Quill editor.
38+
*/
39+
function createAndReplaceTextarea(textarea) {
40+
const editorDiv = document.createElement("div");
41+
editorDiv.className = "mb-3";
42+
editorDiv.style.height = "250px";
43+
44+
const label = textarea.closest("label");
45+
if (!label) {
46+
textarea.parentNode.insertBefore(editorDiv, textarea);
47+
} else {
48+
label.parentNode.insertBefore(editorDiv, label.nextSibling);
49+
}
50+
textarea.style.display = "none";
51+
return editorDiv;
52+
}
53+
54+
/**
55+
* Returns the toolbar options array configured for Markdown compatibility.
56+
* @returns {Array} - Quill toolbar options.
57+
*/
58+
function getMarkdownToolbarOptions() {
59+
return [
60+
[{ header: [1, 2, 3, false] }],
61+
["bold", "italic"],
62+
["link", "image", "blockquote"],
63+
[{ list: "ordered" }, { list: "bullet" }],
64+
["clean"],
65+
];
66+
}
67+
68+
/**
69+
* Initializes a Quill editor instance on a given div.
70+
* @param {HTMLDivElement} editorDiv - The div element for the editor.
71+
* @param {Array} toolbarOptions - The toolbar configuration.
72+
* @param {string} initialValue - The initial content for the editor.
73+
* @returns {Quill} - The initialized Quill instance.
74+
*/
75+
function initializeQuillEditor(editorDiv, toolbarOptions, initialValue) {
76+
const quill = new Quill(editorDiv, {
77+
theme: "snow",
78+
modules: {
79+
toolbar: toolbarOptions,
80+
},
81+
});
82+
if (initialValue) {
83+
quill.setText(initialValue);
84+
}
85+
return quill;
86+
}
87+
88+
/**
89+
* Attaches a submit event listener to the form to update the hidden textarea.
90+
* @param {HTMLFormElement} form - The form containing the editor.
91+
* @param {HTMLTextAreaElement} textarea - The original (hidden) textarea.
92+
* @param {Quill} quill - The Quill editor instance.
93+
*/
94+
function updateTextareaOnSubmit(form, textarea, quill) {
95+
if (!form) {
96+
console.warn(
97+
"Textarea not inside a form, submission handling skipped for:",
98+
textarea.name || textarea.id,
99+
);
100+
return;
101+
}
102+
form.addEventListener("submit", (evt) => {
103+
const delta = quill.getContents();
104+
const markdownContent = deltaToMarkdown(delta);
105+
textarea.value = markdownContent;
106+
console.log(
107+
`Converted content for ${textarea.name || "textarea"} to Markdown and updated value.`,
108+
);
109+
});
110+
}
111+
112+
const initializeEditors = () => {
113+
const link = document.createElement("link");
114+
link.rel = "stylesheet";
115+
link.href = "https://esm.sh/[email protected]/dist/quill.snow.css";
116+
document.head.appendChild(link);
117+
118+
const textareas = document.getElementsByTagName("textarea");
119+
if (textareas.length === 0) {
120+
console.log("No textareas found to initialize Quill on.");
121+
return;
122+
}
123+
124+
const markdownToolbarOptions = getMarkdownToolbarOptions();
125+
let initializedCount = 0;
126+
127+
for (const textarea of textareas) {
128+
if (textarea.dataset.quillInitialized === "true") {
129+
continue;
130+
}
131+
132+
try {
133+
const initialValue = textarea.value;
134+
const form = textarea.closest("form");
135+
const editorDiv = createAndReplaceTextarea(textarea);
136+
const quill = initializeQuillEditor(
137+
editorDiv,
138+
markdownToolbarOptions,
139+
initialValue,
140+
);
141+
updateTextareaOnSubmit(form, textarea, quill);
142+
textarea.dataset.quillInitialized = "true";
143+
initializedCount++;
144+
} catch (error) {
145+
console.error(
146+
"Failed to initialize Quill for textarea:",
147+
textarea,
148+
error,
149+
);
150+
textarea.style.display = "";
151+
const errorMsg = document.createElement("p");
152+
errorMsg.textContent = "Failed to load rich text editor.";
153+
errorMsg.style.color = "red";
154+
textarea.parentNode.insertBefore(errorMsg, textarea.nextSibling);
155+
}
156+
}
157+
158+
if (initializedCount > 0) {
159+
console.log(
160+
`Successfully initialized Quill for ${initializedCount} textareas.`,
161+
);
162+
} else if (textareas.length > 0) {
163+
console.log(
164+
"Found 'rich-text-editor' textareas, but none were newly initialized (already initialized or failed).",
165+
);
166+
}
167+
};
168+
169+
function deltaToMdast(delta) {
170+
const mdast = {
171+
type: "root",
172+
children: [],
173+
};
174+
175+
let currentParagraph = null;
176+
let textBuffer = "";
177+
178+
for (const op of delta.ops) {
179+
if (op.delete) {
180+
continue;
181+
}
182+
183+
if (op.retain) {
184+
continue;
185+
}
186+
187+
if (typeof op.insert === "string") {
188+
const text = op.insert;
189+
const attributes = op.attributes || {};
190+
191+
if (text === "\n") {
192+
if (currentParagraph) {
193+
if (attributes.header) {
194+
const lines = textBuffer.split("\n");
195+
if (lines.length > 1) {
196+
const lastLine = lines.pop();
197+
const previousLines = lines.join("\n");
198+
199+
if (previousLines) {
200+
const paragraph = {
201+
type: "paragraph",
202+
children: [{ type: "text", value: previousLines }],
203+
};
204+
mdast.children.push(paragraph);
205+
}
206+
207+
const heading = {
208+
type: "heading",
209+
depth: attributes.header,
210+
children: [{ type: "text", value: lastLine }],
211+
};
212+
mdast.children.push(heading);
213+
} else {
214+
const heading = {
215+
type: "heading",
216+
depth: attributes.header,
217+
children: [{ type: "text", value: textBuffer }],
218+
};
219+
mdast.children.push(heading);
220+
}
221+
currentParagraph = null;
222+
textBuffer = "";
223+
} else {
224+
mdast.children.push(currentParagraph);
225+
currentParagraph = null;
226+
}
227+
}
228+
continue;
229+
}
230+
231+
textBuffer += text;
232+
let node = {
233+
type: "text",
234+
value: text,
235+
};
236+
237+
if (attributes.bold) {
238+
node = {
239+
type: "strong",
240+
children: [node],
241+
};
242+
}
243+
244+
if (attributes.italic) {
245+
node = {
246+
type: "emphasis",
247+
children: [node],
248+
};
249+
}
250+
251+
if (attributes.link) {
252+
node = {
253+
type: "link",
254+
url: attributes.link,
255+
children: [node],
256+
};
257+
}
258+
259+
if (!currentParagraph) {
260+
currentParagraph = {
261+
type: "paragraph",
262+
children: [],
263+
};
264+
}
265+
266+
currentParagraph.children.push(node);
267+
} else if (typeof op.insert === "object") {
268+
if (op.insert.image) {
269+
const imageNode = {
270+
type: "image",
271+
url: op.insert.image,
272+
title: op.attributes?.alt || "",
273+
alt: op.attributes?.alt || "",
274+
};
275+
276+
if (!currentParagraph) {
277+
currentParagraph = {
278+
type: "paragraph",
279+
children: [],
280+
};
281+
}
282+
283+
currentParagraph.children.push(imageNode);
284+
textBuffer = "";
285+
}
286+
}
287+
}
288+
289+
if (currentParagraph) {
290+
mdast.children.push(currentParagraph);
291+
}
292+
293+
return mdast;
294+
}
295+
296+
// --- Main Script Execution ---
297+
document.addEventListener("DOMContentLoaded", initializeEditors);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
create table blog_posts (
2+
id integer primary key autoincrement,
3+
title text not null,
4+
content text not null
5+
);

0 commit comments

Comments
 (0)