Skip to content

Commit

Permalink
chore(rte): add dialog modal
Browse files Browse the repository at this point in the history
  • Loading branch information
gjulivan committed Aug 7, 2024
1 parent 78ea54f commit b91897a
Show file tree
Hide file tree
Showing 17 changed files with 646 additions and 20 deletions.
4 changes: 3 additions & 1 deletion packages/pluggableWidgets/rich-text-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@mendix/widget-plugin-test-utils": "workspace:*",
"@rollup/plugin-json": "^4.1.0",
"@types/dompurify": "^2.4.0",
"@types/react-modal": "^3.16.3",
"@types/sanitize-html": "^1.27.2",
"cross-env": "^7.0.3",
"postcss": "^8.4.21",
Expand All @@ -61,6 +62,7 @@
"dependencies": {
"classnames": "^2.2.6",
"dompurify": "^2.5.0",
"quill": "^2.0.2"
"quill": "^2.0.2",
"react-modal": "^3.16.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export enum FormatterType {
DateTime = "datetime"
}

function emptyFunction(): null {
return null;
}
const emptyAction = { canExecute: true, isExecuting: false, execute: emptyFunction };

export function preview(props: RichTextPreviewProps): ReactElement {
const stringAttribute = {
value: `[${
Expand All @@ -26,10 +31,10 @@ export function preview(props: RichTextPreviewProps): ReactElement {
return { valid: true, value: "" };
}
} as SimpleFormatter<string>,
setValidator: () => {},
setValue: () => {},
setTextValue: () => {},
setFormatter: () => {}
setValidator: emptyFunction,
setValue: emptyFunction,
setTextValue: emptyFunction,
setFormatter: emptyFunction
} as EditableValue<string>;

return (
Expand All @@ -40,9 +45,9 @@ export function preview(props: RichTextPreviewProps): ReactElement {
width={props.width ?? 0}
height={props.height ?? 0}
minHeight={props.minHeight ?? 0}
onChange={{ canExecute: true, isExecuting: false, execute: () => {} }}
onFocus={{ canExecute: true, isExecuting: false, execute: () => {} }}
onBlur={{ canExecute: true, isExecuting: false, execute: () => {} }}
onChange={emptyAction}
onFocus={emptyAction}
onBlur={emptyAction}
stringAttribute={stringAttribute}
className={classNames("widget-rich-text", "form-control")}
toolbarOptions={[
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type Quill from "quill";
import { useState, MutableRefObject, Dispatch, SetStateAction } from "react";
import { type ChildDialogProps } from "../ModalDialog/Dialog";
import { type linkConfigType, type videoConfigType, type videoEmbedConfigType } from "../../utils/formats";
import { type VideoFormType } from "../ModalDialog/VideoDialog";

type ModalReturnType = {
showDialog: boolean;
setShowDialog: Dispatch<SetStateAction<boolean>>;
dialogConfig: ChildDialogProps;
customLinkHandler(value: any): void;
customVideoHandler(value: any): void;
};

export function useEmbedModal(ref: MutableRefObject<Quill | null>): ModalReturnType {
const [showDialog, setShowDialog] = useState<boolean>(false);
const [dialogConfig, setDialogConfig] = useState<ChildDialogProps>({});

const customLinkHandler = (value: any): void => {
if (value === true) {
setDialogConfig({
dialogType: "link",
config: {
onSubmit: (value: linkConfigType) => {
ref.current?.format("link", value);

setShowDialog(false);
},
onClose: () => setShowDialog(false)
}
});
setShowDialog(true);
} else {
ref.current?.format("link", false);
setShowDialog(false);
}
};

const customVideoHandler = (value: any): void => {
if (value === true) {
console.log("");
setDialogConfig({
dialogType: "video",
config: {
onSubmit: (value: VideoFormType) => {
// const currentValue = Object.hasOwn(value, "src") && (value as VideoFormTypeGeneral).src !== undefined ? value as VideoFormTypeGeneral : value as VideoFormTypeEmbed;;
if (Object.hasOwn(value, "src") && (value as videoConfigType).src !== undefined) {
const currentValue = value as videoConfigType;
ref.current?.format("video", currentValue);
} else {
const currentValue = value as videoEmbedConfigType;
const res = ref.current?.clipboard.convert({
html: currentValue.embedcode
});
if (res) {
ref.current?.updateContents(res);
}
}

setShowDialog(false);
},
onClose: () => setShowDialog(false)
}
});
setShowDialog(true);
} else {
ref.current?.format("link", false);
setShowDialog(false);
}
};

return {
showDialog,
setShowDialog,
dialogConfig,
customLinkHandler,
customVideoHandler
};
}
52 changes: 48 additions & 4 deletions packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import Quill, { QuillOptions, EmitterSource, Range } from "quill";
import CustomListItem from "../utils/formats/customList";
import { createElement, MutableRefObject, forwardRef, useEffect, useRef, useLayoutEffect, CSSProperties } from "react";
import CustomLink from "../utils/formats/link";
import CustomVideo from "../utils/formats/video";

import {
createElement,
MutableRefObject,
forwardRef,
useEffect,
// useState,
useRef,
useLayoutEffect,
CSSProperties,
Fragment
} from "react";
import Delta from "quill-delta";
import "../utils/formats/fonts";
import Dialog from "./ModalDialog/Dialog";
// import {type LinkFormType} from "./ModalDialog/LinkDialog";
import { useEmbedModal } from "./CustomToolbars/useEmbedModal";

export interface EditorProps {
defaultValue?: string;
Expand All @@ -16,22 +32,27 @@ export interface EditorProps {
}

Quill.register(CustomListItem, true);
Quill.register(CustomLink, true);
Quill.register(CustomVideo, true);

// Editor is an uncontrolled React component
const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | null>) => {
const { theme, defaultValue, style, className, toolbarId, onTextChange, onSelectionChange, readOnly } = props;
const containerRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);

const { showDialog, setShowDialog, dialogConfig, customLinkHandler, customVideoHandler } = useEmbedModal(ref);
const onTextChangeRef = useRef(onTextChange);
const onSelectionChangeRef = useRef(onSelectionChange);

// quill instance is not changing, thus, the function reference has to stays.
useLayoutEffect(() => {
onTextChangeRef.current = onTextChange;
onSelectionChangeRef.current = onSelectionChange;
}, [onTextChange, onSelectionChange]);

// update quills content on value change.
useEffect(() => {
// update quills content on value change.
const newContent = ref.current?.clipboard.convert({
html: defaultValue,
text: "\n"
Expand All @@ -40,17 +61,29 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
ref.current?.setContents(newContent);
}
}, [ref, defaultValue]);

// use effect for constructing Quill instance
useEffect(
() => {
const container = containerRef.current;
if (container) {
const editorDiv = container.ownerDocument.createElement<"div">("div");
editorDiv.innerHTML = defaultValue ?? "";
const editorContainer = container.appendChild(editorDiv);

// Quill instance configurations.
const options: QuillOptions = {
theme,
modules: {
toolbar: toolbarId && Array.isArray(toolbarId) ? toolbarId : toolbarId ? `#${toolbarId}` : false
toolbar: toolbarId
? {
container: Array.isArray(toolbarId) ? toolbarId : `#${toolbarId}`,
handlers: {
link: customLinkHandler,
video: customVideoHandler
}
}
: false
},
readOnly
};
Expand All @@ -75,7 +108,18 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul
[ref, toolbarId]
);

