Skip to content

Commit

Permalink
feat(rte): add indent handler and maintain format on enter
Browse files Browse the repository at this point in the history
  • Loading branch information
gjulivan committed Sep 18, 2024
1 parent f9e9b94 commit c706686
Show file tree
Hide file tree
Showing 15 changed files with 240 additions and 11 deletions.
1 change: 1 addition & 0 deletions packages/pluggableWidgets/rich-text-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"classnames": "^2.2.6",
"dompurify": "^2.5.0",
"linkifyjs": "^4.1.3",
"parchment": "^3.0.0",
"quill": "^2.0.2"
}
}
1 change: 1 addition & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default function RichText(props: RichTextContainerProps): JSX.Element {
"form-control",
stringAttribute.readOnly ? `widget-rich-text-readonly-${readOnlyStyle}` : ""
)}
enableStatusBar={props.enableStatusBar && !stringAttribute.readOnly}
/>
)}
<ValidationAlert>{stringAttribute.validation}</ValidationAlert>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Quill from "quill";
import { MutableRefObject } from "react";

import { Range } from "quill/core/selection";
import Keyboard, { Context } from "quill/modules/keyboard";
import { Scope } from "parchment";
import { Delta } from "quill/core";
/**
* give custom indent handler to use our custom "indent-left" and "indent-right" formats
* give custom indent handler to use our custom "indent-left" and "indent-right" formats (formats/indent.ts)
*/
export function getIndentHandler(ref: MutableRefObject<Quill | null>): (value: any) => void {
const customIndentHandler = (value: any): void => {
Expand All @@ -13,15 +16,53 @@ export function getIndentHandler(ref: MutableRefObject<Quill | null>): (value: a
const indent = parseInt((formats.indent as string) || "0", 10);
if (value === "+1" || value === "-1") {
let modifier = value === "+1" ? 1 : -1;
const formatHandler = formats.list
? "indent"
: formats.direction === "rtl"
? "indent-right"
: "indent-left";
if (formats.direction === "rtl") {
modifier *= -1;
ref.current?.format("indent-right", indent + modifier, Quill.sources.USER);
} else {
ref.current?.format("indent-left", indent + modifier, Quill.sources.USER);
}
ref.current?.format(formatHandler, indent + modifier, Quill.sources.USER);
}
}
};

return customIndentHandler;
}

/**
* copied with modification from "handleEnter" function in :
* https://github.com/slab/quill/blob/main/packages/quill/src/modules/keyboard.ts
*/
export function enterKeyKeyboardHandler(this: Keyboard, range: Range, context: Context): any {
const lineFormats = Object.keys(context.format).reduce((formats: Record<string, unknown>, format) => {
if (this.quill.scroll.query(format, Scope.BLOCK) && !Array.isArray(context.format[format])) {
formats[format] = context.format[format];
}
return formats;
}, {});

const delta = new Delta().retain(range.index).delete(range.length).insert("\n", lineFormats);
this.quill.updateContents(delta, Quill.sources.USER);
this.quill.setSelection(range.index + 1, Quill.sources.SILENT);

/**
* NOTE: Modified from default handler to maintain format on new line
* Applies previous formats on the new line. This was dropped in
* https://github.com/slab/quill/commit/ba5461634caa8e24641b687f2d1a8768abfec640
*/
Object.keys(context.format).forEach(name => {
if (lineFormats[name] != null) {
return;
}
if (Array.isArray(context.format[name])) {
return;
}
if (name === "code" || name === "link") {
return;
}
this.quill.format(name, context.format[name], Quill.sources.USER);
});
}
13 changes: 11 additions & 2 deletions packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import Delta from "quill-delta";
import Dialog from "./ModalDialog/Dialog";
import "../utils/customPluginRegisters";
import { useEmbedModal } from "./CustomToolbars/useEmbedModal";
import { getIndentHandler } from "./CustomToolbars/toolbarHandlers";
import { getIndentHandler, enterKeyKeyboardHandler } from "./CustomToolbars/toolbarHandlers";
import MxQuill from "../utils/MxQuill";

