From 599dde9cafef697fc3d0b9e9192de1e1c32b5bb8 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 4 Feb 2025 14:23:45 +0100 Subject: [PATCH 01/28] Press enter to apply href & link text (only if href is valid) --- src/zui/ZUIEditor/LinkExtensionUI.tsx | 11 +++++++++-- src/zui/ZUIEditor/elements/TextAndHrefOverlay.tsx | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/zui/ZUIEditor/LinkExtensionUI.tsx b/src/zui/ZUIEditor/LinkExtensionUI.tsx index c29f296ae..e3fe6ca50 100644 --- a/src/zui/ZUIEditor/LinkExtensionUI.tsx +++ b/src/zui/ZUIEditor/LinkExtensionUI.tsx @@ -14,8 +14,13 @@ export type NodeWithPosition = { const LinkExtensionUI: FC = () => { const state = useEditorState(); const view = useEditorView(); - const { removeLink, removeUnfinishedLinks, updateLink, updateLinkText } = - useCommands(); + const { + focus, + removeLink, + removeUnfinishedLinks, + updateLink, + updateLinkText, + } = useCommands(); const [selectedNodes, setSelectedNodes] = useState([]); const [selectionHasOtherNodes, setSelectionHasOtherNodes] = useState(false); @@ -95,6 +100,7 @@ const LinkExtensionUI: FC = () => { from: selectedNodes[0].from, to: selectedNodes[0].to, }); + focus(); } : undefined } @@ -107,6 +113,7 @@ const LinkExtensionUI: FC = () => { }, linkText ); + focus(); }} open={showLinkMaker} text={linkText} diff --git a/src/zui/ZUIEditor/elements/TextAndHrefOverlay.tsx b/src/zui/ZUIEditor/elements/TextAndHrefOverlay.tsx index 8996eb814..4ad5a66a6 100644 --- a/src/zui/ZUIEditor/elements/TextAndHrefOverlay.tsx +++ b/src/zui/ZUIEditor/elements/TextAndHrefOverlay.tsx @@ -73,6 +73,11 @@ const TextAndHrefOverlay: FC = ({ onChangeHref(ev.target.value)} + onKeyUp={(ev) => { + if (ev.key == 'Enter' && canSubmit) { + onSubmit(); + } + }} size="small" value={href} /> @@ -88,6 +93,11 @@ const TextAndHrefOverlay: FC = ({ onChangeText(ev.target.value)} + onKeyUp={(ev) => { + if (ev.key == 'Enter' && canSubmit) { + onSubmit(); + } + }} placeholder={messages.editor.extensions.link.textPlaceholder()} size="small" value={text} From fb406c89afcd4906ffb40f933807b3c12c619756 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 5 Feb 2025 13:33:37 +0100 Subject: [PATCH 02/28] Padding and size for editor, borders for focused block. --- src/zui/ZUIEditor/EditorOverlays/index.tsx | 8 +++++--- src/zui/ZUIEditor/index.tsx | 6 +++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/zui/ZUIEditor/EditorOverlays/index.tsx b/src/zui/ZUIEditor/EditorOverlays/index.tsx index 0cbcde22d..ed3e22efd 100644 --- a/src/zui/ZUIEditor/EditorOverlays/index.tsx +++ b/src/zui/ZUIEditor/EditorOverlays/index.tsx @@ -6,7 +6,7 @@ import { } from '@remirror/react'; import { FC, useCallback, useEffect, useState } from 'react'; import { ProsemirrorNode } from '@remirror/pm/suggest'; -import { Box } from '@mui/material'; +import { Box, useTheme } from '@mui/material'; import { FromToProps, isNodeSelection } from 'remirror'; import { Attrs } from '@remirror/pm/model'; @@ -205,22 +205,24 @@ const EditorOverlays: FC = ({ editable && blocks.length > 0 && !showBlockMenu && !typing; const showSelectedBlockOutline = editable && !!currentBlock; + const theme = useTheme(); return ( <> {showSelectedBlockOutline && ( )} diff --git a/src/zui/ZUIEditor/index.tsx b/src/zui/ZUIEditor/index.tsx index 717245fe4..6d7e7c9b3 100644 --- a/src/zui/ZUIEditor/index.tsx +++ b/src/zui/ZUIEditor/index.tsx @@ -166,6 +166,9 @@ const ZUIEditor: FC = ({ return ( = ({ px: 1, }, ['[contenteditable="true"]']: { + outline: '0px solid transparent', padding: 1, }, ['[contenteditable="true"] > *']: { @@ -199,7 +203,7 @@ const ZUIEditor: FC = ({ }, }} > -
+
({ From 744e7e17aa7b81693e11cd608ea8020cad82208b Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 5 Feb 2025 15:21:02 +0100 Subject: [PATCH 03/28] Handle when the editor is focused and not. --- src/zui/ZUIEditor/EditorOverlays/index.tsx | 7 +++-- src/zui/ZUIEditor/index.tsx | 32 ++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/zui/ZUIEditor/EditorOverlays/index.tsx b/src/zui/ZUIEditor/EditorOverlays/index.tsx index ed3e22efd..707a679ed 100644 --- a/src/zui/ZUIEditor/EditorOverlays/index.tsx +++ b/src/zui/ZUIEditor/EditorOverlays/index.tsx @@ -46,6 +46,7 @@ type Props = { enableItalic: boolean; enableLink: boolean; enableVariable: boolean; + focused: boolean; }; const EditorOverlays: FC = ({ @@ -55,7 +56,9 @@ const EditorOverlays: FC = ({ enableItalic, enableLink, enableVariable, + focused, }) => { + const theme = useTheme(); const view = useEditorView(); const state = useEditorState(); const positioner = usePositioner('cursor'); @@ -195,6 +198,7 @@ const EditorOverlays: FC = ({ currentBlock?.type == 'paragraph' && currentBlock?.node.textContent == ''; const showBlockToolbar = + focused && editable && !showBlockMenu && !!currentBlock && @@ -204,8 +208,7 @@ const EditorOverlays: FC = ({ const showBlockInsert = editable && blocks.length > 0 && !showBlockMenu && !typing; - const showSelectedBlockOutline = editable && !!currentBlock; - const theme = useTheme(); + const showSelectedBlockOutline = focused && editable && !!currentBlock; return ( <> diff --git a/src/zui/ZUIEditor/index.tsx b/src/zui/ZUIEditor/index.tsx index 6d7e7c9b3..347ec53ec 100644 --- a/src/zui/ZUIEditor/index.tsx +++ b/src/zui/ZUIEditor/index.tsx @@ -4,7 +4,7 @@ import { Remirror, useRemirror, } from '@remirror/react'; -import { FC, useMemo } from 'react'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { BoldExtension, BulletListExtension, @@ -64,6 +64,30 @@ const ZUIEditor: FC = ({ }) => { const messages = useMessages(messageIds.editor); const theme = useTheme(); + const editorContainerRef = useRef(null); + + const [focused, setFocused] = useState(false); + + useEffect(() => { + const editorContainer = editorContainerRef.current; + + if (editorContainer) { + const detectClickOnEditor = (ev: Event) => { + const clickedInside = ev.composedPath().includes(editorContainer); + if (clickedInside) { + setFocused(true); + } else { + setFocused(false); + } + }; + + document.addEventListener('click', detectClickOnEditor); + + return () => { + document.removeEventListener('click', detectClickOnEditor); + }; + } + }, [document]); const boldExtension = new BoldExtension({}); const btnExtension = new ButtonExtension(); @@ -203,7 +227,10 @@ const ZUIEditor: FC = ({ }, }} > -
+
({ @@ -215,6 +242,7 @@ const ZUIEditor: FC = ({ enableItalic={!!enableItalic} enableLink={!!enableLink} enableVariable={!!enableVariable} + focused={focused} /> {enableBlockMenu && } {enableBlockMenu && enableImage && } From ccdd9552c4785932ce6229d7c461e570af51000d Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 6 Feb 2025 12:30:30 +0100 Subject: [PATCH 04/28] Add alt and fileId attributes to ImageExtension. --- src/zui/ZUIEditor/extensions/ImageExtension.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/zui/ZUIEditor/extensions/ImageExtension.ts b/src/zui/ZUIEditor/extensions/ImageExtension.ts index db442212f..d9b06acdc 100644 --- a/src/zui/ZUIEditor/extensions/ImageExtension.ts +++ b/src/zui/ZUIEditor/extensions/ImageExtension.ts @@ -55,6 +55,8 @@ export default class ImageExtension extends NodeExtension { ...override, attrs: { ...extra.defaults(), + alt: { default: null }, + fileId: { default: null }, src: { default: null }, }, parseDOM: [ @@ -102,9 +104,13 @@ export default class ImageExtension extends NodeExtension { @command() setImageFile(file: ZetkinFile | null, pos: number): CommandFunction { return (props) => { - props.dispatch?.( - props.tr.setNodeAttribute(pos, 'src', file?.url ?? null) - ); + const { dispatch, tr } = props; + tr.setNodeAttribute(pos, 'src', file?.url ?? null); + tr.setNodeAttribute(pos, 'alt', file?.original_name ?? null); + tr.setNodeAttribute(pos, 'fileId', file?.id ?? null); + + dispatch?.(tr); + return true; }; } From 39b3b5b522e69774049ced2628fbc7db4a944821 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 7 Feb 2025 18:18:51 +0100 Subject: [PATCH 05/28] Create tested function to transform content of header and paragraph blocks into our InlineNode format. --- src/zui/ZUIEditor/types.ts | 26 + src/zui/ZUIEditor/utils/inlineVariables.ts | 7 + .../utils/remirrorToInlineNodes.spec.ts | 485 ++++++++++++++++++ .../ZUIEditor/utils/remirrorToInlineNodes.ts | 80 +++ 4 files changed, 598 insertions(+) create mode 100644 src/zui/ZUIEditor/types.ts create mode 100644 src/zui/ZUIEditor/utils/inlineVariables.ts create mode 100644 src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts create mode 100644 src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts diff --git a/src/zui/ZUIEditor/types.ts b/src/zui/ZUIEditor/types.ts new file mode 100644 index 000000000..4af0c46eb --- /dev/null +++ b/src/zui/ZUIEditor/types.ts @@ -0,0 +1,26 @@ +import { + BoldNode, + ItalicNode, + LinkNode, + StringNode, +} from 'features/emails/types'; + +export enum TextBlockContentType { + HARD_BREAK = 'hardBreak', + TEXT = 'text', + VARIABLE = 'zvariable', +} + +export enum MarkType { + BOLD = 'bold', + ITALIC = 'italic', + LINK = 'zlink', +} + +export type MarkNode = StringNode | BoldNode | ItalicNode | LinkNode; + +export enum EmailVariable { + FIRST_NAME = 'target.first_name', + FULL_NAME = 'target.full_name', + LAST_NAME = 'target.last_name', +} diff --git a/src/zui/ZUIEditor/utils/inlineVariables.ts b/src/zui/ZUIEditor/utils/inlineVariables.ts new file mode 100644 index 000000000..5ee41b96c --- /dev/null +++ b/src/zui/ZUIEditor/utils/inlineVariables.ts @@ -0,0 +1,7 @@ +import { EmailVariable } from '../types'; + +export const inlineVariables: Record = { + first_name: EmailVariable.FIRST_NAME, + full_name: EmailVariable.FULL_NAME, + last_name: EmailVariable.LAST_NAME, +}; diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts new file mode 100644 index 000000000..f59491411 --- /dev/null +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts @@ -0,0 +1,485 @@ +import { + BoldNode, + InlineNodeKind, + ItalicNode, + LinkNode, + StringNode, + VariableNode, +} from 'features/emails/types'; +import remirrorToInlineNodes from './remirrorToInlineNodes'; +import { EmailVariable, TextBlockContentType, MarkType } from '../types'; + +describe('remirrorToInlineNodes()', () => { + it('returns an empty array when passed an empty array', () => { + const inlineNodes = remirrorToInlineNodes([]); + + expect(inlineNodes).toHaveLength(0); + }); + + it('converts content with only plain text', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email. It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes.length).toEqual(1); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is very short.', + }); + }); + + it('converts content with a hard break in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email.', + type: TextBlockContentType.TEXT, + }, + { + type: TextBlockContentType.HARD_BREAK, + }, + { + text: 'It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes.length).toEqual(3); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email.', + }); + expect(nodes[1]).toEqual({ + kind: InlineNodeKind.LINE_BREAK, + }); + expect(nodes[2]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'It is very short.', + }); + }); + + it('converts content with an italicized word in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.ITALIC }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes.length).toEqual(3); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }); + expect(nodes[1]).toEqual({ + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.ITALIC, + }); + expect(nodes[2]).toEqual({ + kind: InlineNodeKind.STRING, + value: ' short.', + }); + }); + + it('converts content with a bold word in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.BOLD }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes.length).toEqual(3); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }); + expect(nodes[1]).toEqual({ + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.BOLD, + }); + expect(nodes[2]).toEqual({ + kind: InlineNodeKind.STRING, + value: ' short.', + }); + }); + + it('converts content with a link in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { attrs: { href: 'http://www.zetkin.org' }, type: MarkType.LINK }, + ], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + + const linkNode = nodes[1] as LinkNode; + const stringNode = linkNode.content[0] as StringNode; + + expect(nodes.length).toEqual(3); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }); + + expect(linkNode.href).toEqual('http://www.zetkin.org'); + expect(linkNode.kind).toEqual(InlineNodeKind.LINK); + expect(linkNode.tag).toHaveLength(8); + expect(stringNode.value).toEqual('very'); + expect(stringNode.kind).toEqual(InlineNodeKind.STRING); + + expect(nodes[2]).toEqual({ + kind: InlineNodeKind.STRING, + value: ' short.', + }); + }); + + it('converts content with a bold and italicized word in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.BOLD }, { type: MarkType.ITALIC }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes.length).toEqual(3); + expect(nodes[0]).toEqual({ + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }); + expect(nodes[1]).toEqual({ + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }); + expect(nodes[2]).toEqual({ + kind: InlineNodeKind.STRING, + value: ' short.', + }); + }); + + it('makes unique tags on links', () => { + const nodes = remirrorToInlineNodes([ + { + marks: [ + { attrs: { href: 'http://www.clara.org' }, type: MarkType.LINK }, + ], + text: 'This is our whole email.', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { attrs: { href: 'http://www.zetkin.org' }, type: MarkType.LINK }, + ], + text: ' It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + + const linkNode1 = nodes[0] as LinkNode; + const linkNode2 = nodes[1] as LinkNode; + + expect(nodes).toHaveLength(2); + expect(linkNode1.tag).not.toEqual(linkNode2.tag); + }); + + it('converts content with a variable in it', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'This is our whole email, ', + type: TextBlockContentType.TEXT, + }, + { + attrs: { name: 'first_name' }, + type: 'zvariable', + }, + { + text: '. It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + + expect(nodes).toEqual([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email, ', + }, + { + kind: InlineNodeKind.VARIABLE, + name: 'target.first_name', + }, + { + kind: InlineNodeKind.STRING, + value: '. It is very short.', + }, + ]); + }); + + it('converts complex intersections of different marks and content types', () => { + const nodes = remirrorToInlineNodes([ + { + text: 'Th', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.ITALIC, + }, + ], + text: 'is i', + type: TextBlockContentType.TEXT, + }, + { + text: 's ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + ], + text: 'our who', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + { + type: MarkType.ITALIC, + }, + ], + text: 'le em', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + ], + text: 'ai', + type: TextBlockContentType.TEXT, + }, + { + text: 'l, ', + type: TextBlockContentType.TEXT, + }, + { + attrs: { + name: 'full_name', + }, + type: 'zvariable', + }, + { + text: '. It ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + attrs: { + href: 'http://clara.org/', + }, + type: MarkType.LINK, + }, + ], + text: 'is ', + type: TextBlockContentType.TEXT, + }, + { + text: 've', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + { + type: MarkType.ITALIC, + }, + ], + text: 'ry ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + { + type: MarkType.ITALIC, + }, + { + attrs: { + href: 'http://zetkin.org/', + }, + type: MarkType.LINK, + }, + ], + text: 'short', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + { + type: MarkType.ITALIC, + }, + ], + text: '.', + type: TextBlockContentType.TEXT, + }, + ]); + + const node1 = nodes[0] as StringNode; + const node2 = nodes[1] as ItalicNode; + const node2a = node2.content[0] as StringNode; + const node3 = nodes[2] as StringNode; + const node4 = nodes[3] as BoldNode; + const node4a = node4.content[0] as StringNode; + const node5 = nodes[4] as BoldNode; + const node5a = node5.content[0] as ItalicNode; + const node5b = node5a.content[0] as StringNode; + const node6 = nodes[5] as BoldNode; + const node6a = node6.content[0] as StringNode; + const node7 = nodes[6] as StringNode; + const node8 = nodes[7] as VariableNode; + const node9 = nodes[8] as StringNode; + const node10 = nodes[9] as LinkNode; + const node10a = node10.content[0] as StringNode; + const node11 = nodes[10] as StringNode; + const node12 = nodes[11] as ItalicNode; + const node12a = node12.content[0] as BoldNode; + const node12b = node12a.content[0] as StringNode; + const node13 = nodes[12] as LinkNode; + const node13a = node13.content[0] as ItalicNode; + const node13b = node13a.content[0] as BoldNode; + const node13c = node13b.content[0] as StringNode; + const node14 = nodes[13] as ItalicNode; + const node14a = node14.content[0] as BoldNode; + const node14b = node14a.content[0] as StringNode; + + expect(node1).toEqual({ kind: 'string', value: 'Th' }); + + expect(node2.kind).toEqual(InlineNodeKind.ITALIC); + expect(node2a.value).toEqual('is i'); + expect(node2a.kind).toEqual(InlineNodeKind.STRING); + + expect(node3.kind).toEqual(InlineNodeKind.STRING); + expect(node3.value).toEqual('s '); + + expect(node4.kind).toEqual(InlineNodeKind.BOLD); + expect(node4a.kind).toEqual(InlineNodeKind.STRING); + expect(node4a.value).toEqual('our who'); + + expect(node5.kind).toEqual(InlineNodeKind.ITALIC); + expect(node5a.kind).toEqual(InlineNodeKind.BOLD); + expect(node5b.kind).toEqual(InlineNodeKind.STRING); + expect(node5b.value).toEqual('le em'); + + expect(node6.kind).toEqual(InlineNodeKind.BOLD); + expect(node6a.kind).toEqual(InlineNodeKind.STRING); + expect(node6a.value).toEqual('ai'); + + expect(node7.kind).toEqual(InlineNodeKind.STRING); + expect(node7.value).toEqual('l, '); + + expect(node8.kind).toEqual(InlineNodeKind.VARIABLE); + expect(node8.name).toEqual(EmailVariable.FULL_NAME); + + expect(node9.kind).toEqual(InlineNodeKind.STRING); + expect(node9.value).toEqual('. It '); + + expect(node10.kind).toEqual(InlineNodeKind.LINK); + expect(node10.href).toEqual('http://clara.org/'); + expect(node10.tag).toHaveLength(8); + expect(node10a.kind).toEqual(InlineNodeKind.STRING); + expect(node10a.value).toEqual('is '); + + expect(node11.kind).toEqual(InlineNodeKind.STRING); + expect(node11.value).toEqual('ve'); + + expect(node12.kind).toEqual(InlineNodeKind.ITALIC); + expect(node12a.kind).toEqual(InlineNodeKind.BOLD); + expect(node12b.kind).toEqual(InlineNodeKind.STRING); + expect(node12b.value).toEqual('ry '); + + expect(node13.kind).toEqual(InlineNodeKind.LINK); + expect(node13.href).toEqual('http://zetkin.org/'); + expect(node13.tag).toHaveLength(8); + expect(node13a.kind).toEqual(InlineNodeKind.ITALIC); + expect(node13b.kind).toEqual(InlineNodeKind.BOLD); + expect(node13c.kind).toEqual(InlineNodeKind.STRING); + expect(node13c.value).toEqual('short'); + + expect(node14.kind).toEqual(InlineNodeKind.ITALIC); + expect(node14a.kind).toEqual(InlineNodeKind.BOLD); + expect(node14b.kind).toEqual(InlineNodeKind.STRING); + expect(node14b.value).toEqual('.'); + }); +}); diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts new file mode 100644 index 000000000..3b2fc461d --- /dev/null +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts @@ -0,0 +1,80 @@ +import { ObjectMark, RemirrorJSON } from 'remirror'; +import crypto from 'crypto'; + +import { + BoldNode, + EmailContentInlineNode, + InlineNodeKind, + ItalicNode, + LinkNode, +} from 'features/emails/types'; +import { MarkNode, MarkType, TextBlockContentType } from '../types'; +import { inlineVariables } from './inlineVariables'; + +const isObjectMark = (mark: string | ObjectMark): mark is ObjectMark => { + return typeof mark != 'string'; +}; + +export default function remirrorToInlineNodes(blockContent: RemirrorJSON[]) { + const inlineNodes: EmailContentInlineNode[] = []; + + blockContent.forEach((block) => { + if (block.type == TextBlockContentType.TEXT) { + const text = block.text; + const marks = block.marks; + + if (text) { + if (marks) { + let inlineNode: MarkNode = { + kind: InlineNodeKind.STRING, + value: text, + }; + + marks.forEach((mark) => { + if (isObjectMark(mark)) { + if (mark.type == MarkType.LINK && mark.attrs) { + const newLinkNode: LinkNode = { + content: [inlineNode], + href: mark.attrs.href?.toString() || '', + kind: InlineNodeKind.LINK, + tag: crypto.randomUUID().slice(0, 8), + }; + inlineNode = newLinkNode; + } else if (mark.type == MarkType.BOLD) { + const newBoldNode: BoldNode = { + content: [inlineNode], + kind: InlineNodeKind.BOLD, + }; + inlineNode = newBoldNode; + } else if (mark.type == MarkType.ITALIC) { + const newItalicNode: ItalicNode = { + content: [inlineNode], + kind: InlineNodeKind.ITALIC, + }; + inlineNode = newItalicNode; + } + } + }); + + inlineNodes.push(inlineNode); + } else { + inlineNodes.push({ + kind: InlineNodeKind.STRING, + value: text, + }); + } + } + } else if (block.type == TextBlockContentType.HARD_BREAK) { + inlineNodes.push({ kind: InlineNodeKind.LINE_BREAK }); + } else if (block.type == TextBlockContentType.VARIABLE) { + const name = block.attrs?.name?.toString(); + if (name) { + inlineNodes.push({ + kind: InlineNodeKind.VARIABLE, + name: inlineVariables[name], + }); + } + } + }); + return inlineNodes; +} From 85027c70e52636ddbb7bf4886ee782c2957caaf5 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Sat, 8 Feb 2025 21:34:40 +0100 Subject: [PATCH 06/28] Create tested function to turn remirror editor content into our zetkin email content format. --- src/zui/ZUIEditor/types.ts | 7 + .../ZUIEditor/utils/remirrorToZetkin.spec.ts | 179 ++++++++++++++++++ src/zui/ZUIEditor/utils/remirrorToZetkin.ts | 85 +++++++++ 3 files changed, 271 insertions(+) create mode 100644 src/zui/ZUIEditor/utils/remirrorToZetkin.spec.ts create mode 100644 src/zui/ZUIEditor/utils/remirrorToZetkin.ts diff --git a/src/zui/ZUIEditor/types.ts b/src/zui/ZUIEditor/types.ts index 4af0c46eb..14a357d31 100644 --- a/src/zui/ZUIEditor/types.ts +++ b/src/zui/ZUIEditor/types.ts @@ -5,6 +5,13 @@ import { StringNode, } from 'features/emails/types'; +export enum RemirrorBlockType { + BUTTON = 'zbutton', + HEADING = 'heading', + IMAGE = 'zimage', + PARAGRAPH = 'paragraph', +} + export enum TextBlockContentType { HARD_BREAK = 'hardBreak', TEXT = 'text', diff --git a/src/zui/ZUIEditor/utils/remirrorToZetkin.spec.ts b/src/zui/ZUIEditor/utils/remirrorToZetkin.spec.ts new file mode 100644 index 000000000..9cb538608 --- /dev/null +++ b/src/zui/ZUIEditor/utils/remirrorToZetkin.spec.ts @@ -0,0 +1,179 @@ +import { BlockKind, ButtonBlock, InlineNodeKind } from 'features/emails/types'; +import remirrorToZetkin from './remirrorToZetkin'; +import { + RemirrorBlockType, + EmailVariable, + MarkType, + TextBlockContentType, +} from '../types'; + +describe('remirrorToZetkin', () => { + it('does nothing when passed an empty array', () => { + const zetkinBlocks = remirrorToZetkin([]); + + expect(zetkinBlocks).toHaveLength(0); + }); + + it('converts an image block', () => { + const zetkinBlocks = remirrorToZetkin([ + { + attrs: { + alt: 'clara.jpg', + fileId: 2, + src: 'http://files.zetkin.org/1/clara.jpg', + }, + type: RemirrorBlockType.IMAGE, + }, + ]); + + expect(zetkinBlocks).toEqual([ + { + data: { + alt: 'clara.jpg', + fileId: 2, + src: 'http://files.zetkin.org/1/clara.jpg', + }, + kind: BlockKind.IMAGE, + }, + ]); + }); + + it('converts a button block', () => { + const zetkinBlocks = remirrorToZetkin([ + { + attrs: { + href: 'http://www.zetkin.org', + }, + content: [ + { + text: 'Click me!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.BUTTON, + }, + ]); + + const block: ButtonBlock = zetkinBlocks[0] as ButtonBlock; + + expect(block.kind).toBe(BlockKind.BUTTON); + expect(block.data.href).toEqual('http://www.zetkin.org'); + expect(block.data.text).toEqual('Click me!'); + expect(block.data.tag).toHaveLength(8); + }); + + it('adds unique tag to button block', () => { + const zetkinBlocks = remirrorToZetkin([ + { + attrs: { + href: 'http://www.zetkin.org', + }, + content: [ + { + text: 'Click me!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.BUTTON, + }, + { + attrs: { + href: 'http://www.clara.org', + }, + content: [ + { + text: 'No, click me!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.BUTTON, + }, + ]); + + const block1 = zetkinBlocks[0] as ButtonBlock; + const block2 = zetkinBlocks[1] as ButtonBlock; + + expect(block1.data.tag).toHaveLength(8); + expect(block2.data.tag).toHaveLength(8); + expect(block1.data.tag).not.toEqual(block2.data.tag); + }); + + it('converts a header block', () => { + const zetkinBlocks = remirrorToZetkin([ + { + attrs: { + level: 1, + }, + content: [ + { + text: 'Hello!', + type: TextBlockContentType.TEXT, + }, + { + attrs: { name: 'first_name' }, + type: TextBlockContentType.VARIABLE, + }, + ], + type: RemirrorBlockType.HEADING, + }, + ]); + + expect(zetkinBlocks).toEqual([ + { + data: { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Hello!', + }, + { kind: InlineNodeKind.VARIABLE, name: EmailVariable.FIRST_NAME }, + ], + level: 1, + }, + kind: BlockKind.HEADER, + }, + ]); + }); + + it('converts paragraph block', () => { + const zetkinBlocks = remirrorToZetkin([ + { + content: [ + { + text: 'Welcome to our cool ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.BOLD }], + text: 'party!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.PARAGRAPH, + }, + ]); + + expect(zetkinBlocks).toEqual([ + { + data: { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Welcome to our cool ', + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'party!', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + }, + kind: BlockKind.PARAGRAPH, + }, + ]); + }); +}); diff --git a/src/zui/ZUIEditor/utils/remirrorToZetkin.ts b/src/zui/ZUIEditor/utils/remirrorToZetkin.ts new file mode 100644 index 000000000..51107e329 --- /dev/null +++ b/src/zui/ZUIEditor/utils/remirrorToZetkin.ts @@ -0,0 +1,85 @@ +import crypto from 'crypto'; +import { RemirrorJSON } from 'remirror'; + +import { BlockKind, EmailContentBlock } from 'features/emails/types'; +import remirrorToInlineNodes from './remirrorToInlineNodes'; +import { RemirrorBlockType } from '../types'; + +export default function remirrorToZetkin( + remirrorBlocks: RemirrorJSON[] +): EmailContentBlock[] { + const zetkinBlocks: EmailContentBlock[] = []; + + remirrorBlocks.forEach((remirrorBlock) => { + if (remirrorBlock.type == RemirrorBlockType.IMAGE) { + const attributes = remirrorBlock.attrs; + + if (attributes) { + zetkinBlocks.push({ + data: { + alt: attributes.alt as string, + fileId: attributes.fileId as number, + src: attributes.src as string, + }, + kind: BlockKind.IMAGE, + }); + } + } else if (remirrorBlock.type == RemirrorBlockType.BUTTON) { + const blockContent = remirrorBlock.content; + const attributes = remirrorBlock.attrs; + + const hasData = + blockContent && + blockContent.length > 0 && + !!blockContent[0] && + !!attributes; + + if (hasData) { + const textContent = blockContent[0]; + const buttonText = textContent.text; + const href = attributes.href; + + if (!!buttonText && !!href) { + zetkinBlocks.push({ + data: { + href: href as string, + tag: crypto.randomUUID().slice(0, 8), + text: buttonText, + }, + kind: BlockKind.BUTTON, + }); + } + } + } else if (remirrorBlock.type == RemirrorBlockType.HEADING) { + const blockContent = remirrorBlock.content; + const attributes = remirrorBlock.attrs; + + const hasData = blockContent && blockContent.length > 0 && !!attributes; + + if (hasData) { + const inlineNodes = remirrorToInlineNodes(blockContent); + zetkinBlocks.push({ + data: { + content: inlineNodes, + level: attributes.level as 1 | 2 | 3 | 4, + }, + kind: BlockKind.HEADER, + }); + } + } else if (remirrorBlock.type == RemirrorBlockType.PARAGRAPH) { + const blockContent = remirrorBlock.content; + + if (blockContent && blockContent.length > 0) { + const inlineNodes = remirrorToInlineNodes(blockContent); + zetkinBlocks.push({ + data: { + content: inlineNodes, + }, + kind: BlockKind.PARAGRAPH, + }); + } + } + }); + + return zetkinBlocks; +} From c0e6a5b8a7e7c277d33634cd6941a6512231f356 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Sun, 9 Feb 2025 11:50:13 +0100 Subject: [PATCH 07/28] Update a few types. --- src/features/emails/types.ts | 4 +++- src/features/emails/utils/htmlToInlineNodes.ts | 3 ++- .../emails/utils/inlineNodesToHtml.spec.ts | 3 ++- src/zui/ZUIEditor/types.ts | 2 +- src/zui/ZUIEditor/utils/inlineVariables.ts | 7 ------- .../ZUIEditor/utils/remirrorToInlineNodes.spec.ts | 6 +++--- src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts | 7 ++++--- src/zui/ZUIEditor/utils/variables.ts | 14 ++++++++++++++ 8 files changed, 29 insertions(+), 17 deletions(-) delete mode 100644 src/zui/ZUIEditor/utils/inlineVariables.ts create mode 100644 src/zui/ZUIEditor/utils/variables.ts diff --git a/src/features/emails/types.ts b/src/features/emails/types.ts index ab3657ca3..0522932dd 100644 --- a/src/features/emails/types.ts +++ b/src/features/emails/types.ts @@ -1,5 +1,7 @@ import { MJMLJsonObject } from 'mjml-core'; +import { EmailVariable } from 'zui/ZUIEditor/types'; + export enum BLOCK_TYPES { BUTTON = 'button', HEADER = 'header', @@ -30,7 +32,7 @@ export type StringNode = { export type VariableNode = { kind: InlineNodeKind.VARIABLE; - name: string; + name: EmailVariable; }; export type LinkNode = { diff --git a/src/features/emails/utils/htmlToInlineNodes.ts b/src/features/emails/utils/htmlToInlineNodes.ts index b550ae706..675b68317 100644 --- a/src/features/emails/utils/htmlToInlineNodes.ts +++ b/src/features/emails/utils/htmlToInlineNodes.ts @@ -1,3 +1,4 @@ +import { EmailVariable } from 'zui/ZUIEditor/types'; import { EmailContentInlineNode, InlineNodeKind } from '../types'; function childNodesToInlineNodes(childNodes: ChildNode[]) { @@ -35,7 +36,7 @@ function childNodesToInlineNodes(childNodes: ChildNode[]) { const span = node as HTMLSpanElement; inlineNodes.push({ kind: InlineNodeKind.VARIABLE, - name: span.getAttribute('data-slug') || '', + name: (span.getAttribute('data-slug') as EmailVariable) || '', }); } }); diff --git a/src/features/emails/utils/inlineNodesToHtml.spec.ts b/src/features/emails/utils/inlineNodesToHtml.spec.ts index e9fa57497..6273b0483 100644 --- a/src/features/emails/utils/inlineNodesToHtml.spec.ts +++ b/src/features/emails/utils/inlineNodesToHtml.spec.ts @@ -1,3 +1,4 @@ +import { EmailVariable } from 'zui/ZUIEditor/types'; import { InlineNodeKind } from '../types'; import inlineNodesToHtml from './inlineNodesToHtml'; @@ -87,7 +88,7 @@ describe('inlineNodesToHtml()', () => { }, { kind: InlineNodeKind.VARIABLE, - name: 'target.first_name', + name: EmailVariable.FIRST_NAME, }, { kind: InlineNodeKind.STRING, diff --git a/src/zui/ZUIEditor/types.ts b/src/zui/ZUIEditor/types.ts index 14a357d31..88c073ff7 100644 --- a/src/zui/ZUIEditor/types.ts +++ b/src/zui/ZUIEditor/types.ts @@ -13,7 +13,7 @@ export enum RemirrorBlockType { } export enum TextBlockContentType { - HARD_BREAK = 'hardBreak', + LINE_BREAK = 'hardBreak', TEXT = 'text', VARIABLE = 'zvariable', } diff --git a/src/zui/ZUIEditor/utils/inlineVariables.ts b/src/zui/ZUIEditor/utils/inlineVariables.ts deleted file mode 100644 index 5ee41b96c..000000000 --- a/src/zui/ZUIEditor/utils/inlineVariables.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { EmailVariable } from '../types'; - -export const inlineVariables: Record = { - first_name: EmailVariable.FIRST_NAME, - full_name: EmailVariable.FULL_NAME, - last_name: EmailVariable.LAST_NAME, -}; diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts index f59491411..dd21b548d 100644 --- a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.spec.ts @@ -38,7 +38,7 @@ describe('remirrorToInlineNodes()', () => { type: TextBlockContentType.TEXT, }, { - type: TextBlockContentType.HARD_BREAK, + type: TextBlockContentType.LINE_BREAK, }, { text: 'It is very short.', @@ -249,7 +249,7 @@ describe('remirrorToInlineNodes()', () => { }, { attrs: { name: 'first_name' }, - type: 'zvariable', + type: TextBlockContentType.VARIABLE, }, { text: '. It is very short.', @@ -264,7 +264,7 @@ describe('remirrorToInlineNodes()', () => { }, { kind: InlineNodeKind.VARIABLE, - name: 'target.first_name', + name: EmailVariable.FIRST_NAME, }, { kind: InlineNodeKind.STRING, diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts index 3b2fc461d..804244504 100644 --- a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts @@ -9,7 +9,8 @@ import { LinkNode, } from 'features/emails/types'; import { MarkNode, MarkType, TextBlockContentType } from '../types'; -import { inlineVariables } from './inlineVariables'; +import { remirrorVarsToInlineVars } from './variables'; +import { VariableName } from '../extensions/VariableExtension'; const isObjectMark = (mark: string | ObjectMark): mark is ObjectMark => { return typeof mark != 'string'; @@ -64,14 +65,14 @@ export default function remirrorToInlineNodes(blockContent: RemirrorJSON[]) { }); } } - } else if (block.type == TextBlockContentType.HARD_BREAK) { + } else if (block.type == TextBlockContentType.LINE_BREAK) { inlineNodes.push({ kind: InlineNodeKind.LINE_BREAK }); } else if (block.type == TextBlockContentType.VARIABLE) { const name = block.attrs?.name?.toString(); if (name) { inlineNodes.push({ kind: InlineNodeKind.VARIABLE, - name: inlineVariables[name], + name: remirrorVarsToInlineVars[name as VariableName], }); } } diff --git a/src/zui/ZUIEditor/utils/variables.ts b/src/zui/ZUIEditor/utils/variables.ts new file mode 100644 index 000000000..b5b7cd906 --- /dev/null +++ b/src/zui/ZUIEditor/utils/variables.ts @@ -0,0 +1,14 @@ +import { VariableName } from '../extensions/VariableExtension'; +import { EmailVariable } from '../types'; + +export const remirrorVarsToInlineVars: Record = { + first_name: EmailVariable.FIRST_NAME, + full_name: EmailVariable.FULL_NAME, + last_name: EmailVariable.LAST_NAME, +}; + +export const inlineVarsToRemirrorVars: Record = { + [EmailVariable.FIRST_NAME]: 'first_name', + [EmailVariable.FULL_NAME]: 'full_name', + [EmailVariable.LAST_NAME]: 'last_name', +}; From ab1cf4ce04b67691a3e09f78aedabfb54cab85d7 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Sun, 9 Feb 2025 11:52:24 +0100 Subject: [PATCH 08/28] Create tested function to turn inline nodes into remirror format. --- .../utils/inlineNodesToRemirror.spec.ts | 475 ++++++++++++++++++ .../ZUIEditor/utils/inlineNodesToRemirror.ts | 82 +++ 2 files changed, 557 insertions(+) create mode 100644 src/zui/ZUIEditor/utils/inlineNodesToRemirror.spec.ts create mode 100644 src/zui/ZUIEditor/utils/inlineNodesToRemirror.ts diff --git a/src/zui/ZUIEditor/utils/inlineNodesToRemirror.spec.ts b/src/zui/ZUIEditor/utils/inlineNodesToRemirror.spec.ts new file mode 100644 index 000000000..26c0c3281 --- /dev/null +++ b/src/zui/ZUIEditor/utils/inlineNodesToRemirror.spec.ts @@ -0,0 +1,475 @@ +import { InlineNodeKind } from 'features/emails/types'; +import inlineNodesToRemirror from './inlineNodesToRemirror'; +import { EmailVariable, MarkType, TextBlockContentType } from '../types'; + +describe('inlineNodesToRemirror()', () => { + it('does nothing when passed an empty array', () => { + const remirrorTextContent = inlineNodesToRemirror([]); + + expect(remirrorTextContent).toHaveLength(0); + }); + + it('converts String nodes', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is very short.', + }, + ]); + + expect(remirrorTextContent).toHaveLength(1); + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email. It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts line break nodes', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email.', + }, + { + kind: InlineNodeKind.LINE_BREAK, + }, + { + kind: InlineNodeKind.STRING, + value: 'It is very short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email.', + type: TextBlockContentType.TEXT, + }, + { + type: TextBlockContentType.LINE_BREAK, + }, + { + text: 'It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts italics node', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.ITALIC, + }, + { + kind: InlineNodeKind.STRING, + value: ' short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.ITALIC }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts bold node', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.BOLD, + }, + { + kind: InlineNodeKind.STRING, + value: ' short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.BOLD }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts link nodes', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }, + { + content: [{ kind: InlineNodeKind.STRING, value: 'very' }], + href: 'http://www.zetkin.org', + kind: InlineNodeKind.LINK, + tag: 'abc123fh', + }, + { + kind: InlineNodeKind.STRING, + value: ' short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { attrs: { href: 'http://www.zetkin.org' }, type: MarkType.LINK }, + ], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts content with both bold and italic nodes', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is ', + }, + { + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'very', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }, + { + kind: InlineNodeKind.STRING, + value: ' short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email. It is ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.ITALIC }, { type: MarkType.BOLD }], + text: 'very', + type: TextBlockContentType.TEXT, + }, + { + text: ' short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts content with a variable in it', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email, ', + }, + { + kind: InlineNodeKind.VARIABLE, + name: EmailVariable.FIRST_NAME, + }, + { + kind: InlineNodeKind.STRING, + value: '. It is very short.', + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'This is our whole email, ', + type: TextBlockContentType.TEXT, + }, + { + attrs: { name: 'first_name' }, + type: TextBlockContentType.VARIABLE, + }, + { + text: '. It is very short.', + type: TextBlockContentType.TEXT, + }, + ]); + }); + + it('converts complex intersections of different marks and content types', () => { + const remirrorTextContent = inlineNodesToRemirror([ + { kind: InlineNodeKind.STRING, value: 'Th' }, + { + content: [{ kind: InlineNodeKind.STRING, value: 'is i' }], + kind: InlineNodeKind.ITALIC, + }, + { + kind: InlineNodeKind.STRING, + value: 's ', + }, + { + content: [{ kind: InlineNodeKind.STRING, value: 'our who' }], + kind: InlineNodeKind.BOLD, + }, + { + content: [ + { + content: [{ kind: InlineNodeKind.STRING, value: 'le em' }], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'ai', + }, + ], + kind: InlineNodeKind.BOLD, + }, + { + kind: InlineNodeKind.STRING, + value: 'l, ', + }, + { + kind: InlineNodeKind.VARIABLE, + name: EmailVariable.FULL_NAME, + }, + { + kind: InlineNodeKind.STRING, + value: '. It ', + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'is ', + }, + ], + href: 'http://clara.org/', + kind: InlineNodeKind.LINK, + tag: '123abc12', + }, + { + kind: InlineNodeKind.STRING, + value: 've', + }, + { + content: [ + { + content: [{ kind: InlineNodeKind.STRING, value: 'ry ' }], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }, + { + content: [ + { + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'short', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }, + ], + href: 'http://zetkin.org/', + kind: InlineNodeKind.LINK, + tag: '123efg45', + }, + { + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: '.', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + kind: InlineNodeKind.ITALIC, + }, + ]); + + expect(remirrorTextContent).toEqual([ + { + text: 'Th', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.ITALIC, + }, + ], + text: 'is i', + type: TextBlockContentType.TEXT, + }, + { + text: 's ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + ], + text: 'our who', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.ITALIC, + }, + { + type: MarkType.BOLD, + }, + ], + text: 'le em', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.BOLD, + }, + ], + text: 'ai', + type: TextBlockContentType.TEXT, + }, + { + text: 'l, ', + type: TextBlockContentType.TEXT, + }, + { + attrs: { + name: 'full_name', + }, + type: 'zvariable', + }, + { + text: '. It ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + attrs: { + href: 'http://clara.org/', + }, + type: MarkType.LINK, + }, + ], + text: 'is ', + type: TextBlockContentType.TEXT, + }, + { + text: 've', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.ITALIC, + }, + { + type: MarkType.BOLD, + }, + ], + text: 'ry ', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + attrs: { + href: 'http://zetkin.org/', + }, + type: MarkType.LINK, + }, + { + type: MarkType.ITALIC, + }, + { + type: MarkType.BOLD, + }, + ], + text: 'short', + type: TextBlockContentType.TEXT, + }, + { + marks: [ + { + type: MarkType.ITALIC, + }, + { + type: MarkType.BOLD, + }, + ], + text: '.', + type: TextBlockContentType.TEXT, + }, + ]); + }); +}); diff --git a/src/zui/ZUIEditor/utils/inlineNodesToRemirror.ts b/src/zui/ZUIEditor/utils/inlineNodesToRemirror.ts new file mode 100644 index 000000000..4d0ca3553 --- /dev/null +++ b/src/zui/ZUIEditor/utils/inlineNodesToRemirror.ts @@ -0,0 +1,82 @@ +import { ObjectMark, RemirrorJSON } from 'remirror'; + +import { + BoldNode, + EmailContentInlineNode, + InlineNodeKind, + ItalicNode, + LinkNode, +} from 'features/emails/types'; +import { MarkType, TextBlockContentType } from '../types'; +import { inlineVarsToRemirrorVars } from './variables'; + +type InlineNodeWithContent = ItalicNode | BoldNode | LinkNode; + +const markTypes: Record = { + bold: MarkType.BOLD, + italic: MarkType.ITALIC, + link: MarkType.LINK, +}; + +const isNodeWithContent = ( + node: EmailContentInlineNode +): node is InlineNodeWithContent => { + return 'content' in node; +}; + +export default function inlineNodesToRemirror( + inlineNodes: EmailContentInlineNode[] +) { + const content: RemirrorJSON[] = []; + inlineNodes.forEach((node) => { + { + if (isNodeWithContent(node)) { + //Is a node of type BOLD, ITALIC or LINK + const marks: ObjectMark[] = []; + let text = ''; + + const findMarks = (markNode: EmailContentInlineNode) => { + if (isNodeWithContent(markNode)) { + if (markNode.kind == InlineNodeKind.LINK) { + marks.push({ + attrs: { href: markNode.href }, + type: markTypes[markNode.kind], + }); + } else { + marks.push({ type: markTypes[markNode.kind] }); + } + + const childNode = markNode.content[0]; + if (isNodeWithContent(childNode)) { + findMarks(childNode); + } else if (childNode.kind == InlineNodeKind.STRING) { + text = childNode.value; + } + } + }; + + findMarks(node); + + content.push({ + marks: marks.length > 0 ? marks : undefined, + text: text, + type: TextBlockContentType.TEXT, + }); + } else if (node.kind == InlineNodeKind.STRING) { + content.push({ + text: node.value, + type: TextBlockContentType.TEXT, + }); + } else if (node.kind == InlineNodeKind.LINE_BREAK) { + content.push({ type: TextBlockContentType.LINE_BREAK }); + } else if (node.kind == InlineNodeKind.VARIABLE) { + content.push({ + attrs: { name: inlineVarsToRemirrorVars[node.name] }, + type: TextBlockContentType.VARIABLE, + }); + } + } + }); + + return content; +} From ce0ea57944401770ce3242cbfd49437e49c2ce8f Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Sun, 9 Feb 2025 12:19:44 +0100 Subject: [PATCH 09/28] Create tested function to turn zetkin block format into remirror block format. --- .../ZUIEditor/utils/zetkinToRemirror.spec.ts | 147 ++++++++++++++++++ src/zui/ZUIEditor/utils/zetkinToRemirror.ts | 52 +++++++ 2 files changed, 199 insertions(+) create mode 100644 src/zui/ZUIEditor/utils/zetkinToRemirror.spec.ts create mode 100644 src/zui/ZUIEditor/utils/zetkinToRemirror.ts diff --git a/src/zui/ZUIEditor/utils/zetkinToRemirror.spec.ts b/src/zui/ZUIEditor/utils/zetkinToRemirror.spec.ts new file mode 100644 index 000000000..d06b229a4 --- /dev/null +++ b/src/zui/ZUIEditor/utils/zetkinToRemirror.spec.ts @@ -0,0 +1,147 @@ +import { BlockKind, InlineNodeKind } from 'features/emails/types'; +import zetkinToRemirror from './zetkinToRemirror'; +import { + EmailVariable, + MarkType, + RemirrorBlockType, + TextBlockContentType, +} from '../types'; + +describe('zetkinToRemirror()', () => { + it('returns an empty array when it recieves an empty array', () => { + const remirrorBlocks = zetkinToRemirror([]); + + expect(remirrorBlocks).toHaveLength(0); + }); + + it('converts Button blocks', () => { + const remirrorBlocks = zetkinToRemirror([ + { + data: { + href: 'http://www.zetkin.org/', + tag: 'abcdefgh', + text: 'Click me!', + }, + kind: BlockKind.BUTTON, + }, + ]); + + expect(remirrorBlocks).toMatchObject([ + { + attrs: { + href: 'http://www.zetkin.org/', + }, + content: [ + { + text: 'Click me!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.BUTTON, + }, + ]); + }); + + it('converts Header blocks', () => { + const remirrorBlocks = zetkinToRemirror([ + { + data: { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Hello!', + }, + { kind: InlineNodeKind.VARIABLE, name: EmailVariable.FIRST_NAME }, + ], + level: 1, + }, + kind: BlockKind.HEADER, + }, + ]); + + expect(remirrorBlocks).toMatchObject([ + { + attrs: { + level: 1, + }, + content: [ + { + text: 'Hello!', + type: TextBlockContentType.TEXT, + }, + { + attrs: { name: 'first_name' }, + type: TextBlockContentType.VARIABLE, + }, + ], + type: RemirrorBlockType.HEADING, + }, + ]); + }); + + it('converts Image blocks', () => { + const remirrorBlocks = zetkinToRemirror([ + { + data: { + alt: 'clara.jpg', + fileId: 18, + src: 'http://files.zetkin.org/1/clara.jpg', + }, + kind: BlockKind.IMAGE, + }, + ]); + + expect(remirrorBlocks).toMatchObject([ + { + attrs: { + alt: 'clara.jpg', + fileId: 18, + src: 'http://files.zetkin.org/1/clara.jpg', + }, + type: RemirrorBlockType.IMAGE, + }, + ]); + }); + + it('converts Paragraph blocks', () => { + const remirrorBlocks = zetkinToRemirror([ + { + data: { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Welcome to our cool ', + }, + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'party!', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + }, + kind: BlockKind.PARAGRAPH, + }, + ]); + + expect(remirrorBlocks).toMatchObject([ + { + content: [ + { + text: 'Welcome to our cool ', + type: TextBlockContentType.TEXT, + }, + { + marks: [{ type: MarkType.BOLD }], + text: 'party!', + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.PARAGRAPH, + }, + ]); + }); +}); diff --git a/src/zui/ZUIEditor/utils/zetkinToRemirror.ts b/src/zui/ZUIEditor/utils/zetkinToRemirror.ts new file mode 100644 index 000000000..57a278f92 --- /dev/null +++ b/src/zui/ZUIEditor/utils/zetkinToRemirror.ts @@ -0,0 +1,52 @@ +import { RemirrorJSON } from 'remirror'; + +import { BlockKind, EmailContentBlock } from 'features/emails/types'; +import { RemirrorBlockType, TextBlockContentType } from '../types'; +import inlineNodesToRemirror from './inlineNodesToRemirror'; + +export default function zetkinToRemirror(zetkinBlocks: EmailContentBlock[]) { + const remirrorBlocks: RemirrorJSON[] = []; + + zetkinBlocks.forEach((block) => { + if (block.kind == BlockKind.BUTTON) { + remirrorBlocks.push({ + attrs: { + href: block.data.href, + }, + content: [ + { + text: block.data.text, + type: TextBlockContentType.TEXT, + }, + ], + type: RemirrorBlockType.BUTTON, + }); + } else if (block.kind == BlockKind.HEADER) { + const remirrorBlockContent = inlineNodesToRemirror(block.data.content); + + remirrorBlocks.push({ + attrs: { + level: block.data.level, + }, + content: remirrorBlockContent, + type: RemirrorBlockType.HEADING, + }); + } else if (block.kind == BlockKind.IMAGE) { + remirrorBlocks.push({ + attrs: { + alt: block.data.alt, + fileId: block.data.fileId, + src: block.data.src, + }, + type: RemirrorBlockType.IMAGE, + }); + } else if (block.kind == BlockKind.PARAGRAPH) { + const remirrorBlockContent = inlineNodesToRemirror(block.data.content); + remirrorBlocks.push({ + content: remirrorBlockContent, + type: RemirrorBlockType.PARAGRAPH, + }); + } + }); + return remirrorBlocks; +} From 17c4edd7acb56b23e05623a98d234e81d8c6457a Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Sun, 9 Feb 2025 13:41:29 +0100 Subject: [PATCH 10/28] Create util type guard function. --- src/zui/ZUIEditor/utils/isObjectMark.ts | 7 +++++++ src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts | 7 ++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 src/zui/ZUIEditor/utils/isObjectMark.ts diff --git a/src/zui/ZUIEditor/utils/isObjectMark.ts b/src/zui/ZUIEditor/utils/isObjectMark.ts new file mode 100644 index 000000000..5fa06891c --- /dev/null +++ b/src/zui/ZUIEditor/utils/isObjectMark.ts @@ -0,0 +1,7 @@ +import { ObjectMark } from 'remirror'; + +export default function isObjectMark( + mark: string | ObjectMark +): mark is ObjectMark { + return typeof mark != 'string'; +} diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts index 804244504..165e13da3 100644 --- a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts @@ -1,4 +1,4 @@ -import { ObjectMark, RemirrorJSON } from 'remirror'; +import { RemirrorJSON } from 'remirror'; import crypto from 'crypto'; import { @@ -11,10 +11,7 @@ import { import { MarkNode, MarkType, TextBlockContentType } from '../types'; import { remirrorVarsToInlineVars } from './variables'; import { VariableName } from '../extensions/VariableExtension'; - -const isObjectMark = (mark: string | ObjectMark): mark is ObjectMark => { - return typeof mark != 'string'; -}; +import isObjectMark from './isObjectMark'; export default function remirrorToInlineNodes(blockContent: RemirrorJSON[]) { const inlineNodes: EmailContentInlineNode[] = []; From e6045104e5b97b9efa1fc593c765eb824c3cc145 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 10 Feb 2025 11:32:27 +0100 Subject: [PATCH 11/28] Create tested function to determine block problems. --- .../utils/editorBlockProblems.spec.ts | 128 ++++++++++++++++++ .../ZUIEditor/utils/editorBlockProblems.ts | 52 +++++++ 2 files changed, 180 insertions(+) create mode 100644 src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts create mode 100644 src/zui/ZUIEditor/utils/editorBlockProblems.ts diff --git a/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts b/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts new file mode 100644 index 000000000..830c94db4 --- /dev/null +++ b/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts @@ -0,0 +1,128 @@ +import editorBlockProblems from './editorBlockProblems'; +import { + BlockKind, + BlockProblem, + ButtonBlock, + InlineNodeKind, +} from 'features/emails/types'; + +describe('editorBlockProblems()', () => { + describe('checks if a button block has errors', () => { + function mockButtonBlock( + overrides?: Partial + ): ButtonBlock { + return { + data: { + href: 'http://www.zetkin.org/', + tag: '1234abcd', + text: 'Click me!', + ...overrides, + }, + kind: BlockKind.BUTTON, + }; + } + + it('returns empty array when there is a correct url and a buttonText', () => { + const errors = editorBlockProblems(mockButtonBlock()); + + expect(errors.length).toEqual(0); + }); + + it('returns an array with a INVALID_BUTTON_URL error when there is an incorrect url', () => { + const errors = editorBlockProblems(mockButtonBlock({ href: 'clara' })); + + expect(errors.length).toEqual(1); + expect(errors[0]).toEqual(BlockProblem.INVALID_BUTTON_URL); + }); + + it('returns an array with an INVALID_BUTTON_URL error when url is missing', () => { + const errors = editorBlockProblems(mockButtonBlock({ href: undefined })); + + expect(errors.length).toEqual(1); + expect(errors[0]).toEqual(BlockProblem.INVALID_BUTTON_URL); + }); + + it('returns an array with a DEFAULT_BUTTON_TEXT error when the button text is missing', () => { + const errors = editorBlockProblems(mockButtonBlock({ text: '' })); + + expect(errors.length).toEqual(1); + expect(errors[0]).toEqual(BlockProblem.DEFAULT_BUTTON_TEXT); + }); + + it('returns an array with BUTTON_TEXT_MISSING error when button text exists but is only spaces or empty', () => { + const errors = editorBlockProblems(mockButtonBlock({ text: ' ' })); + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual(BlockProblem.BUTTON_TEXT_MISSING); + }); + }); + + describe('checks if a paragraph block has errors', () => { + it('returns an empty array if content does not contain any links', () => { + const errors = editorBlockProblems({ + data: { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'This is our whole email. It is very short.', + }, + ], + }, + kind: BlockKind.PARAGRAPH, + }); + + expect(errors.length).toEqual(0); + }); + + it('returns an empty array if text contains a link with a correct href', () => { + const errors = editorBlockProblems({ + data: { + content: [ + { + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Click here!', + }, + ], + href: 'http://www.zetkin.org', + kind: InlineNodeKind.LINK, + tag: 'abcdefgh', + }, + ], + kind: InlineNodeKind.BOLD, + }, + ], + }, + kind: BlockKind.PARAGRAPH, + }); + + expect(errors.length).toEqual(0); + }); + + it('returns an array with an INVALID_LINK_URL if the href does not have http://', () => { + const errors = editorBlockProblems({ + data: { + content: [ + { + content: [ + { + kind: InlineNodeKind.STRING, + value: 'Click here!', + }, + ], + href: 'zetkin.org', + kind: InlineNodeKind.LINK, + tag: 'abcdefgh', + }, + ], + }, + kind: BlockKind.PARAGRAPH, + }); + + expect(errors.length).toEqual(1); + expect(errors[0]).toEqual(BlockProblem.INVALID_LINK_URL); + }); + }); +}); diff --git a/src/zui/ZUIEditor/utils/editorBlockProblems.ts b/src/zui/ZUIEditor/utils/editorBlockProblems.ts new file mode 100644 index 000000000..51f1704d8 --- /dev/null +++ b/src/zui/ZUIEditor/utils/editorBlockProblems.ts @@ -0,0 +1,52 @@ +import isURL from 'validator/lib/isURL'; + +import { + BlockKind, + BlockProblem, + EmailContentBlock, + EmailContentInlineNode, + InlineNodeKind, + LinkNode, +} from 'features/emails/types'; + +export default function remirrorBlockProblems(block: EmailContentBlock) { + const blockProblems: BlockProblem[] = []; + + if (block.kind == BlockKind.BUTTON) { + if ( + !block.data.href || + !isURL(block.data.href, { require_protocol: true }) + ) { + blockProblems.push(BlockProblem.INVALID_BUTTON_URL); + } + + const buttonText = block.data.text; + if (!buttonText) { + blockProblems.push(BlockProblem.DEFAULT_BUTTON_TEXT); + } else if (!buttonText.replaceAll(' ', '').trim().length) { + blockProblems.push(BlockProblem.BUTTON_TEXT_MISSING); + } + } else if (block.kind == BlockKind.PARAGRAPH) { + const linksInBlock: LinkNode[] = []; + + const findLinkNodes = (node: EmailContentInlineNode) => { + if (node.kind == InlineNodeKind.LINK) { + linksInBlock.push(node); + } else if ('content' in node) { + node.content.forEach((node) => findLinkNodes(node)); + } + }; + + block.data.content.forEach((contentNode) => { + findLinkNodes(contentNode); + }); + + if ( + linksInBlock.some((link) => !isURL(link.href, { require_protocol: true })) + ) { + blockProblems.push(BlockProblem.INVALID_LINK_URL); + } + } + + return blockProblems; +} From aedba68a114bf00c0d57aece5398a717e0cee05d Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 10:25:34 +0100 Subject: [PATCH 12/28] Use math.random instead of crypto UUID. --- src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts | 3 +-- src/zui/ZUIEditor/utils/remirrorToZetkin.ts | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts index 165e13da3..16ff9aa87 100644 --- a/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts +++ b/src/zui/ZUIEditor/utils/remirrorToInlineNodes.ts @@ -1,5 +1,4 @@ import { RemirrorJSON } from 'remirror'; -import crypto from 'crypto'; import { BoldNode, @@ -35,7 +34,7 @@ export default function remirrorToInlineNodes(blockContent: RemirrorJSON[]) { content: [inlineNode], href: mark.attrs.href?.toString() || '', kind: InlineNodeKind.LINK, - tag: crypto.randomUUID().slice(0, 8), + tag: Math.random().toString(36).substring(2, 10), }; inlineNode = newLinkNode; } else if (mark.type == MarkType.BOLD) { diff --git a/src/zui/ZUIEditor/utils/remirrorToZetkin.ts b/src/zui/ZUIEditor/utils/remirrorToZetkin.ts index 51107e329..4b85e353f 100644 --- a/src/zui/ZUIEditor/utils/remirrorToZetkin.ts +++ b/src/zui/ZUIEditor/utils/remirrorToZetkin.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto'; import { RemirrorJSON } from 'remirror'; import { BlockKind, EmailContentBlock } from 'features/emails/types'; @@ -39,11 +38,11 @@ export default function remirrorToZetkin( const buttonText = textContent.text; const href = attributes.href; - if (!!buttonText && !!href) { + if (buttonText) { zetkinBlocks.push({ data: { - href: href as string, - tag: crypto.randomUUID().slice(0, 8), + href: href ? href.toString() : '', + tag: Math.random().toString(36).substring(2, 10), text: buttonText, }, kind: BlockKind.BUTTON, From 37904d04ff8ce91636bbbb67b407fa7b0e0fb1bc Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 11:00:14 +0100 Subject: [PATCH 13/28] Rename function. --- src/zui/ZUIEditor/utils/editorBlockProblems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zui/ZUIEditor/utils/editorBlockProblems.ts b/src/zui/ZUIEditor/utils/editorBlockProblems.ts index 51f1704d8..3c134b26e 100644 --- a/src/zui/ZUIEditor/utils/editorBlockProblems.ts +++ b/src/zui/ZUIEditor/utils/editorBlockProblems.ts @@ -9,7 +9,7 @@ import { LinkNode, } from 'features/emails/types'; -export default function remirrorBlockProblems(block: EmailContentBlock) { +export default function editorBlockProblems(block: EmailContentBlock) { const blockProblems: BlockProblem[] = []; if (block.kind == BlockKind.BUTTON) { From 2f4e37739f40d815d7e0452f755d4de566020969 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 11:04:11 +0100 Subject: [PATCH 14/28] Make EmailOutline component that displays one list item per editor block. --- .../EmailOutline/BlockListItem.tsx | 70 +++++++++++++++++++ .../EmailOutline/BlockListItemBase.tsx | 42 +++++++++++ .../EmailEditor/EmailOutline/index.tsx | 24 +++++++ src/features/emails/l10n/messageIds.ts | 7 ++ src/zui/ZUIEditor/index.tsx | 27 +++---- 5 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx create mode 100644 src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx create mode 100644 src/features/emails/components/EmailEditor/EmailOutline/index.tsx diff --git a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx new file mode 100644 index 000000000..973abfe25 --- /dev/null +++ b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx @@ -0,0 +1,70 @@ +import { Crop75, Image as ImageIcon, Notes, Title } from '@mui/icons-material'; +import { FC } from 'react'; + +import { + BlockKind, + EmailContentBlock, + EmailContentInlineNode, + InlineNodeKind, +} from 'features/emails/types'; +import BlockListItemBase from './BlockListItemBase'; +import editorBlockProblems from 'zui/ZUIEditor/utils/editorBlockProblems'; +import { useMessages } from 'core/i18n'; +import messageIds from 'features/emails/l10n/messageIds'; + +interface BlockListItemProps { + block: EmailContentBlock; +} + +const BlockListItem: FC = ({ block }) => { + const messages = useMessages(messageIds); + + const makeTitle = (nodes: EmailContentInlineNode[]): string => { + let text = ''; + nodes.forEach((node) => { + if (node.kind == InlineNodeKind.STRING) { + text += node.value; + } else if (node.kind == InlineNodeKind.VARIABLE) { + text += messages.editor.outline.variables[node.name](); + } else if ('content' in node) { + text += makeTitle(node.content); + } + }); + return text; + }; + + if (block.kind === BlockKind.PARAGRAPH) { + const title = makeTitle(block.data.content); + const hasErrors = editorBlockProblems(block); + return ( + 0} + icon={Notes} + title={title} + /> + ); + } else if (block.kind === BlockKind.HEADER) { + const title = makeTitle(block.data.content); + return ; + } else if (block.kind === BlockKind.BUTTON) { + const hasErrors = editorBlockProblems(block); + return ( + 0} + icon={Crop75} + title={block.data.text} + /> + ); + } else { + //Is image block + return ( + + ); + } +}; + +export default BlockListItem; diff --git a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx new file mode 100644 index 000000000..36ffaa704 --- /dev/null +++ b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx @@ -0,0 +1,42 @@ +import { Box, SvgIconTypeMap, Typography } from '@mui/material'; +import { ErrorOutlineOutlined } from '@mui/icons-material'; +import { FC } from 'react'; +import { OverridableComponent } from '@mui/material/OverridableComponent'; + +interface BlockListItemBaseProps { + hasErrors: boolean; + icon: OverridableComponent, 'svg'>>; + title: string; +} + +const BlockListItemBase: FC = ({ + hasErrors, + icon: Icon, + title, +}) => { + return ( + + + + + {title} + + + {hasErrors && ( + + )} + + ); +}; + +export default BlockListItemBase; diff --git a/src/features/emails/components/EmailEditor/EmailOutline/index.tsx b/src/features/emails/components/EmailEditor/EmailOutline/index.tsx new file mode 100644 index 000000000..ea69bc71f --- /dev/null +++ b/src/features/emails/components/EmailEditor/EmailOutline/index.tsx @@ -0,0 +1,24 @@ +import { FC, Fragment } from 'react'; +import { Box, Divider } from '@mui/material'; + +import { EmailContentBlock } from 'features/emails/types'; +import BlockListItem from './BlockListItem'; + +type EmailOutlineProps = { + blocks: EmailContentBlock[]; +}; + +const EmailOutline: FC = ({ blocks }) => { + return ( + + {blocks.map((block, index) => ( + + + + + ))} + + ); +}; + +export default EmailOutline; diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index f410c652a..e42cc9cfb 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -33,6 +33,13 @@ export default makeMessages('feat.emails', { willSend: m<{ datetime: ReactElement }>('Will send at {datetime}'), }, editor: { + outline: { + variables: { + ['target.first_name']: m('First name'), + ['target.full_name']: m('Full name'), + ['target.last_name']: m('Last name'), + }, + }, readOnlyModeInfo: m( 'This email is in read-only mode because it is scheduled for delivery, or has already been sent. If it is scheduled for delivery and you want to make changes, you need to cancel the delivery first.' ), diff --git a/src/zui/ZUIEditor/index.tsx b/src/zui/ZUIEditor/index.tsx index 347ec53ec..4c3e5ffaa 100644 --- a/src/zui/ZUIEditor/index.tsx +++ b/src/zui/ZUIEditor/index.tsx @@ -31,6 +31,9 @@ import LinkExtensionUI from './LinkExtensionUI'; import ButtonExtensionUI from './ButtonExtensionUI'; import MoveExtension from './extensions/MoveExtension'; import IndentDedentExtension from './extensions/IndentDedentExtension'; +import { EmailContentBlock } from 'features/emails/types'; +import zetkinToRemirror from './utils/zetkinToRemirror'; +import remirrorToZetkin from './utils/remirrorToZetkin'; type BlockExtension = | ButtonExtension @@ -40,6 +43,7 @@ type BlockExtension = | BulletListExtension; type Props = { + content: EmailContentBlock[]; editable: boolean; enableBold?: boolean; enableButton?: boolean; @@ -49,9 +53,11 @@ type Props = { enableLink?: boolean; enableLists?: boolean; enableVariable?: boolean; + onChange: (newContent: EmailContentBlock[]) => void; }; const ZUIEditor: FC = ({ + content, editable, enableBold, enableButton, @@ -61,6 +67,7 @@ const ZUIEditor: FC = ({ enableLink, enableLists, enableVariable, + onChange, }) => { const messages = useMessages(messageIds.editor); const theme = useTheme(); @@ -150,17 +157,7 @@ const ZUIEditor: FC = ({ const { manager, state } = useRemirror({ content: { - content: [ - { - content: [ - { - text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas dictum tempus leo sit amet ornare. Aliquam efficitur arcu id ex efficitur viverra. Morbi malesuada posuere faucibus. Donec tempus ornare interdum. Aliquam ac mattis erat, sed dapibus odio. Sed sollicitudin turpis et diam ultrices, at luctus ex blandit. Sed semper, ligula tempus molestie hendrerit, nisi quam euismod lorem, ac ullamcorper felis lorem sit amet elit. Nullam lacinia tortor ut facilisis cursus. Nullam vel pulvinar magna, semper tristique lacus. Vivamus egestas lorem erat, eget rutrum arcu lobortis et. Quisque placerat nisi a porta dapibus. Donec sed congue risus. Pellentesque condimentum, nibh ac lobortis efficitur, dui dolor molestie tortor, id auctor libero erat eget diam. Fusce rutrum mollis congue. Aliquam erat volutpat. Praesent volutpat, nibh ut cursus dictum, mauris magna pulvinar elit, ut mattis ex felis id nulla.', - type: 'text', - }, - ], - type: 'paragraph', - }, - ], + content: zetkinToRemirror(content), type: 'doc', }, extensions: () => [ @@ -250,8 +247,12 @@ const ZUIEditor: FC = ({ {enableLink && } console.log(updatedContent)} + onChange={(updatedContent) => { + if (updatedContent.content) { + const zetkinContent = remirrorToZetkin(updatedContent.content); + onChange(zetkinContent); + } + }} />
From f9ef80acc60b78828aeb8b86a02dbe2e3b963ff0 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 11:27:26 +0100 Subject: [PATCH 15/28] Add test for default button text. --- .../EmailEditor/EmailOutline/BlockListItem.tsx | 13 +++++++++---- .../ZUIEditor/utils/editorBlockProblems.spec.ts | 17 +++++++++++------ src/zui/ZUIEditor/utils/editorBlockProblems.ts | 15 ++++++++++++--- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx index 973abfe25..200cdb84b 100644 --- a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx +++ b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx @@ -10,14 +10,16 @@ import { import BlockListItemBase from './BlockListItemBase'; import editorBlockProblems from 'zui/ZUIEditor/utils/editorBlockProblems'; import { useMessages } from 'core/i18n'; -import messageIds from 'features/emails/l10n/messageIds'; +import emailMessageIds from 'features/emails/l10n/messageIds'; +import editorMessageIds from 'zui/l10n/messageIds'; interface BlockListItemProps { block: EmailContentBlock; } const BlockListItem: FC = ({ block }) => { - const messages = useMessages(messageIds); + const emailMessages = useMessages(emailMessageIds); + const editorMessages = useMessages(editorMessageIds.editor); const makeTitle = (nodes: EmailContentInlineNode[]): string => { let text = ''; @@ -25,7 +27,7 @@ const BlockListItem: FC = ({ block }) => { if (node.kind == InlineNodeKind.STRING) { text += node.value; } else if (node.kind == InlineNodeKind.VARIABLE) { - text += messages.editor.outline.variables[node.name](); + text += emailMessages.editor.outline.variables[node.name](); } else if ('content' in node) { text += makeTitle(node.content); } @@ -47,7 +49,10 @@ const BlockListItem: FC = ({ block }) => { const title = makeTitle(block.data.content); return ; } else if (block.kind === BlockKind.BUTTON) { - const hasErrors = editorBlockProblems(block); + const hasErrors = editorBlockProblems( + block, + editorMessages.extensions.button.defaultText() + ); return ( 0} diff --git a/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts b/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts index 830c94db4..d28e6e733 100644 --- a/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts +++ b/src/zui/ZUIEditor/utils/editorBlockProblems.spec.ts @@ -42,17 +42,22 @@ describe('editorBlockProblems()', () => { expect(errors[0]).toEqual(BlockProblem.INVALID_BUTTON_URL); }); - it('returns an array with a DEFAULT_BUTTON_TEXT error when the button text is missing', () => { - const errors = editorBlockProblems(mockButtonBlock({ text: '' })); - + it('returns an array with a DEFAULT_BUTTON_TEXT error when the button text is the default text', () => { + const errors = editorBlockProblems( + mockButtonBlock({ text: 'Button text' }), + 'Button text' + ); expect(errors.length).toEqual(1); expect(errors[0]).toEqual(BlockProblem.DEFAULT_BUTTON_TEXT); }); it('returns an array with BUTTON_TEXT_MISSING error when button text exists but is only spaces or empty', () => { - const errors = editorBlockProblems(mockButtonBlock({ text: ' ' })); - expect(errors).toHaveLength(1); - expect(errors[0]).toEqual(BlockProblem.BUTTON_TEXT_MISSING); + const errors1 = editorBlockProblems(mockButtonBlock({ text: ' ' })); + const errors2 = editorBlockProblems(mockButtonBlock({ text: '' })); + expect(errors1).toHaveLength(1); + expect(errors2).toHaveLength(1); + expect(errors1[0]).toEqual(BlockProblem.BUTTON_TEXT_MISSING); + expect(errors2[0]).toEqual(BlockProblem.BUTTON_TEXT_MISSING); }); }); diff --git a/src/zui/ZUIEditor/utils/editorBlockProblems.ts b/src/zui/ZUIEditor/utils/editorBlockProblems.ts index 3c134b26e..4d9904c98 100644 --- a/src/zui/ZUIEditor/utils/editorBlockProblems.ts +++ b/src/zui/ZUIEditor/utils/editorBlockProblems.ts @@ -9,7 +9,10 @@ import { LinkNode, } from 'features/emails/types'; -export default function editorBlockProblems(block: EmailContentBlock) { +export default function editorBlockProblems( + block: EmailContentBlock, + defaultButtonText?: string +) { const blockProblems: BlockProblem[] = []; if (block.kind == BlockKind.BUTTON) { @@ -21,9 +24,15 @@ export default function editorBlockProblems(block: EmailContentBlock) { } const buttonText = block.data.text; - if (!buttonText) { + + const noButtonText = + !buttonText || !buttonText.replaceAll(' ', '').trim().length; + const hasDefaultButtonText = + defaultButtonText && buttonText && buttonText == defaultButtonText; + + if (hasDefaultButtonText) { blockProblems.push(BlockProblem.DEFAULT_BUTTON_TEXT); - } else if (!buttonText.replaceAll(' ', '').trim().length) { + } else if (noButtonText) { blockProblems.push(BlockProblem.BUTTON_TEXT_MISSING); } } else if (block.kind == BlockKind.PARAGRAPH) { From 8fa8269676d2133fef33224337f8addb6d763cb1 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 13:46:45 +0100 Subject: [PATCH 16/28] Gray background colour on item in outline that represents the currently selected block. --- .../EmailOutline/BlockListItem.tsx | 15 +++++++++-- .../EmailOutline/BlockListItemBase.tsx | 12 +++++++-- .../EmailEditor/EmailOutline/index.tsx | 8 ++++-- src/zui/ZUIEditor/EditorOverlays/index.tsx | 5 ++++ src/zui/ZUIEditor/index.tsx | 27 ++++++++++++++++++- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx index 200cdb84b..3d7c2cb12 100644 --- a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx +++ b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx @@ -15,9 +15,10 @@ import editorMessageIds from 'zui/l10n/messageIds'; interface BlockListItemProps { block: EmailContentBlock; + selected: boolean; } -const BlockListItem: FC = ({ block }) => { +const BlockListItem: FC = ({ block, selected }) => { const emailMessages = useMessages(emailMessageIds); const editorMessages = useMessages(editorMessageIds.editor); @@ -42,12 +43,20 @@ const BlockListItem: FC = ({ block }) => { 0} icon={Notes} + selected={selected} title={title} /> ); } else if (block.kind === BlockKind.HEADER) { const title = makeTitle(block.data.content); - return ; + return ( + + ); } else if (block.kind === BlockKind.BUTTON) { const hasErrors = editorBlockProblems( block, @@ -57,6 +66,7 @@ const BlockListItem: FC = ({ block }) => { 0} icon={Crop75} + selected={selected} title={block.data.text} /> ); @@ -66,6 +76,7 @@ const BlockListItem: FC = ({ block }) => { ); diff --git a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx index 36ffaa704..920f06357 100644 --- a/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx +++ b/src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx @@ -1,4 +1,4 @@ -import { Box, SvgIconTypeMap, Typography } from '@mui/material'; +import { Box, SvgIconTypeMap, Typography, useTheme } from '@mui/material'; import { ErrorOutlineOutlined } from '@mui/icons-material'; import { FC } from 'react'; import { OverridableComponent } from '@mui/material/OverridableComponent'; @@ -6,16 +6,24 @@ import { OverridableComponent } from '@mui/material/OverridableComponent'; interface BlockListItemBaseProps { hasErrors: boolean; icon: OverridableComponent, 'svg'>>; + selected: boolean; title: string; } const BlockListItemBase: FC = ({ hasErrors, icon: Icon, + selected, title, }) => { + const theme = useTheme(); return ( - + = ({ blocks }) => { +const EmailOutline: FC = ({ + blocks, + selectedBlockIndex, +}) => { return ( {blocks.map((block, index) => ( - + ))} diff --git a/src/zui/ZUIEditor/EditorOverlays/index.tsx b/src/zui/ZUIEditor/EditorOverlays/index.tsx index 707a679ed..8c9145e9f 100644 --- a/src/zui/ZUIEditor/EditorOverlays/index.tsx +++ b/src/zui/ZUIEditor/EditorOverlays/index.tsx @@ -47,6 +47,7 @@ type Props = { enableLink: boolean; enableVariable: boolean; focused: boolean; + onSelectBlock: (selectedBlockIndex: number) => void; }; const EditorOverlays: FC = ({ @@ -57,6 +58,7 @@ const EditorOverlays: FC = ({ enableLink, enableVariable, focused, + onSelectBlock, }) => { const theme = useTheme(); const view = useEditorView(); @@ -71,6 +73,8 @@ const EditorOverlays: FC = ({ const findSelectedNode = useCallback(() => { if (isNodeSelection(state.selection)) { const selection = state.selection; + const index = selection.$anchor.index(0); + onSelectBlock(index); const posBefore = selection.$anchor.before(1); const posAfter = selection.$head.after(1); const elem = view.nodeDOM(posBefore); @@ -111,6 +115,7 @@ const EditorOverlays: FC = ({ const nodeRect = nodeElem.getBoundingClientRect(); const x = nodeRect.x - editorRect.x; const y = nodeRect.y - editorRect.y; + onSelectBlock(resolved.index(0)); setCurrentBlock({ attributes: node.attrs, node, diff --git a/src/zui/ZUIEditor/index.tsx b/src/zui/ZUIEditor/index.tsx index 4c3e5ffaa..a0f3a2051 100644 --- a/src/zui/ZUIEditor/index.tsx +++ b/src/zui/ZUIEditor/index.tsx @@ -13,8 +13,14 @@ import { ItalicExtension, OrderedListExtension, } from 'remirror/extensions'; -import { AnyExtension, PasteRulesExtension } from 'remirror'; +import { + AnyExtension, + FromToProps, + PasteRulesExtension, + ProsemirrorNode, +} from 'remirror'; import { Box, useTheme } from '@mui/material'; +import { Attrs } from '@remirror/pm/model'; import LinkExtension from './extensions/LinkExtension'; import ButtonExtension from './extensions/ButtonExtension'; @@ -42,6 +48,22 @@ type BlockExtension = | OrderedListExtension | BulletListExtension; +export type BlockType = + | 'paragraph' + | 'heading' + | 'orderedList' + | 'bulletList' + | 'zimage' + | 'zbutton'; + +export type BlockData = { + attributes: Attrs; + node: ProsemirrorNode; + range: FromToProps; + rect: DOMRect; + type: BlockType; +}; + type Props = { content: EmailContentBlock[]; editable: boolean; @@ -54,6 +76,7 @@ type Props = { enableLists?: boolean; enableVariable?: boolean; onChange: (newContent: EmailContentBlock[]) => void; + onSelectBlock: (selectedBlockIndex: number) => void; }; const ZUIEditor: FC = ({ @@ -68,6 +91,7 @@ const ZUIEditor: FC = ({ enableLists, enableVariable, onChange, + onSelectBlock, }) => { const messages = useMessages(messageIds.editor); const theme = useTheme(); @@ -240,6 +264,7 @@ const ZUIEditor: FC = ({ enableLink={!!enableLink} enableVariable={!!enableVariable} focused={focused} + onSelectBlock={(selectedBlock) => onSelectBlock(selectedBlock)} /> {enableBlockMenu && } {enableBlockMenu && enableImage && } From e987a1206d85d45efaf10c16f6da26731f539893 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 12 Feb 2025 15:16:31 +0100 Subject: [PATCH 17/28] Delete everything related to the old email editor. --- .../EmailEditor/EmailEditorFrontend.tsx | 226 -------------- .../EmailOutline/BlockListItem.tsx | 86 ------ .../EmailOutline/BlockListItemBase.tsx | 50 ---- .../EmailEditor/EmailOutline/index.tsx | 28 -- .../EmailSettings/ButtonBlockListItem.tsx | 116 -------- .../EmailSettings/HeaderBlockListItem.tsx | 32 -- .../EmailSettings/ImageBlockListItem.tsx | 109 ------- .../EmailSettings/TextBlockListItem.tsx | 40 --- .../EmailSettings/utils/blockProblems.spec.ts | 103 ------- .../EmailSettings/utils/blockProblems.ts | 32 -- .../EmailSettings/utils/formatUrl.spec.ts | 18 -- .../EmailSettings/utils/formatUrl.ts | 15 - .../tools/Button/ButtonEditableBlock.tsx | 158 ---------- .../EmailEditor/tools/Button/index.tsx | 71 ----- .../EmailEditor/tools/InlineLink/index.tsx | 280 ------------------ .../InlineLink/utils/getAnchorTags.spec.ts | 120 -------- .../tools/InlineLink/utils/getAnchorTags.ts | 26 -- .../LibraryImageEditableBlock.tsx | 101 ------- .../EmailEditor/tools/LibraryImage/index.tsx | 67 ----- .../EmailEditor/tools/inlineVariable/index.ts | 131 -------- .../tools/paragraphWithSpanPaste.ts | 23 -- .../EmailEditor/utils/InlineToolBase.ts | 44 --- .../utils/defaultBlockAttributes.ts | 41 --- .../editorjsBlocksToZetkinBlocks.spec.ts | 120 -------- .../utils/editorjsBlocksToZetkinBlocks.ts | 49 --- .../emails/utils/htmlToInlineNodes.spec.ts | 205 ------------- .../emails/utils/htmlToInlineNodes.ts | 53 ---- .../emails/utils/inlineNodesToHtml.spec.ts | 204 ------------- .../emails/utils/inlineNodesToHtml.ts | 30 -- src/features/emails/utils/inlineVars.ts | 5 - .../zetkinBlocksToEditorjsBlocks.spec.ts | 123 -------- .../utils/zetkinBlocksToEditorjsBlocks.ts | 53 ---- .../emails/[emailId]/compose/index.tsx | 75 ----- .../[campId]/emails/[emailId]/newEditor.tsx | 48 --- 34 files changed, 2882 deletions(-) delete mode 100644 src/features/emails/components/EmailEditor/EmailEditorFrontend.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailOutline/BlockListItem.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailOutline/BlockListItemBase.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailOutline/index.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/ButtonBlockListItem.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/HeaderBlockListItem.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/ImageBlockListItem.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/TextBlockListItem.tsx delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/utils/blockProblems.spec.ts delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/utils/blockProblems.ts delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/utils/formatUrl.spec.ts delete mode 100644 src/features/emails/components/EmailEditor/EmailSettings/utils/formatUrl.ts delete mode 100644 src/features/emails/components/EmailEditor/tools/Button/ButtonEditableBlock.tsx delete mode 100644 src/features/emails/components/EmailEditor/tools/Button/index.tsx delete mode 100644 src/features/emails/components/EmailEditor/tools/InlineLink/index.tsx delete mode 100644 src/features/emails/components/EmailEditor/tools/InlineLink/utils/getAnchorTags.spec.ts delete mode 100644 src/features/emails/components/EmailEditor/tools/InlineLink/utils/getAnchorTags.ts delete mode 100644 src/features/emails/components/EmailEditor/tools/LibraryImage/LibraryImageEditableBlock.tsx delete mode 100644 src/features/emails/components/EmailEditor/tools/LibraryImage/index.tsx delete mode 100644 src/features/emails/components/EmailEditor/tools/inlineVariable/index.ts delete mode 100644 src/features/emails/components/EmailEditor/tools/paragraphWithSpanPaste.ts delete mode 100644 src/features/emails/components/EmailEditor/utils/InlineToolBase.ts delete mode 100644 src/features/emails/components/EmailEditor/utils/defaultBlockAttributes.ts delete mode 100644 src/features/emails/utils/editorjsBlocksToZetkinBlocks.spec.ts delete mode 100644 src/features/emails/utils/editorjsBlocksToZetkinBlocks.ts delete mode 100644 src/features/emails/utils/htmlToInlineNodes.spec.ts delete mode 100644 src/features/emails/utils/htmlToInlineNodes.ts delete mode 100644 src/features/emails/utils/inlineNodesToHtml.spec.ts delete mode 100644 src/features/emails/utils/inlineNodesToHtml.ts delete mode 100644 src/features/emails/utils/inlineVars.ts delete mode 100644 src/features/emails/utils/zetkinBlocksToEditorjsBlocks.spec.ts delete mode 100644 src/features/emails/utils/zetkinBlocksToEditorjsBlocks.ts delete mode 100644 src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/compose/index.tsx delete mode 100644 src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/newEditor.tsx diff --git a/src/features/emails/components/EmailEditor/EmailEditorFrontend.tsx b/src/features/emails/components/EmailEditor/EmailEditorFrontend.tsx deleted file mode 100644 index 944c05752..000000000 --- a/src/features/emails/components/EmailEditor/EmailEditorFrontend.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -//@ts-ignore -import Header from '@editorjs/header'; -import { Box, useTheme } from '@mui/material'; -import EditorJS, { - EditorConfig, - OutputData, - ToolConstructable, -} from '@editorjs/editorjs'; -import { FC, MutableRefObject, useEffect, useRef } from 'react'; - -import Button from './tools/Button'; -import { EmailTheme } from 'features/emails/types'; -import LibraryImage from './tools/LibraryImage'; -import { linkToolFactory } from './tools/InlineLink'; -import messageIds from 'features/emails/l10n/messageIds'; -import { useMessages } from 'core/i18n'; -import { useNumericRouteParams } from 'core/hooks'; -import variableToolFactory from './tools/inlineVariable'; -import ParagraphWithSpanPaste from './tools/paragraphWithSpanPaste'; - -export type EmailEditorFrontendProps = { - apiRef: MutableRefObject; - initialContent: OutputData; - onSave: (data: OutputData) => void; - onSelectBlock: (selectedBlockIndex: number) => void; - readOnly: boolean; - theme: EmailTheme | null; -}; - -const EmailEditorFrontend: FC = ({ - apiRef, - theme: emailTheme, - initialContent, - onSave, - onSelectBlock, - readOnly, -}) => { - const muiTheme = useTheme(); - const messages = useMessages(messageIds); - const { orgId } = useNumericRouteParams(); - const editorInstance = useRef(null); - const blockIndexRef = useRef(null); - - const saveData = async () => { - try { - const savedData = await editorInstance.current?.save(); - if (savedData && onSave) { - const filteredSavedData = { - ...savedData, - blocks: savedData.blocks.filter((block) => { - if (block.type === 'libraryImage' && !block.data.fileId) { - return false; - } - return true; - }), - }; - onSave(filteredSavedData); - } - } catch (error) { - //TODO: handle error - } - }; - - useEffect(() => { - const editorConfig: EditorConfig = { - data: initialContent, - // TODO: Find way to make unique IDs - holder: 'ClientOnlyEditor-container', - inlineToolbar: ['bold', 'italic', 'link', 'variable'], - onChange: () => saveData(), - readOnly: readOnly, - tools: { - button: { - class: Button as unknown as ToolConstructable, - config: { - attributes: emailTheme?.block_attributes?.['button'] ?? {}, - }, - }, - header: { - class: Header, - config: { - defaultLevel: 1, - levels: [1, 2, 3], - }, - inlineToolbar: ['variable'], - }, - libraryImage: { - class: LibraryImage as unknown as ToolConstructable, - config: { - attributes: emailTheme?.block_attributes?.['image'] ?? {}, - orgId, - }, - }, - link: { - class: linkToolFactory(messages.editor.tools.link.title()), - config: { - messages: { - addUrl: messages.editor.tools.link.addUrl(), - invalidUrl: messages.editor.tools.link.invalidUrl(), - testLink: messages.editor.tools.link.testLink(), - }, - theme: { - body2FontSize: muiTheme.typography.body2.fontSize, - mediumGray: muiTheme.palette.grey[600], - primaryColor: muiTheme.palette.primary.main, - warningColor: muiTheme.palette.warning.main, - }, - }, - }, - paragraph: { - class: ParagraphWithSpanPaste as unknown as ToolConstructable, - }, - variable: { - class: variableToolFactory(messages.editor.tools.variable.title()), - }, - }, - }; - - // Create the EditorJS instance - editorInstance.current = new EditorJS(editorConfig); - - const setEditorJSApiRef = async () => { - await editorInstance.current?.isReady; - apiRef.current = editorInstance.current; - }; - - setEditorJSApiRef(); - - return () => { - // Cleanup when the component is unmounted - if (editorInstance.current) { - try { - editorInstance.current.destroy(); - } catch (error) { - //TODO: handle error - } - } - }; - }, []); - - useEffect(() => { - const timer = setInterval(async () => { - await editorInstance.current?.isReady; - - const currentBlockIndex = - editorInstance.current?.blocks.getCurrentBlockIndex(); - if ( - typeof currentBlockIndex == 'number' && - currentBlockIndex >= 0 && - currentBlockIndex !== blockIndexRef.current - ) { - blockIndexRef.current = currentBlockIndex; - onSelectBlock(currentBlockIndex); - } - }, 200); - return () => { - clearInterval(timer); - }; - }, []); - - const styleSheet: CSSStyleSheet = new CSSStyleSheet(); - styleSheet.deleteRule; - - function prefixRule(rule: CSSRule): string { - if (rule instanceof CSSStyleRule) { - let text = ''; - if (rule.selectorText.startsWith('body')) { - // Remove "body" so that body styling affects editor container instead - text = `#ClientOnlyEditor-container ${rule.cssText.slice(4)}`; - } else if (rule.selectorText.startsWith('.wrapper')) { - text = `#ClientOnlyEditor-container .ce-block__content ${rule.cssText.slice( - 8 - )}`; - } else { - text = `#ClientOnlyEditor-container ${rule.cssText}`; - } - - text = text.replace(/(?<=\W)p(?=\W)/, '.ce-paragraph'); - - return text; - } else { - return rule.cssText; - } - } - - styleSheet.replaceSync(emailTheme?.css || ''); - const themeStyles = Array.from(styleSheet.cssRules) - .map((rule) => { - if (rule instanceof CSSMediaRule) { - if (rule.conditionText.includes('dark')) { - // Let's ignore dark mode for now - return ''; - } - - return [ - // TODO: Uncomment this to enable light/dark mode - //`@media ${rule.conditionText} {`, - ...Array.from(rule.cssRules).map((rule) => ' ' + prefixRule(rule)), - //'}', - ].join('\n'); - } else { - return prefixRule(rule); - } - }) - .join('\n'); - - /*eslint-disable react/no-danger*/ - return ( - <> -