return <div ref={containerRef} style={style} className={className}></div>;
return (
<Fragment>
<div ref={containerRef} style={style} className={className}></div>
<div ref={modalRef}></div>
<Dialog
isOpen={showDialog}
onClose={() => setShowDialog(false)}
parentNode={modalRef.current?.ownerDocument.body}
{...dialogConfig}
></Dialog>
</Fragment>
);
});

Editor.displayName = "Editor";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function EditorWrapper(props: EditorWrapperProps): ReactElement {
const quillRef = useRef<Quill>(null);
const [isFocus, setIsFocus] = useState(false);
const editorValueRef = useRef<string>("");
const toolbarRef = useRef<HTMLDivElement>(null);

const onTextChange = useCallback(() => {
if (onChange?.canExecute && onChangeType === "onDataChange") {
Expand Down Expand Up @@ -91,14 +92,23 @@ export default function EditorWrapper(props: EditorWrapperProps): ReactElement {
}}
onClick={e => {
// click on other parts of editor, such as the toolbar, should also set focus
if (!quillRef.current?.container.contains(e.target as Node)) {
if (
toolbarRef.current === (e.target as HTMLDivElement) ||
toolbarRef.current?.contains(e.target as Node)
) {
quillRef?.current?.focus();
}
}}
spellCheck={props.spellCheck}
>
<If condition={!shouldHideToolbar && toolbarOptions === undefined}>
<Toolbar id={toolbarId} preset={preset} quill={quillRef.current} toolbarContent={toolbarPreset} />
<Toolbar
ref={toolbarRef}
id={toolbarId}
preset={preset}
quill={quillRef.current}
toolbarContent={toolbarPreset}
/>
</If>
<Editor
theme={"snow"}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
.widget-rich-text-modal {
position: relative;
inset: 40px;
background: rgb(255, 255, 255);
overflow: auto;
border-radius: 4px;
outline: none;
padding: 0;
box-shadow: 0px 0px 0px 1px rgba(136, 143, 170, 0.1), 0px 30px 70px 0px rgba(26, 34, 64, 0.15),
0px 10px 30px 0px rgba(0, 0, 0, 0.2);

&-overlay {
position: fixed;
inset: 0px;
background-color: var(--shadow-color);
justify-content: center;
align-items: center;
display: flex;
}

&-body {
display: flex;
flex-direction: column;
height: 100%;

& > * {
padding: 0 20px;
}
}

&-header {
padding-top: 15px;
padding-bottom: 14px;
font-size: var(--font-size-large);
}

&-content {
padding-top: 20px;
padding-bottom: 20px;
flex: 1;
}

&-footer {
padding-top: 12px;
padding-bottom: 14px;
display: flex;
justify-content: end;

button.btn:not(:last-child) {
margin-right: 20px;
}
}

&-form {
&:not(:first-child) {
margin-top: 20px;
}
}

&-input {
display: flex;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { If } from "@mendix/widget-plugin-component-kit/If";
import { createElement, ReactElement } from "react";
import Modal from "react-modal";
import "./Dialog.scss";
import LinkDialog, { LinkDialogProps } from "./LinkDialog";
import VideoDialog, { VideoDialogProps } from "./VideoDialog";

interface BaseDialogProps {
isOpen: boolean;
parentNode?: HTMLElement | null;
onClose?(event: React.MouseEvent | React.KeyboardEvent): void;
}

export type LinkDialogBaseProps = {
dialogType?: "link";
config?: LinkDialogProps;
};

export type VideoDialogBaseProps = {
dialogType?: "video";
config?: VideoDialogProps;
};

export type ChildDialogProps = LinkDialogBaseProps | VideoDialogBaseProps;

export type DialogProps = BaseDialogProps & ChildDialogProps;

export default function Dialog(props: DialogProps): ReactElement {
const { isOpen, onClose, dialogType, config } = props;
return (
<Modal
isOpen={isOpen}
// parentSelector={() => parentNode ?? document.body}
overlayClassName={"widget-rich-text-modal-overlay"}
className={"widget-rich-text-modal modal-dialog"}
// style={{ overlay: { position: "absolute" }, content: { width: "fit-content", padding: 0 } }}
onRequestClose={onClose}
appElement={document.body}
portalClassName={`widget-rich-text-modal-container`}
>
<If condition={dialogType === "link"}>
<LinkDialog {...(config as LinkDialogProps)}></LinkDialog>
</If>
<If condition={dialogType === "video"}>
<VideoDialog {...(config as VideoDialogProps)}></VideoDialog>
</If>
</Modal>
);
}
Loading

0 comments on commit b91897a

Please sign in to comment.