export interface EditorProps {
defaultValue?: string;
Expand Down Expand Up @@ -69,6 +70,14 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
const options: QuillOptions = {
theme,
modules: {
keyboard: {
bindings: {
enter: {
key: "Enter",
handler: enterKeyKeyboardHandler
}
}
},
toolbar: toolbarId
? {
container: Array.isArray(toolbarId) ? toolbarId : `#${toolbarId}`,
Expand All @@ -83,7 +92,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
},
readOnly
};
const quill = new Quill(editorContainer, options);
const quill = new MxQuill(editorContainer, options);
ref.current = quill;
quill.on(Quill.events.TEXT_CHANGE, (...arg) => {
onTextChangeRef.current?.(...arg);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export type ChildDialogProps = LinkDialogBaseProps | VideoDialogBaseProps | View

export type DialogProps = BaseDialogProps & ChildDialogProps;

/**
* Dialog components that will be shown on toolbar's button
*/
export default function Dialog(props: DialogProps): ReactElement {
const { isOpen, onOpenChange, dialogType, config } = props;
return (
Expand Down
149 changes: 149 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* this file overrides Quill instance.
* allowing us to override certain function that is not easy to extend.
*/
import { type Blot, ParentBlot } from "parchment";
import Quill, { QuillOptions } from "quill";
import TextBlot, { escapeText } from "quill/blots/text";
import Editor from "quill/core/editor";

/**
* Rich Text's extended Quill Editor
* allowing us to override certain editor's function, such as: getHTML
*/
class MxEditor extends Editor {
/**
* copied without modification from Quill's editor
* https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
getHTML(index: number, length: number): string {
const [line, lineOffset] = this.scroll.line(index);
if (line) {
const lineLength = line.length();
const isWithinLine = line.length() >= lineOffset + length;
if (isWithinLine && !(lineOffset === 0 && length === lineLength)) {
return convertHTML(line, lineOffset, length, true);
}
return convertHTML(this.scroll, index, length, true);
}
return "";
}
}

/**
* Extension's of quill to allow us replacing the editor instance.
*/
export default class MxQuill extends Quill {
constructor(container: HTMLElement | string, options: QuillOptions = {}) {
super(container, options);
this.editor = new MxEditor(this.scroll);
}
}

/**
* copied without modification from Quill's editor
* https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
function getListType(type: string | undefined): [tag: string, attr: string] {
const tag = type === "ordered" ? "ol" : "ul";
switch (type) {
case "checked":
return [tag, ' data-list="checked"'];
case "unchecked":
return [tag, ' data-list="unchecked"'];
default:
return [tag, ""];
}
}

// based on LIST_STYLE variables sequence in https://github.com/slab/quill/blob/main/packages/quill/src/assets/core.styl
const ListSequence = ["ordered", "lower-alpha", "lower-roman"];

// construct proper "list-style-type" style attribute
function getExpectedType(type: string | undefined, indent: number): string {
const currentIndex = ListSequence.indexOf(type || "ordered");
const expectedIndex = (currentIndex + indent) % 3;
const expectedType = ListSequence[expectedIndex];
return expectedType === "ordered" ? "decimal" : expectedType === "bullet" ? "disc" : expectedType;
}

/**
* Copy with modification from https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
function convertListHTML(items: any[], lastIndent: number, types: string[]): string {
if (items.length === 0) {
const [endTag] = getListType(types.pop());
if (lastIndent <= 0) {
return `</li></${endTag}>`;
}
return `</li></${endTag}>${convertListHTML([], lastIndent - 1, types)}`;
}
const [{ child, offset, length, indent, type }, ...rest] = items;
const [tag, attribute] = getListType(type);
// modified by web-content: get proper list-style-type
const expectedType = getExpectedType(type, indent);
if (indent > lastIndent) {
types.push(type);
if (indent === lastIndent + 1) {
// modified by web-content: adding list-style-type to allow retaining list style when converted to html
return `<${tag} style="list-style-type: ${expectedType}"><li${attribute}>${convertHTML(
child,
offset,
length
)}${convertListHTML(rest, indent, types)}`;
}
return `<${tag}><li>${convertListHTML(items, lastIndent + 1, types)}`;
}
const previousType = types[types.length - 1];
if (indent === lastIndent && type === previousType) {
return `</li><li${attribute}>${convertHTML(child, offset, length)}${convertListHTML(rest, indent, types)}`;
}
const [endTag] = getListType(types.pop());
return `</li></${endTag}>${convertListHTML(items, lastIndent - 1, types)}`;
}

/**
* copied without modification from Quill's editor
* https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
* allowing us to use our own convertListHTML function.
*/
function convertHTML(blot: Blot, index: number, length: number, isRoot = false): string {
if ("html" in blot && typeof blot.html === "function") {
return blot.html(index, length);
}
if (blot instanceof TextBlot) {
return escapeText(blot.value().slice(index, index + length));
}
if (blot instanceof ParentBlot) {
// TODO fix API
if (blot.statics.blotName === "list-container") {
const items: any[] = [];
blot.children.forEachAt(index, length, (child, offset, childLength) => {
const formats = "formats" in child && typeof child.formats === "function" ? child.formats() : {};
items.push({
child,
offset,
length: childLength,
indent: formats.indent || 0,
type: formats.list
});
});
return convertListHTML(items, -1, []);
}
const parts: string[] = [];
blot.children.forEachAt(index, length, (child, offset, childLength) => {
parts.push(convertHTML(child, offset, childLength));
});
if (isRoot || blot.statics.blotName === "list") {
return parts.join("");
}
const { outerHTML, innerHTML } = blot.domNode as Element;
const [start, end] = outerHTML.split(`>${innerHTML}<`);
// TODO cleanup
if (start === "<table") {
return `<table style="border: 1px solid #000;">${parts.join("")}<${end}`;
}
return `${start}>${parts.join("")}<${end}`;
}
return blot.domNode instanceof Element ? blot.domNode.outerHTML : "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class Empty {
}
}

/**
* Custom format registration for quill.
*/
Quill.debug("error");
Quill.register({ "themes/snow": MendixTheme }, true);
Quill.register(CustomListItem, true);
Expand All @@ -25,4 +28,5 @@ Quill.register(direction, true);
Quill.register(alignment, true);
Quill.register(IndentLeftStyle, true);
Quill.register(IndentRightStyle, true);
// add empty handler for view code, this format is handled by toolbar's custom config via ViewCodeDialog
Quill.register({ "ui/view-code": Empty });
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.widget-rich-text {
.ql-editor {
ol {
list-style-type: none;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ListItem from "quill/formats/list";

import "./customList.scss";
/**
* adding custom list item, alowing extra list style
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const indentLists = ["3em", "6em", "9em", "12em", "15em", "18em", "21em", "24em"

/**
* overriding current quill's indent format by using inline style instead of classname
* toolbar's indent button also have to be overriden using getIndentHandler
*/
class IndentAttributor extends StyleAttributor {
add(node: HTMLElement, value: string | number): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ function getLink(url: string): string {
return results;
}

/**
* Custom Link handler, allowing extra config: target and default protocol.
*/
export default class CustomLink extends Link {
format(name: string, value: unknown): void {
if (name !== this.statics.blotName || !value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Video from "quill/formats/video";
import { videoConfigType } from "../formats";

/**
* custom video link handler, allowing width and height config
*/
export default class CustomVideo extends Video {
html(): string {
return this.domNode.outerHTML;
Expand Down
7 changes: 4 additions & 3 deletions packages/pluggableWidgets/rich-text-web/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export function updateLegacyQuillFormats(quill: Quill): boolean {
if (results.isDirty) {
quill.setContents(results.data, Quill.sources.USER);
}

return results.isDirty;
}

Expand All @@ -39,8 +38,10 @@ export function transformLegacyQuillFormats(delta: Delta): { data: Op[]; isDirty
let isDirty = false;
const newDelta: Op[] = delta.map(d => {
if (d.attributes && d.attributes.indent) {
d.attributes["indent-left"] = (d.attributes.indent as number) * 3;
delete d.attributes.indent;
if (!d.attributes.list) {
d.attributes["indent-left"] = (d.attributes.indent as number) * 3;
delete d.attributes.indent;
}
if (!isDirty) {
isDirty = true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import SnowTheme from "quill/themes/snow";

/**
* Override quill's current theme.
*/
export default class MendixTheme extends SnowTheme {
buildPickers(selects: NodeListOf<HTMLSelectElement>, icons: Record<string, string | Record<string, string>>): void {
super.buildPickers(selects, icons);
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c706686

Please sign in to comment.