From 403bc2c6c3281cea7f831d47dead671b3a3e84de Mon Sep 17 00:00:00 2001 From: Akhmed Ibragimov Date: Tue, 25 Jun 2024 21:25:30 +0300 Subject: [PATCH] Exporting/importing Yoopta content in different formats html, md, text (#187) * Published @yoopta/exports with import/export in the next formats: plain text, html, markdown * Added copy/cut/pasting functionality * Added docs for some plugins --- README.md | 4 +- lerna.json | 8 +- package.json | 2 +- packages/core/editor/package.json | 2 +- .../editor/src/components/Editor/Editor.tsx | 91 +- .../src/editor/blocks/increaseBlockDepth.ts | 6 +- .../editor/src/editor/blocks/insertBlocks.ts | 1 + .../editor/src/editor/blocks/updateBlock.ts | 2 - packages/core/editor/src/editor/core/blur.ts | 8 +- .../editor/src/editor/core/setEditorValue.ts | 1 + packages/core/editor/src/editor/types.ts | 4 +- .../core/editor/src/handlers/onKeyDown.ts | 10 +- packages/core/editor/src/index.ts | 2 + .../editor/src/parsers/deserializeHTML.ts | 125 +- .../editor/src/parsers/deserializeMarkdown.ts | 1 - packages/core/editor/src/parsers/index.ts | 10 - .../core/editor/src/parsers/serializeHTML.ts | 59 +- .../editor/src/parsers/serializeMarkdown.ts | 1 - .../src/plugins/SlateEditorComponent.tsx | 25 +- packages/core/editor/src/plugins/types.ts | 3 +- .../core/editor/src/utils/blockElements.ts | 12 +- .../core/editor/src/utils/editorBuilders.ts | 3 +- packages/core/editor/src/utils/hotkeys.ts | 6 +- .../core/editor/src/utils/serializeHTML.ts | 34 - packages/core/exports/README.md | 125 +- packages/core/exports/package.json | 12 +- packages/core/exports/rollup.config.js | 7 + packages/core/exports/src/html/deserialize.ts | 223 +++- packages/core/exports/src/html/serialize.ts | 65 + packages/core/exports/src/html/serialize.tsx | 36 - packages/core/exports/src/index.ts | 12 +- .../core/exports/src/markdown/deserialize.ts | 85 +- .../core/exports/src/markdown/serialize.ts | 41 +- packages/core/exports/src/text/deserialize.ts | 13 + packages/core/exports/src/text/serialize.ts | 11 + .../core/exports/src/utils/mergePlugins.ts | 45 - packages/core/exports/tsconfig.json | 33 +- packages/development/package.json | 1 + packages/development/src/pages/dev/index.tsx | 467 ++++--- .../development/src/utils/yoopta/marks.ts | 3 + .../development/src/utils/yoopta/plugins.ts | 147 +++ .../development/src/utils/yoopta/tools.ts | 26 + packages/marks/package.json | 4 +- packages/plugins/accordion/package.json | 2 +- .../plugins/accordion/src/plugin/index.tsx | 8 + packages/plugins/blockquote/README.md | 62 +- packages/plugins/blockquote/package.json | 2 +- .../plugins/blockquote/src/plugin/index.tsx | 8 + packages/plugins/callout/README.md | 63 +- packages/plugins/callout/package.json | 2 +- packages/plugins/callout/src/plugin/index.tsx | 41 +- packages/plugins/code/package.json | 2 +- packages/plugins/code/src/plugin/index.tsx | 12 +- packages/plugins/embed/package.json | 2 +- .../src/hooks/useIntersectionObserver.ts | 4 +- packages/plugins/embed/src/plugin/index.tsx | 24 +- .../plugins/embed/src/providers/Figma.tsx | 2 +- packages/plugins/file/package.json | 2 +- packages/plugins/file/src/plugin/index.tsx | 73 +- packages/plugins/file/src/ui/FileUploader.tsx | 1 - packages/plugins/headings/README.md | 156 ++- packages/plugins/headings/package.json | 2 +- .../headings/src/plugin/HeadingOne.tsx | 9 +- .../headings/src/plugin/HeadingThree.tsx | 8 + .../headings/src/plugin/HeadingTwo.tsx | 8 + packages/plugins/image/package.json | 2 +- packages/plugins/image/src/plugin/index.tsx | 19 +- packages/plugins/link/package.json | 2 +- packages/plugins/link/src/plugin/index.tsx | 57 +- packages/plugins/lists/README.md | 8 +- packages/plugins/lists/package.json | 2 +- .../plugins/lists/src/plugin/BulletedList.tsx | 8 + .../plugins/lists/src/plugin/NumberedList.tsx | 8 + .../plugins/lists/src/plugin/TodoList.tsx | 10 +- packages/plugins/paragraph/README.md | 2 +- packages/plugins/paragraph/package.json | 2 +- .../plugins/paragraph/src/plugin/index.tsx | 8 + packages/plugins/video/package.json | 2 +- .../src/hooks/useIntersectionObserver.ts | 4 +- packages/plugins/video/src/plugin/index.tsx | 11 + packages/tools/action-menu/package.json | 2 +- packages/tools/link-tool/package.json | 2 +- packages/tools/toolbar/package.json | 2 +- web/next-example/package.json | 41 +- .../examples/withBaseFullSetup/initValue.ts | 6 +- .../examples/withEditorControl/index.tsx | 4 +- .../components/examples/withExports/index.tsx | 136 ++ .../examples/withExports/initValue.ts | 334 +++++ .../html/HtmlPreview/HtmlPreview.module.scss | 125 ++ .../parsers/html/HtmlPreview/HtmlPreview.tsx | 217 ++++ .../MarkdownPreview.module.scss | 125 ++ .../MarkdownPreview/MarkdownPreview.tsx | 231 ++++ web/next-example/src/components/ui/sheet.tsx | 30 +- .../src/pages/examples/[example].tsx | 12 +- .../src/pages/examples/withExports/html.tsx | 7 + .../pages/examples/withExports/markdown.tsx | 7 + web/next-example/src/styles/globals.scss | 5 +- web/next-example/yarn.lock | 1108 +++++++++-------- web/vite-example/index.html | 2 +- yarn.lock | 165 ++- 100 files changed, 3655 insertions(+), 1320 deletions(-) delete mode 100644 packages/core/editor/src/parsers/deserializeMarkdown.ts delete mode 100644 packages/core/editor/src/parsers/index.ts delete mode 100644 packages/core/editor/src/parsers/serializeMarkdown.ts delete mode 100644 packages/core/editor/src/utils/serializeHTML.ts create mode 100644 packages/core/exports/rollup.config.js create mode 100644 packages/core/exports/src/html/serialize.ts delete mode 100644 packages/core/exports/src/html/serialize.tsx create mode 100644 packages/core/exports/src/text/deserialize.ts create mode 100644 packages/core/exports/src/text/serialize.ts delete mode 100644 packages/core/exports/src/utils/mergePlugins.ts create mode 100644 packages/development/src/utils/yoopta/marks.ts create mode 100644 packages/development/src/utils/yoopta/plugins.ts create mode 100644 packages/development/src/utils/yoopta/tools.ts create mode 100644 web/next-example/src/components/examples/withExports/index.tsx create mode 100644 web/next-example/src/components/examples/withExports/initValue.ts create mode 100644 web/next-example/src/components/parsers/html/HtmlPreview/HtmlPreview.module.scss create mode 100644 web/next-example/src/components/parsers/html/HtmlPreview/HtmlPreview.tsx create mode 100644 web/next-example/src/components/parsers/markdown/MarkdownPreview/MarkdownPreview.module.scss create mode 100644 web/next-example/src/components/parsers/markdown/MarkdownPreview/MarkdownPreview.tsx create mode 100644 web/next-example/src/pages/examples/withExports/html.tsx create mode 100644 web/next-example/src/pages/examples/withExports/markdown.tsx diff --git a/README.md b/README.md index 4902099c4..0ad2b5e95 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![RepoRater](https://repo-rater.eddiehub.io/api/badge?owner=Darginec05&name=Yoopta-Editor)](https://repo-rater.eddiehub.io/rate?owner=Darginec05&name=Yoopta-Editor) ![npm](https://img.shields.io/npm/v/@yoopta/editor) -![license](https://img.shields.io/npm/l/@yoopta/editor) ![downloads](https://img.shields.io/npm/dm/@yoopta/editor) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/Darginec05) @@ -35,7 +34,7 @@ All of this is customizable, extensible, and easy to set up! - Indent and outdent for every plugin by tabs and shift+tabs - Editor instance to programmatically control your content - Editor events for saving to DB in real-time -- Exports in markdown, plain text, html - [in progress. Currently available only HTML exports] +- Exports in markdown, html, plain text - Shortcuts, hotkeys. And customization for this! - Super AI tools not for HYPE, but for real useful work with editor content - [in progress] - The soul invested in the development of this editor 💙 @@ -46,6 +45,7 @@ All of this is customizable, extensible, and easy to set up! - Core - [**@yoopta/editor**](https://github.com/Darginec05/Yoopta-Editor/blob/master/packages/core/editor/README.md) + - [**@yoopta/exports**](https://github.com/Darginec05/Yoopta-Editor/blob/master/packages/core/exports/README.md) - Plugins diff --git a/lerna.json b/lerna.json index 71b47a6a7..f1acd9522 100644 --- a/lerna.json +++ b/lerna.json @@ -1,12 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "packages": [ - "packages/plugins/*", - "packages/tools/*", - "packages/marks/*", - "packages/core/*", - "packages/development" - ], + "packages": ["packages/plugins/*", "packages/tools/*", "packages/marks", "packages/core/*", "packages/development"], "npmClient": "yarn", "useWorkspaces": true, "version": "independent", diff --git a/package.json b/package.json index 987c50057..e7b29ed8b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "workspaces": [ "packages/plugins/*", "packages/tools/*", - "packages/marks/*", + "packages/marks", "packages/core/*", "packages/development" ], diff --git a/packages/core/editor/package.json b/packages/core/editor/package.json index 2f922a549..31ce9cea3 100644 --- a/packages/core/editor/package.json +++ b/packages/core/editor/package.json @@ -1,6 +1,6 @@ { "name": "@yoopta/editor", - "version": "4.5.1", + "version": "4.5.2-rc.3", "license": "MIT", "private": false, "main": "dist/index.js", diff --git a/packages/core/editor/src/components/Editor/Editor.tsx b/packages/core/editor/src/components/Editor/Editor.tsx index 9327e3e7b..94681f401 100644 --- a/packages/core/editor/src/components/Editor/Editor.tsx +++ b/packages/core/editor/src/components/Editor/Editor.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, ReactNode, useEffect, useRef } from 'react'; +import { ClipboardEvent, CSSProperties, ReactNode, useEffect, useRef } from 'react'; import { useYooptaEditor, useYooptaReadOnly } from '../../contexts/YooptaContext/YooptaContext'; import { RenderBlocks } from './RenderBlocks'; import { YooptaMark } from '../../marks'; @@ -12,6 +12,7 @@ import { ReactEditor } from 'slate-react'; import { YooptaBlockPath } from '../../editor/types'; import { useRectangeSelectionBox } from '../SelectionBox/hooks'; import { SelectionBox } from '../SelectionBox/SelectionBox'; +import { serializeHTML } from '../../parsers/serializeHTML'; type Props = { marks?: YooptaMark[]; @@ -27,7 +28,7 @@ type Props = { const getEditorStyles = (styles: CSSProperties) => ({ ...styles, width: styles.width || 400, - paddingBottom: styles.paddingBottom || 100, + paddingBottom: typeof styles.paddingBottom === 'number' ? styles.paddingBottom : 100, }); type State = { @@ -120,12 +121,29 @@ const Editor = ({ state.selectionStarted = false; }; - const onClick = (event: React.MouseEvent) => { + const onMouseDown = (event: React.MouseEvent) => { if (isReadOnly) return; - // [TODO] - handle shift+click - // if (event.shiftKey) { - // } + if (event.shiftKey) { + const currentSelectionIndex = editor.selection; + if (!currentSelectionIndex) return; + + const targetBlock = (event.target as HTMLElement).closest('div[data-yoopta-block]'); + const targetBlockId = targetBlock?.getAttribute('data-yoopta-block-id') || ''; + const targetBlockIndex = editor.children[targetBlockId]?.meta.order; + if (typeof targetBlockIndex !== 'number') return; + + const indexesBetween = Array.from({ length: Math.abs(targetBlockIndex - currentSelectionIndex[0]) }).map( + (_, index) => + targetBlockIndex > currentSelectionIndex[0] + ? currentSelectionIndex[0] + index + 1 + : currentSelectionIndex[0] - index - 1, + ); + + editor.blur(); + editor.setBlockSelected([currentSelectionIndex[0], ...indexesBetween], { only: true }); + return; + } resetSelectionState(); handleEmptyZoneClick(event); @@ -142,39 +160,9 @@ const Editor = ({ resetSelectedBlocks(); }; - // [TODO] - implement with @yoopta/exports - const onCopy = (event: React.ClipboardEvent) => { - // function escapeHtml(text) { - // const map = { - // '&': '&', - // '<': '<', - // '>': '>', - // '"': '"', - // "'": ''', - // }; - // return text.replace(/[&<>"']/g, (m) => map[m]); - // } - // function serializeNode(node, plugins) { - // if (Text.isText(node)) { - // return escapeHtml(node.text); - // } - // const children = node.children.map((node) => serializeNode(node, plugins)).join(''); - // const plugin = plugins[node.type]; - // if (typeof plugin.exports?.html?.serialize === 'function') { - // return plugin.exports.html.serialize(node, children); - // } - // return children; - // } - // export function serializeHtml(data: Descendant[], pluginsMap) { - // const html = data.map((node) => serializeNode(node, pluginsMap)).join(''); - // return html; - // } - }; - const onKeyDown = (event) => { if (isReadOnly) return; - // [TODO] - handle shift+click? if (HOTKEYS.isSelect(event)) { const isAllBlocksSelected = editor.selectedBlocks?.length === Object.keys(editor.children).length; @@ -190,6 +178,35 @@ const Editor = ({ } } + if (HOTKEYS.isCopy(event) || HOTKEYS.isCut(event)) { + if (Array.isArray(editor.selectedBlocks) && editor.selectedBlocks.length > 0) { + event.preventDefault(); + + const htmlString = serializeHTML(editor, editor.getEditorValue()); + const blob = new Blob([htmlString], { type: 'text/html' }); + + const item = new ClipboardItem({ 'text/html': blob }); + + navigator.clipboard.write([item]).then(() => { + const html = new DOMParser().parseFromString(htmlString, 'text/html'); + console.log('HTML copied\n', html.body); + }); + + if (HOTKEYS.isCut(event)) { + const isAllBlocksSelected = editor.selectedBlocks.length === Object.keys(editor.children).length; + + editor.deleteBlocks({ paths: editor.selectedBlocks, focus: false }); + editor.setBlockSelected(null); + resetSelectionState(); + + if (isAllBlocksSelected) { + editor.insertBlock(buildBlockData({ id: generateId() }), { at: [0], focus: true }); + } + } + return; + } + } + if (HOTKEYS.isBackspace(event)) { event.stopPropagation(); @@ -348,7 +365,7 @@ const Editor = ({ className={className ? `yoopta-editor ${className}` : 'yoopta-editor'} style={editorStyles} ref={yooptaEditorRef} - onClick={onClick} + onMouseDown={onMouseDown} onBlur={onBlur} > diff --git a/packages/core/editor/src/editor/blocks/increaseBlockDepth.ts b/packages/core/editor/src/editor/blocks/increaseBlockDepth.ts index a5e62f16e..2e3bae7e0 100644 --- a/packages/core/editor/src/editor/blocks/increaseBlockDepth.ts +++ b/packages/core/editor/src/editor/blocks/increaseBlockDepth.ts @@ -3,12 +3,12 @@ import { findPluginBlockBySelectionPath } from '../../utils/findPluginBlockBySel import { YooEditor, YooptaEditorTransformOptions } from '../types'; export function increaseBlockDepth(editor: YooEditor, options: YooptaEditorTransformOptions = {}) { - const { at = editor.selection } = options; + const { at = editor.selection, blockId = '' } = options; - if (!at) return; + if (!blockId && !at) return; editor.children = createDraft(editor.children); - const block = findPluginBlockBySelectionPath(editor); + const block = editor.children[blockId] || findPluginBlockBySelectionPath(editor); if (!block) return; block.meta.depth = block.meta.depth + 1; diff --git a/packages/core/editor/src/editor/blocks/insertBlocks.ts b/packages/core/editor/src/editor/blocks/insertBlocks.ts index 948484d1c..579a775c6 100644 --- a/packages/core/editor/src/editor/blocks/insertBlocks.ts +++ b/packages/core/editor/src/editor/blocks/insertBlocks.ts @@ -70,6 +70,7 @@ export function insertBlocks(editor: YooEditor, blocks: YooptaBlockData[], optio } editor.children = finishDraft(editor.children); + editor.applyChanges(); editor.emit('change', editor.children); diff --git a/packages/core/editor/src/editor/blocks/updateBlock.ts b/packages/core/editor/src/editor/blocks/updateBlock.ts index 102b4c950..e35515650 100644 --- a/packages/core/editor/src/editor/blocks/updateBlock.ts +++ b/packages/core/editor/src/editor/blocks/updateBlock.ts @@ -12,8 +12,6 @@ export function updateBlock( const block = editor.children[blockId]; if (!block) { - // [TODO] - some weird behaviour when copy/paste - console.log(`Block with id ${blockId} not found`); return; } diff --git a/packages/core/editor/src/editor/core/blur.ts b/packages/core/editor/src/editor/core/blur.ts index cdcf28939..d87fe5c36 100644 --- a/packages/core/editor/src/editor/core/blur.ts +++ b/packages/core/editor/src/editor/core/blur.ts @@ -10,9 +10,11 @@ export type EditorBlurOptions = Pick & { }; function blurFn(editor: YooEditor, slate: SlateEditor) { - ReactEditor.blur(slate); - ReactEditor.deselect(slate); - Transforms.deselect(slate); + try { + ReactEditor.blur(slate); + ReactEditor.deselect(slate); + Transforms.deselect(slate); + } catch (error) {} editor.setBlockSelected(null); editor.setSelection(null); diff --git a/packages/core/editor/src/editor/core/setEditorValue.ts b/packages/core/editor/src/editor/core/setEditorValue.ts index 0b5017047..58a948063 100644 --- a/packages/core/editor/src/editor/core/setEditorValue.ts +++ b/packages/core/editor/src/editor/core/setEditorValue.ts @@ -6,4 +6,5 @@ export function setEditorValue(editor: YooEditor, value: YooptaContentValue) { editor.blockEditorsMap = buildBlockSlateEditors(editor); editor.applyChanges(); + editor.emit('change', editor.children); } diff --git a/packages/core/editor/src/editor/types.ts b/packages/core/editor/src/editor/types.ts index 23a7fa46e..4d2d9b86f 100644 --- a/packages/core/editor/src/editor/types.ts +++ b/packages/core/editor/src/editor/types.ts @@ -19,13 +19,13 @@ export type YooptaBlockData = { meta: YooptaBlockBaseMeta; }; -export type YooptaContentValue = Record; - export type YooptaBlockBaseMeta = { order: number; depth: number; }; +export type YooptaContentValue = Record; + export type SlateEditor = Editor; // add 'end' | 'start' diff --git a/packages/core/editor/src/handlers/onKeyDown.ts b/packages/core/editor/src/handlers/onKeyDown.ts index 94ec3d124..052de2004 100644 --- a/packages/core/editor/src/handlers/onKeyDown.ts +++ b/packages/core/editor/src/handlers/onKeyDown.ts @@ -2,7 +2,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { Editor, Path, Point, Range, Text, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; import { buildBlockData } from '../components/Editor/utils'; -import { Elements } from '../editor/elements'; import { SlateEditor, YooEditor, YooptaBlockPath } from '../editor/types'; import { findPluginBlockBySelectionPath } from '../utils/findPluginBlockBySelectionPath'; import { findSlateBySelectionPath } from '../utils/findSlateBySelectionPath'; @@ -81,8 +80,6 @@ export function onKeyDown(editor: YooEditor) { if (HOTKEYS.isBackspace(event)) { if (event.isDefaultPrevented()) return; - const blockData = findPluginBlockBySelectionPath(editor, { at: editor.selection }); - const block = editor.blocks[blockData?.type || '']; const parentPath = Path.parent(slate.selection.anchor.path); const isStart = Editor.isStart(slate, slate.selection.anchor, parentPath); @@ -160,8 +157,11 @@ export function onKeyDown(editor: YooEditor) { const fullRange = Editor.range(slate, firstElementPath, lastElementPath); const isAllBlockElementsSelected = Range.equals(slate.selection, fullRange); - // [TODO] - handle cases for void node elements and when string is empty - if (Range.isExpanded(slate.selection) && isAllBlockElementsSelected) { + const string = Editor.string(slate, fullRange); + const isElementEmpty = string.trim().length === 0; + + // [TODO] - handle cases for void node elements + if ((Range.isExpanded(slate.selection) && isAllBlockElementsSelected) || isElementEmpty) { event.preventDefault(); ReactEditor.blur(slate); diff --git a/packages/core/editor/src/index.ts b/packages/core/editor/src/index.ts index b3d6d3f45..16d31ef4d 100644 --- a/packages/core/editor/src/index.ts +++ b/packages/core/editor/src/index.ts @@ -30,6 +30,8 @@ export { PluginElementRenderProps, PluginEventHandlerOptions, PluginCustomEditorRenderProps, + PluginDeserializeParser, + PluginserializeParser, YooptaMarkProps, } from './plugins/types'; diff --git a/packages/core/editor/src/parsers/deserializeHTML.ts b/packages/core/editor/src/parsers/deserializeHTML.ts index 4c08b878d..b94f3f122 100644 --- a/packages/core/editor/src/parsers/deserializeHTML.ts +++ b/packages/core/editor/src/parsers/deserializeHTML.ts @@ -13,10 +13,15 @@ const MARKS_NODE_NAME_MATCHERS_MAP = { U: { type: 'underline' }, S: { type: 'strike' }, CODE: { type: 'code' }, - EM: { type: 'code' }, + EM: { type: 'italic' }, }; -type PluginsMapByNodeNames = Record; +type PluginsMapByNode = { + type: string; + parse: PluginDeserializeParser['parse']; +}; + +type PluginsMapByNodeNames = Record; function getMappedPluginByNodeNames(editor: YooEditor): PluginsMapByNodeNames { const PLUGINS_NODE_NAME_MATCHERS_MAP: PluginsMapByNodeNames = {}; @@ -35,10 +40,20 @@ function getMappedPluginByNodeNames(editor: YooEditor): PluginsMapByNodeNames { const { nodeNames } = deserialize; if (nodeNames) { nodeNames.forEach((nodeName) => { - PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName] = { - type: pluginType, - parse: deserialize.parse, - }; + const nodeNameMap = PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName]; + + if (nodeNameMap) { + const nodeNameItem = Array.isArray(nodeNameMap) ? nodeNameMap : [nodeNameMap]; + PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName] = [ + ...nodeNameItem, + { type: pluginType, parse: deserialize.parse }, + ]; + } else { + PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName] = { + type: pluginType, + parse: deserialize.parse, + }; + } }); } } @@ -49,6 +64,55 @@ function getMappedPluginByNodeNames(editor: YooEditor): PluginsMapByNodeNames { return PLUGINS_NODE_NAME_MATCHERS_MAP; } +function buildBlocks(editor: YooEditor, plugin: PluginsMapByNode, el: HTMLElement, children: any[]) { + let nodeElementOrBlocks; + + if (plugin.parse) { + nodeElementOrBlocks = plugin.parse(el as HTMLElement); + + const isInline = Element.isElement(nodeElementOrBlocks) && nodeElementOrBlocks.props?.nodeType === 'inline'; + if (isInline) return nodeElementOrBlocks; + } + + const block = editor.blocks[plugin.type]; + const rootElementType = getRootBlockElementType(block.elements) || ''; + const rootElement = block.elements[rootElementType]; + + const isVoid = rootElement.props?.nodeType === 'void'; + + let rootNode: SlateElement | YooptaBlockData[] = { + id: generateId(), + type: rootElementType, + children: isVoid && !block.hasCustomEditor ? [{ text: '' }] : children.map(mapNodeChildren), + props: { nodeType: 'block', ...rootElement.props }, + }; + + if (nodeElementOrBlocks) { + if (Element.isElement(nodeElementOrBlocks)) { + rootNode = nodeElementOrBlocks; + } else if (Array.isArray(nodeElementOrBlocks)) { + const blocks = nodeElementOrBlocks; + return blocks; + } + } + + if (rootNode.children.length === 0) { + rootNode.children = [{ text: '' }]; + } + + const blockData = buildBlockData({ + id: generateId(), + type: plugin.type, + value: [rootNode], + meta: { + order: 0, + depth: 0, + }, + }); + + return blockData; +} + export function deserialize(editor: YooEditor, pluginsMap: PluginsMapByNodeNames, el: HTMLElement | ChildNode) { if (el.nodeType === 3) { const text = el.textContent?.replace(/[\t\n\r\f\v]+/g, ' '); @@ -72,49 +136,14 @@ export function deserialize(editor: YooEditor, pluginsMap: PluginsMapByNodeNames return { [markType]: true, text }; } - if (pluginsMap[el.nodeName]) { - const plugin = pluginsMap[el.nodeName]; - const block = editor.blocks[plugin.type]; - const rootElementType = getRootBlockElementType(block.elements) || ''; - const rootElement = block.elements[rootElementType]; - - const isVoid = rootElement.props?.nodeType === 'void'; - - let rootNode: SlateElement | YooptaBlockData[] = { - id: generateId(), - type: rootElementType, - children: isVoid && !block.hasCustomEditor ? [{ text: '' }] : children.map(mapNodeChildren), - props: { nodeType: 'block', ...rootElement.props }, - }; - - if (plugin.parse) { - const nodeElementOrBlocks = plugin.parse(el as HTMLElement); - - if (nodeElementOrBlocks) { - if (Element.isElement(nodeElementOrBlocks)) { - rootNode = nodeElementOrBlocks; - } else if (Array.isArray(nodeElementOrBlocks)) { - const blocks = nodeElementOrBlocks; - return blocks; - } - } - } + const plugin = pluginsMap[el.nodeName]; - if (rootNode.children.length === 0) { - rootNode.children = [{ text: '' }]; + if (plugin) { + if (Array.isArray(plugin)) { + return plugin.map((p) => buildBlocks(editor, p, el as HTMLElement, children)); } - const blockData = buildBlockData({ - id: generateId(), - type: plugin.type, - value: [rootNode], - meta: { - order: 0, - depth: 0, - }, - }); - - return blockData; + return buildBlocks(editor, plugin, el as HTMLElement, children); } return children; @@ -125,6 +154,10 @@ function mapNodeChildren(child) { return { text: child }; } + if (Element.isElement(child)) { + return child; + } + if (Array.isArray(child)) { return { text: child[0] }; } diff --git a/packages/core/editor/src/parsers/deserializeMarkdown.ts b/packages/core/editor/src/parsers/deserializeMarkdown.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/packages/core/editor/src/parsers/deserializeMarkdown.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/core/editor/src/parsers/index.ts b/packages/core/editor/src/parsers/index.ts deleted file mode 100644 index e7d44342d..000000000 --- a/packages/core/editor/src/parsers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { deserializeHTML } from './deserializeHTML'; -import { serializeHTML } from './serializeHTML'; - -export const parsers = { - html: { - deserialize: deserializeHTML, - serialize: serializeHTML, - }, - markdown: {}, -}; diff --git a/packages/core/editor/src/parsers/serializeHTML.ts b/packages/core/editor/src/parsers/serializeHTML.ts index 3acc832b6..42101e75e 100644 --- a/packages/core/editor/src/parsers/serializeHTML.ts +++ b/packages/core/editor/src/parsers/serializeHTML.ts @@ -1,2 +1,57 @@ -function serialize(): {}; -export function serializeHTML() {} +import { YooEditor, YooptaContentValue } from '../editor/types'; +import { getPluginByInlineElement } from '../utils/blockElements'; + +const MARKS_NODE_NAME_MATCHERS_MAP = { + underline: { type: 'underline', tag: 'U' }, + strike: { type: 'strike', tag: 'S' }, + code: { type: 'code', tag: 'CODE' }, + italic: { type: 'italic', tag: 'I' }, + bold: { type: 'bold', tag: 'B' }, +}; + +function serializeChildren(children, plugins) { + return children + .map((child) => { + let innerHtml = ''; + + if (child.text) { + innerHtml = Object.keys(MARKS_NODE_NAME_MATCHERS_MAP).reduce((acc, mark) => { + if (child[mark]) { + return `<${MARKS_NODE_NAME_MATCHERS_MAP[mark].tag}>${acc}`; + } + return acc; + }, child.text); + + return innerHtml; + } else if (child.type) { + const childPlugin = getPluginByInlineElement(plugins, child.type); + + if (childPlugin && childPlugin.parsers?.html?.serialize) { + innerHtml = childPlugin.parsers.html.serialize(child, serializeChildren(child.children, plugins)); + return innerHtml; + } + } + + return innerHtml; + }) + .join(''); +} + +export function serializeHTML(editor: YooEditor, content: YooptaContentValue) { + const blocks = Object.values(content) + .filter((item) => editor.selectedBlocks?.includes(item.meta.order)) + .sort((a, b) => a.meta.order - b.meta.order); + + const html = blocks.map((blockData) => { + const plugin = editor.plugins[blockData.type]; + + if (plugin && plugin.parsers?.html?.serialize) { + const content = serializeChildren(blockData.value[0].children, editor.plugins); + return plugin.parsers.html.serialize(blockData.value[0], content); + } + + return ''; + }); + + return `${html.join('')}`; +} diff --git a/packages/core/editor/src/parsers/serializeMarkdown.ts b/packages/core/editor/src/parsers/serializeMarkdown.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/packages/core/editor/src/parsers/serializeMarkdown.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/core/editor/src/plugins/SlateEditorComponent.tsx b/packages/core/editor/src/plugins/SlateEditorComponent.tsx index bd5e3710c..7628ff1f0 100644 --- a/packages/core/editor/src/plugins/SlateEditorComponent.tsx +++ b/packages/core/editor/src/plugins/SlateEditorComponent.tsx @@ -15,8 +15,8 @@ import { buildBlockData } from '../components/Editor/utils'; // [TODO] - test import { withInlines } from './extenstions/withInlines'; -import { parsers } from '../parsers'; import { IS_FOCUSED_EDITOR } from '../utils/weakMaps'; +import { deserializeHTML } from '../parsers/deserializeHTML'; type Props = Plugin & { id: string; @@ -183,16 +183,16 @@ const SlateEditorComponent = ({ [eventHandlers.onKeyUp, editor.readOnly], ); - const onClick = useCallback( + const onMouseDown = useCallback( (event: React.MouseEvent) => { if (editor.readOnly) return; if (editor.selection?.[0] !== block.meta.order) { editor.setSelection([block.meta.order]); } - eventHandlers?.onClick?.(event); + eventHandlers?.onMouseDown?.(event); }, - [eventHandlers.onClick, editor.readOnly, editor.selection?.[0], block.meta.order], + [eventHandlers.onMouseDown, editor.readOnly, editor.selection?.[0], block.meta.order], ); const onBlur = useCallback( @@ -209,7 +209,11 @@ const SlateEditorComponent = ({ (event: React.FocusEvent) => { if (editor.readOnly) return; - IS_FOCUSED_EDITOR.set(editor, true); + if (!editor.isFocused()) { + IS_FOCUSED_EDITOR.set(editor, true); + // [TODO] - as test + editor.emit('focus', true); + } eventHandlers?.onFocus?.(event); }, [eventHandlers.onFocus, editor.readOnly], @@ -222,12 +226,11 @@ const SlateEditorComponent = ({ eventHandlers?.onPaste?.(event); const data = event.clipboardData; - const html = data.getData('text/html'); const parsedHTML = new DOMParser().parseFromString(html, 'text/html'); if (parsedHTML.body.childNodes.length > 0) { - const blocks = parsers.html.deserialize(editor, parsedHTML.body); + const blocks = deserializeHTML(editor, parsedHTML.body); if (blocks.length > 0) { editor.insertBlocks(blocks, { at: editor.selection, focus: true }); @@ -278,7 +281,7 @@ const SlateEditorComponent = ({ onKeyDown={onKeyDown} onKeyUp={onKeyUp} onFocus={onFocus} - onClick={onClick} + onMouseDown={onMouseDown} onBlur={onBlur} customEditor={customEditor} readOnly={editor.readOnly} @@ -299,7 +302,7 @@ type SlateEditorInstanceProps = { onKeyDown: (event: React.KeyboardEvent) => void; onKeyUp: (event: React.KeyboardEvent) => void; onFocus: (event: React.FocusEvent) => void; - onClick: (event: React.MouseEvent) => void; + onMouseDown: (event: React.MouseEvent) => void; onBlur: (event: React.FocusEvent) => void; onPaste: (event: React.ClipboardEvent) => void; customEditor?: (props: PluginCustomEditorRenderProps) => JSX.Element; @@ -319,7 +322,7 @@ const SlateEditorInstance = memo( onKeyDown, onKeyUp, onFocus, - onClick, + onMouseDown, onBlur, onPaste, customEditor, @@ -343,7 +346,7 @@ const SlateEditorInstance = memo( onKeyDown={onKeyDown} onKeyUp={onKeyUp} onFocus={onFocus} - onClick={onClick} + onMouseDown={onMouseDown} decorate={decorate} // [TODO] - carefully check onBlur, e.x. transforms using functions, e.x. highlight update onBlur={onBlur} diff --git a/packages/core/editor/src/plugins/types.ts b/packages/core/editor/src/plugins/types.ts index da6b43789..57e250119 100644 --- a/packages/core/editor/src/plugins/types.ts +++ b/packages/core/editor/src/plugins/types.ts @@ -45,6 +45,7 @@ export type PluginElement = { options?: PluginElementOptions; asRoot?: boolean; children?: string[]; + rootPlugin?: string; }; export type PluginElementsMap = { @@ -82,7 +83,7 @@ export type PluginParsers = { export type PluginParserTypes = 'html' | 'markdown'; export type PluginParserValues = 'deserialize' | 'serialize'; -export type PluginserializeParser = (block) => string; +export type PluginserializeParser = (element: SlateElement, text: string) => string; export type PluginDeserializeParser = { nodeNames: string[]; diff --git a/packages/core/editor/src/utils/blockElements.ts b/packages/core/editor/src/utils/blockElements.ts index ed6562bf3..0f7dbc459 100644 --- a/packages/core/editor/src/utils/blockElements.ts +++ b/packages/core/editor/src/utils/blockElements.ts @@ -1,7 +1,7 @@ import { Editor, Element, NodeEntry, Path } from 'slate'; import { buildBlockElement } from '../components/Editor/utils'; import { SlateElement, YooEditor, YooptaBlock } from '../editor/types'; -import { PluginElement, PluginElementProps, PluginElementsMap } from '../plugins/types'; +import { Plugin, PluginElement, PluginElementProps, PluginElementsMap } from '../plugins/types'; import { generateId } from './generateId'; export function getRootBlockElementType(elems: PluginElementsMap | undefined): string | undefined { @@ -115,3 +115,13 @@ export function buildBlockElementsStructure(editor: YooEditor, blockType: string return rootElementNode; } + +export function getPluginByInlineElement( + plugins: YooEditor['plugins'], + elementType: string, +): Plugin | undefined { + const plugin = Object.values(plugins).find((plugin) => { + return plugin.type === plugin.elements?.[elementType]?.rootPlugin; + }); + return plugin; +} diff --git a/packages/core/editor/src/utils/editorBuilders.ts b/packages/core/editor/src/utils/editorBuilders.ts index e0b730569..03be6f63a 100644 --- a/packages/core/editor/src/utils/editorBuilders.ts +++ b/packages/core/editor/src/utils/editorBuilders.ts @@ -128,10 +128,11 @@ export function buildPlugins( if (plugin.elements) { Object.keys(plugin.elements).forEach((type) => { const element = plugin.elements[type]; + const nodeType = element.props?.nodeType; if (nodeType === 'inline' || nodeType === 'inlineVoid') { - inlineTopLevelPlugins[type] = element; + inlineTopLevelPlugins[type] = { ...element, rootPlugin: plugin.type }; } }); } diff --git a/packages/core/editor/src/utils/hotkeys.ts b/packages/core/editor/src/utils/hotkeys.ts index 9202a2193..ef0de7dff 100644 --- a/packages/core/editor/src/utils/hotkeys.ts +++ b/packages/core/editor/src/utils/hotkeys.ts @@ -28,7 +28,8 @@ const HOTKEYS_MAP = { cmd: 'mod', cmdEnter: 'mod+enter', slashCommand: '/', - kekCeburek: 'mod+enter', + copy: 'mod+c', + cut: 'mod+x', }; const APPLE_HOTKEYS = { @@ -108,7 +109,8 @@ export const HOTKEYS = { isSlashCommand: create('slashCommand'), isShiftArrowUp: create('shiftArrowUp'), isShiftArrowDown: create('shiftArrowDown'), - isKekceburek: create('kekCeburek'), + isCopy: create('copy'), + isCut: create('cut'), }; export type HOTKEYS_TYPE = { diff --git a/packages/core/editor/src/utils/serializeHTML.ts b/packages/core/editor/src/utils/serializeHTML.ts deleted file mode 100644 index 5ef95a421..000000000 --- a/packages/core/editor/src/utils/serializeHTML.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Descendant, Text } from 'slate'; - -function escapeHtml(text) { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - - return text.replace(/[&<>"']/g, (m) => map[m]); -} - -function serializeNode(node, plugins) { - if (Text.isText(node)) { - return escapeHtml(node.text); - } - - const children = node.children.map((node) => serializeNode(node, plugins)).join(''); - - const plugin = plugins[node.type]; - - if (typeof plugin.exports?.html?.serialize === 'function') { - return plugin.exports.html.serialize(node, children); - } - - return children; -} - -export function serializeHtml(data: Descendant[], pluginsMap) { - const html = data.map((node) => serializeNode(node, pluginsMap)).join(''); - return html; -} diff --git a/packages/core/exports/README.md b/packages/core/exports/README.md index 177ded412..87bf50d61 100644 --- a/packages/core/exports/README.md +++ b/packages/core/exports/README.md @@ -1,11 +1,126 @@ -# `yoopta-paragraph` +# Exports -> TODO: description +Exports is core package for exporting/importing yoopta content in different formats +The package `@yoopta/exports` supports exporting/importing in the next formats: -## Usage +- HTML +- Markdown +- Plain text +### Installation + +```bash +yarn add @yoopta/exports +``` + +### Usage + +HTML exports/imports example + +```jsx +import { html } from '@yoopta/exports'; + +const Editor = () => { + const editor = useMemo(() => createYooptaEditor(), []); + + // from html to @yoopta content + const deserializeHTML = () => { + const htmlString = '

First title

'; + const content = html.deserialize(editor, htmlString); + + editor.setEditorValue(content); + }; + + // from @yoopta content to html string + const serializeHTML = () => { + const data = editor.getEditorValue(); + const htmlString = html.serialize(editor, data); + console.log('html string', htmlString); + }; + + return ( +
+ + + + +
+ ); +}; +``` + +--- + +Markdown exports/imports example + +```jsx +import { markdown } from '@yoopta/exports'; + +const Editor = () => { + const editor = useMemo(() => createYooptaEditor(), []); + + // from markdown to @yoopta content + const deserializeMarkdown = () => { + const markdownString = '# First title'; + const value = markdown.deserialize(editor, markdownString); + + editor.setEditorValue(value); + }; + + // from @yoopta content to markdown string + const serializeMarkdown = () => { + const data = editor.getEditorValue(); + const markdownString = markdown.serialize(editor, data); + console.log('markdown string', markdownString); + }; + + return ( +
+ + + + +
+ ); +}; ``` -const paragraph = require('yoopta-paragraph'); -// TODO: DEMONSTRATE API +Plain text exports/imports example + +```jsx +import { plainText } from '@yoopta/exports'; + +const Editor = () => { + const editor = useMemo(() => createYooptaEditor(), []); + + // from plain text to @yoopta content + const deserializeText = () => { + const textString = '# First title'; + const value = plainText.deserialize(editor, textString); + + editor.setEditorValue(value); + }; + + // from @yoopta content to plain text string + const serializeText = () => { + const data = editor.getEditorValue(); + const textString = plainText.serialize(editor, data); + console.log('plain text string', textString); + }; + + return ( +
+ + + + +
+ ); +}; ``` + +Examples + +- Page - [https://yoopta.dev/examples/withExports](https://yoopta.dev/examples/withExports) + - Example with HTML - [https://yoopta.dev/examples/withExports/html](https://yoopta.dev/examples/withExports/html) + - Example with Markdown - [https://yoopta.dev/examples/withExports/markdown](https://yoopta.dev/examples/withExports/markdown) diff --git a/packages/core/exports/package.json b/packages/core/exports/package.json index 7f203abe3..0b424f270 100644 --- a/packages/core/exports/package.json +++ b/packages/core/exports/package.json @@ -1,11 +1,11 @@ { "name": "@yoopta/exports", - "version": "2.0.1", - "description": "> TODO: description", + "version": "4.5.2-rc.3", + "description": "Serialize/deserialize exports in different formats for Yoopta-Editor", "author": "Darginec05 ", "homepage": "https://github.com/Darginec05/Editor-Yoopta#readme", "license": "MIT", - "private": true, + "private": false, "main": "dist/index.js", "type": "module", "module": "dist/index.js", @@ -26,12 +26,14 @@ "url": "git+https://github.com/Darginec05/Editor-Yoopta.git" }, "scripts": { - "test": "node ./__tests__/@yoopta/paragraph.test.js" + "start": "rollup --config rollup.config.js --watch --bundleConfigAsCjs --environment NODE_ENV:development", + "prepublishOnly": "yarn build", + "build": "rollup --config rollup.config.js --bundleConfigAsCjs --environment NODE_ENV:production" }, "bugs": { "url": "https://github.com/Darginec05/Editor-Yoopta/issues" }, "dependencies": { - "lodash.uniqwith": "^4.5.0" + "marked": "^13.0.0" } } diff --git a/packages/core/exports/rollup.config.js b/packages/core/exports/rollup.config.js new file mode 100644 index 000000000..d8f0b5d46 --- /dev/null +++ b/packages/core/exports/rollup.config.js @@ -0,0 +1,7 @@ +import { createRollupConfig } from '../../../config/rollup'; + +const pkg = require('./package.json'); +export default createRollupConfig({ + pkg, + tailwindConfig: { content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'] }, +}); diff --git a/packages/core/exports/src/html/deserialize.ts b/packages/core/exports/src/html/deserialize.ts index 76c0904e6..f1b767c28 100644 --- a/packages/core/exports/src/html/deserialize.ts +++ b/packages/core/exports/src/html/deserialize.ts @@ -1,78 +1,207 @@ -import { YooptaBaseElement, YooptaPluginType } from '@yoopta/editor'; -import { Text } from 'slate'; -import { jsx } from 'slate-hyperscript'; -import { mergePluginTypesToMapHMTLNodeName } from '../utils/mergePlugins'; - -const TEXT_TAGS = { - DEL: () => ({ strikethrough: true }), - EM: () => ({ italic: true }), - I: () => ({ italic: true }), - S: () => ({ strikethrough: true }), - STRONG: () => ({ bold: true }), - U: () => ({ underline: true }), +import { Element } from 'slate'; +import { + buildBlockData, + SlateElement, + YooEditor, + YooptaBlockData, + getRootBlockElementType, + generateId, + YooptaContentValue, + PluginDeserializeParser, +} from '@yoopta/editor'; + +export function isYooptaBlock(block: any): boolean { + return !!block && !!block.id && !!block.type && !!block.value && !!block.meta; +} + +const MARKS_NODE_NAME_MATCHERS_MAP = { + B: { type: 'bold' }, + STRONG: { type: 'bold' }, + I: { type: 'italic' }, + U: { type: 'underline' }, + S: { type: 'strike' }, + CODE: { type: 'code' }, + EM: { type: 'italic' }, }; -const deserialize = ( - el: HTMLElement | ChildNode, - pluginsMap: Record['type'], YooptaPluginType>>, -) => { +type PluginsMapByNode = { + type: string; + parse: PluginDeserializeParser['parse']; +}; + +type PluginsMapByNodeNames = Record; + +function getMappedPluginByNodeNames(editor: YooEditor): PluginsMapByNodeNames { + const PLUGINS_NODE_NAME_MATCHERS_MAP: PluginsMapByNodeNames = {}; + + Object.keys(editor.plugins).forEach((pluginType) => { + const plugin = editor.plugins[pluginType]; + const { parsers } = plugin; + + if (parsers) { + const { html } = parsers; + + if (html) { + const { deserialize } = html; + + if (deserialize) { + const { nodeNames } = deserialize; + if (nodeNames) { + nodeNames.forEach((nodeName) => { + const nodeNameMap = PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName]; + + if (nodeNameMap) { + const nodeNameItem = Array.isArray(nodeNameMap) ? nodeNameMap : [nodeNameMap]; + PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName] = [ + ...nodeNameItem, + { type: pluginType, parse: deserialize.parse }, + ]; + } else { + PLUGINS_NODE_NAME_MATCHERS_MAP[nodeName] = { + type: pluginType, + parse: deserialize.parse, + }; + } + }); + } + } + } + } + }); + + return PLUGINS_NODE_NAME_MATCHERS_MAP; +} + +function buildBlocks(editor: YooEditor, plugin: PluginsMapByNode, el: HTMLElement, children: any[]) { + let nodeElementOrBlocks; + + if (plugin.parse) { + nodeElementOrBlocks = plugin.parse(el as HTMLElement); + + const isInline = Element.isElement(nodeElementOrBlocks) && nodeElementOrBlocks.props?.nodeType === 'inline'; + if (isInline) return nodeElementOrBlocks; + } + + const block = editor.blocks[plugin.type]; + const rootElementType = getRootBlockElementType(block.elements) || ''; + const rootElement = block.elements[rootElementType]; + + const isVoid = rootElement.props?.nodeType === 'void'; + + let rootNode: SlateElement | YooptaBlockData[] = { + id: generateId(), + type: rootElementType, + children: isVoid && !block.hasCustomEditor ? [{ text: '' }] : children.map(mapNodeChildren), + props: { nodeType: 'block', ...rootElement.props }, + }; + + if (nodeElementOrBlocks) { + if (Element.isElement(nodeElementOrBlocks)) { + rootNode = nodeElementOrBlocks; + } else if (Array.isArray(nodeElementOrBlocks)) { + const blocks = nodeElementOrBlocks; + return blocks; + } + } + + if (rootNode.children.length === 0) { + rootNode.children = [{ text: '' }]; + } + + const blockData = buildBlockData({ + id: generateId(), + type: plugin.type, + value: [rootNode], + meta: { + order: 0, + depth: 0, + }, + }); + + return blockData; +} + +export function deserialize(editor: YooEditor, pluginsMap: PluginsMapByNodeNames, el: HTMLElement | ChildNode) { if (el.nodeType === 3) { - return el.textContent; + const text = el.textContent?.replace(/[\t\n\r\f\v]+/g, ' '); + return text; } else if (el.nodeType !== 1) { return null; } else if (el.nodeName === 'BR') { return '\n'; } - const { nodeName } = el; - let parent = el; + const parent = el; let children = Array.from(parent.childNodes) - .map((node) => deserialize(node, pluginsMap)) + .map((node) => deserialize(editor, pluginsMap, node)) .flat(); - if (children.length === 0) { - children = [{ text: '' }]; + if (MARKS_NODE_NAME_MATCHERS_MAP[el.nodeName]) { + const mark = MARKS_NODE_NAME_MATCHERS_MAP[el.nodeName]; + const markType = mark.type; + const text = el.textContent?.replace(/[\t\n\r\f\v]+/g, ' '); + return { [markType]: true, text }; } - if (el.nodeName === 'BODY') { - return jsx('fragment', {}, children); + const plugin = pluginsMap[el.nodeName]; + + if (plugin) { + if (Array.isArray(plugin)) { + return plugin.map((p) => buildBlocks(editor, p, el as HTMLElement, children)); + } + + return buildBlocks(editor, plugin, el as HTMLElement, children); } - if (pluginsMap[nodeName]) { - const plugin = pluginsMap[nodeName]; + return children; +} - if (plugin) { - let node = plugin.defineElement(); +function mapNodeChildren(child) { + if (typeof child === 'string') { + return { text: child }; + } - if (typeof plugin.exports?.html.deserialize?.parse === 'function') { - const data = plugin.exports?.html.deserialize?.parse(el as HTMLElement); - node = { ...node, data }; - } + if (Element.isElement(child)) { + return child; + } - return jsx('element', node, children); - } + if (Array.isArray(child)) { + return { text: child[0] }; } - if (Text.isTextList(children)) { - return jsx('element', pluginsMap.P.defineElement(), children); + if (child.text) { + return child; } - if (TEXT_TAGS[nodeName]) { - const attrs = TEXT_TAGS[nodeName](el); - const textNodes = children.map((child) => { - return Text.isText(child) ? jsx('text', attrs, child) : child; + if (isYooptaBlock(child)) { + const block = child as YooptaBlockData; + let text = ''; + + block.value[0].children.forEach((child) => { + text += `${child.text}`; }); - return textNodes; + return { text }; } - return children; -}; - -export function deserializeHtml(htmlString: string, plugins: YooptaPluginType>[]) { - const pluginsMap = mergePluginTypesToMapHMTLNodeName(plugins); + return { text: '' }; +} +export function deserializeHTML(editor: YooEditor, htmlString: string): YooptaContentValue { const parsedHtml = new DOMParser().parseFromString(htmlString, 'text/html'); - return deserialize(parsedHtml.body, pluginsMap); + const value: YooptaContentValue = {}; + + const PLUGINS_NODE_NAME_MATCHERS_MAP = getMappedPluginByNodeNames(editor); + const blocks = deserialize(editor, PLUGINS_NODE_NAME_MATCHERS_MAP, parsedHtml.body).filter( + isYooptaBlock, + ) as YooptaBlockData[]; + + blocks.forEach((block, i) => { + const blockData = block; + blockData.meta.order = i; + value[block.id] = blockData; + }); + + return value; } diff --git a/packages/core/exports/src/html/serialize.ts b/packages/core/exports/src/html/serialize.ts new file mode 100644 index 000000000..1ae58ad07 --- /dev/null +++ b/packages/core/exports/src/html/serialize.ts @@ -0,0 +1,65 @@ +import { YooEditor, YooptaBlockData, SlateElement, YooptaContentValue } from '@yoopta/editor'; + +export function getPluginByInlineElement(plugins: YooEditor['plugins'], elementType: string) { + const plugin = Object.values(plugins).find((plugin) => plugin.type === plugin.elements?.[elementType].rootPlugin); + return plugin; +} + +const MARKS_NODE_NAME_MATCHERS_MAP = { + underline: { type: 'underline', tag: 'U' }, + strike: { type: 'strike', tag: 'S' }, + code: { type: 'code', tag: 'CODE' }, + italic: { type: 'italic', tag: 'I' }, + bold: { type: 'bold', tag: 'B' }, +}; + +function serializeChildren(children, plugins) { + return children + .map((child) => { + let innerHtml = ''; + + if (child.text) { + innerHtml = Object.keys(MARKS_NODE_NAME_MATCHERS_MAP).reduce((acc, mark) => { + if (child[mark]) { + return `<${MARKS_NODE_NAME_MATCHERS_MAP[mark].tag}>${acc}`; + } + return acc; + }, child.text); + + return innerHtml; + } else if (child.type) { + const childPlugin = getPluginByInlineElement(plugins, child.type); + + if (childPlugin && childPlugin.parsers?.html?.serialize) { + innerHtml = childPlugin.parsers.html.serialize(child, serializeChildren(child.children, plugins)); + return innerHtml; + } + } + + return innerHtml; + }) + .join(''); +} + +export function serialize(editor: YooEditor, blocksData: YooptaBlockData[]) { + const blocks = blocksData.sort((a, b) => a.meta.order - b.meta.order); + + const html = blocks.map((blockData) => { + const plugin = editor.plugins[blockData.type]; + + if (plugin && plugin.parsers?.html?.serialize) { + const content = serializeChildren(blockData.value[0].children, editor.plugins); + const text = plugin.parsers.html.serialize(blockData.value[0] as SlateElement, content); + return `${text}\n`; + } + + return ''; + }); + + return `${html.join('')}`; +} + +export function serializeHTML(editor: YooEditor, content: YooptaContentValue) { + const selectedBlocks = Object.values(content); + return serialize(editor, selectedBlocks); +} diff --git a/packages/core/exports/src/html/serialize.tsx b/packages/core/exports/src/html/serialize.tsx deleted file mode 100644 index 799877136..000000000 --- a/packages/core/exports/src/html/serialize.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Descendant, Text } from 'slate'; -import { mergePluginTypesToMap } from '../utils/mergePlugins'; - -function escapeHtml(text) { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - - return text.replace(/[&<>"']/g, (m) => map[m]); -} - -function serializeNode(node, plugins) { - if (Text.isText(node)) { - return escapeHtml(node.text); - } - - const children = node.children.map((node) => serializeNode(node, plugins)).join(''); - - const plugin = plugins[node.type]; - - if (typeof plugin.exports?.html?.serialize === 'function') { - return plugin.exports.html.serialize(node, children); - } - - return children; -} - -export function serializeHtml(data: Descendant[], plugins) { - const pluginsMap = mergePluginTypesToMap(plugins); - const html = data.map((node) => serializeNode(node, pluginsMap)).join(''); - return html; -} diff --git a/packages/core/exports/src/index.ts b/packages/core/exports/src/index.ts index 09d4a7a84..4fce8abd0 100644 --- a/packages/core/exports/src/index.ts +++ b/packages/core/exports/src/index.ts @@ -1,16 +1,20 @@ -import { serializeHtml } from './html/serialize'; +import { serializeHTML } from './html/serialize'; +import { deserializeHTML } from './html/deserialize'; import { deserializeMarkdown } from './markdown/deserialize'; import { serializeMarkdown } from './markdown/serialize'; -import { deserializeHtml } from './html/deserialize'; +import { deserializeText } from './text/deserialize'; +import { serializeText } from './text/serialize'; const markdown = { deserialize: deserializeMarkdown, serialize: serializeMarkdown }; -const html = { deserialize: deserializeHtml, serialize: serializeHtml }; +const html = { deserialize: deserializeHTML, serialize: serializeHTML }; +const plainText = { deserialize: deserializeText, serialize: serializeText }; const yooptaExports = { markdown, html, + plainText, }; -export { markdown, html }; +export { markdown, html, plainText }; export default yooptaExports; diff --git a/packages/core/exports/src/markdown/deserialize.ts b/packages/core/exports/src/markdown/deserialize.ts index 232ed3271..b08fd0e76 100644 --- a/packages/core/exports/src/markdown/deserialize.ts +++ b/packages/core/exports/src/markdown/deserialize.ts @@ -1,81 +1,10 @@ -import { YooptaBaseElement, YooptaPluginType } from '@yoopta/editor'; -import { Text } from 'slate'; -import { jsx } from 'slate-hyperscript'; -import { mergePluginTypesToMapHMTLNodeName } from '../utils/mergePlugins'; +import { YooEditor, YooptaContentValue } from '@yoopta/editor'; +import { marked } from 'marked'; +import { deserializeHTML } from '../html/deserialize'; -const TEXT_TAGS = { - DEL: () => ({ strikethrough: true }), - EM: () => ({ italic: true }), - I: () => ({ italic: true }), - S: () => ({ strikethrough: true }), - STRONG: () => ({ bold: true }), - U: () => ({ underline: true }), -}; +export function deserializeMarkdown(editor: YooEditor, markdown: string): YooptaContentValue { + const html = marked.parse(markdown, { gfm: true, breaks: true, pedantic: false }); + console.log('html from marked', html); -const deserialize = ( - el: HTMLElement | ChildNode, - pluginsMap: Record['type'], YooptaPluginType>>, -) => { - if (el.nodeType === 3) { - return el.textContent; - } else if (el.nodeType !== 1) { - return null; - } else if (el.nodeName === 'BR') { - return '\n'; - } - - const { nodeName } = el; - let parent = el; - - let children = Array.from(parent.childNodes) - .map((node) => deserialize(node, pluginsMap)) - .flat(); - - if (children.length === 0) { - children = [{ text: '' }]; - } - - if (el.nodeName === 'BODY') { - return jsx('fragment', {}, children); - } - - if (pluginsMap[nodeName]) { - const plugin = pluginsMap[nodeName]; - - if (plugin) { - let node = plugin.defineElement(); - - if (typeof plugin.exports?.html.deserialize?.parse === 'function') { - const data = plugin.exports?.html.deserialize?.parse(el as HTMLElement); - node = { ...node, data }; - } - - return jsx('element', node, children); - } - } - - if (Text.isTextList(children)) { - return jsx('element', pluginsMap.P.defineElement(), children); - } - - if (TEXT_TAGS[nodeName]) { - const attrs = TEXT_TAGS[nodeName](el); - const textNodes = children.map((child) => { - return Text.isText(child) ? jsx('text', attrs, child) : child; - }); - - return textNodes; - } - - return children; -}; - -export function deserializeMarkdown( - htmlString: string, - plugins: YooptaPluginType>[], -) { - const pluginsMap = mergePluginTypesToMapHMTLNodeName(plugins); - - const parsedHtml = new DOMParser().parseFromString(htmlString, 'text/html'); - return deserialize(parsedHtml.body, pluginsMap); + return deserializeHTML(editor, html); } diff --git a/packages/core/exports/src/markdown/serialize.ts b/packages/core/exports/src/markdown/serialize.ts index 024f1bea4..49385ab1d 100644 --- a/packages/core/exports/src/markdown/serialize.ts +++ b/packages/core/exports/src/markdown/serialize.ts @@ -1,25 +1,30 @@ -import { Descendant, Text } from 'slate'; -import { mergePluginTypesToMap } from '../utils/mergePlugins'; +import { SlateElement, YooEditor, YooptaBlockData, YooptaContentValue } from '@yoopta/editor'; -const serializeNode = (node, pluginsMap) => { - if (Text.isText(node)) { - return node.text; - } +export function serialize(editor: YooEditor, blocksData: YooptaBlockData[]) { + const blocks = blocksData.sort((a, b) => (a.meta.order > b.meta.order ? 1 : -1)); - const children = node.children.map((n) => serializeNode(n, pluginsMap)).join(''); - const plugin = pluginsMap[node.type]; + const markdown = blocks.map((blockData) => { + const plugin = editor.plugins[blockData.type]; - const serializeFn = plugin.exports?.markdown?.serialize; + if (plugin) { + const element = blockData.value[0] as SlateElement; - if (typeof serializeFn === 'function') { - return `${serializeFn(node, children)}\n`; - } + if (plugin.parsers?.markdown?.serialize) { + const serialized = plugin.parsers.markdown.serialize( + element, + element.children.map((child) => child.text).join(''), + ); + if (serialized) return serialized; + } + } - return `\n${children}\n`; -}; + return ''; + }); -export function serializeMarkdown(data: Descendant[], plugins) { - const pluginsMap = mergePluginTypesToMap(plugins); - const html = data.map((node) => serializeNode(node, pluginsMap)).join(''); - return html; + return markdown.join('\n'); +} + +export function serializeMarkdown(editor: YooEditor, content: YooptaContentValue) { + const selectedBlocks = Object.values(content); + return serialize(editor, selectedBlocks); } diff --git a/packages/core/exports/src/text/deserialize.ts b/packages/core/exports/src/text/deserialize.ts new file mode 100644 index 000000000..163fd4099 --- /dev/null +++ b/packages/core/exports/src/text/deserialize.ts @@ -0,0 +1,13 @@ +import { buildBlockData, generateId, YooEditor, YooptaContentValue } from '@yoopta/editor'; + +export function deserializeText(editor: YooEditor, text: string): YooptaContentValue { + const blockId = generateId(); + const paragraphBlock = buildBlockData({ + id: blockId, + value: [{ id: generateId(), children: [{ text }] }], + }); + + return { + [blockId]: paragraphBlock, + }; +} diff --git a/packages/core/exports/src/text/serialize.ts b/packages/core/exports/src/text/serialize.ts new file mode 100644 index 000000000..a71b8390c --- /dev/null +++ b/packages/core/exports/src/text/serialize.ts @@ -0,0 +1,11 @@ +import { YooEditor, YooptaContentValue } from '@yoopta/editor'; +import { serializeHTML } from '../html/serialize'; + +export function serializeText(editor: YooEditor, content: YooptaContentValue) { + const htmlString = serializeHTML(editor, content); + console.log('htmlString', htmlString); + + const div = document.createElement('div'); + div.innerHTML = htmlString; + return div.innerText; +} diff --git a/packages/core/exports/src/utils/mergePlugins.ts b/packages/core/exports/src/utils/mergePlugins.ts deleted file mode 100644 index c8df9b91a..000000000 --- a/packages/core/exports/src/utils/mergePlugins.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { YooptaBaseElement, YooptaPlugin, YooptaPluginType } from '@yoopta/editor'; -import uniqWith from 'lodash.uniqwith'; - -export function mergePlugins(plugins): YooptaPluginType[] { - const items: YooptaPluginType[] = plugins - .map((instance) => { - const { childPlugin, ...componentProps } = instance.getPlugin; - return childPlugin ? [componentProps, { ...childPlugin.getPlugin, hasParent: true }] : componentProps; - }) - .flat(); - - const uniquePlugins = uniqWith(items, (a, b) => a.type === b.type); - return uniquePlugins; -} - -export function mergePluginTypesToMap( - plugins: YooptaPluginType>[], -): Record['type'], YooptaPluginType>> { - const yooptaPlugins = mergePlugins(plugins); - - const PLUGINS_MAP = {}; - yooptaPlugins.forEach((plugin) => (PLUGINS_MAP[plugin.type] = plugin)); - return PLUGINS_MAP; -} - -export function mergePluginTypesToMapHMTLNodeName( - plugins: YooptaPluginType>[], -): Record['type'], YooptaPluginType>> { - const yooptaPlugins = mergePlugins(plugins); - - const PLUGINS_MAP_HTML_NODE_NAMES = {}; - yooptaPlugins.forEach((plugin) => { - if (plugin.exports?.html.deserialize?.nodeName) { - if (Array.isArray(plugin.exports?.html.deserialize?.nodeName)) { - plugin.exports?.html.deserialize?.nodeName.forEach((nodeName) => { - PLUGINS_MAP_HTML_NODE_NAMES[nodeName] = plugin; - }); - return; - } - - PLUGINS_MAP_HTML_NODE_NAMES[plugin.exports?.html.deserialize?.nodeName] = plugin; - } - }); - return PLUGINS_MAP_HTML_NODE_NAMES; -} diff --git a/packages/core/exports/tsconfig.json b/packages/core/exports/tsconfig.json index f586c8026..8c3461a8a 100644 --- a/packages/core/exports/tsconfig.json +++ b/packages/core/exports/tsconfig.json @@ -1,29 +1,14 @@ { + "extends": "../../../config/tsconfig.base.json", + "include": ["react-svg.d.ts", "css-modules.d.ts", "src"], + "exclude": ["dist", "src/**/*.test.tsx", "src/**/*.stories.tsx"], "compilerOptions": { - "target": "ES6", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": false, - "jsx": "react-jsx", - "incremental": true, "rootDir": "./src", - "noImplicitAny": false, - "downlevelIteration": true, - "module": "ESNext", - "declaration": true, - "declarationDir": "dist", - "sourceMap": true, - "outDir": "dist", - "allowSyntheticDefaultImports": true, - "preserveSymlinks": false + "outDir": "./dist" }, - "include": ["react-svg.d.ts", "css-modules.d.ts", "src"], - "exclude": ["dist", "src/**/*.test.tsx", "src/**/*.stories.tsx"] + "references": [ + { + "path": "../editor" + } + ] } diff --git a/packages/development/package.json b/packages/development/package.json index b8fa67c38..1941f59fb 100644 --- a/packages/development/package.json +++ b/packages/development/package.json @@ -27,6 +27,7 @@ "@yoopta/table": "*", "@yoopta/toolbar": "*", "@yoopta/video": "*", + "@yoopta/exports": "*", "@yoopta/accordion": "*", "classnames": "^2.5.1", "katex": "^0.16.10", diff --git a/packages/development/src/pages/dev/index.tsx b/packages/development/src/pages/dev/index.tsx index 988491c5b..b827d380a 100644 --- a/packages/development/src/pages/dev/index.tsx +++ b/packages/development/src/pages/dev/index.tsx @@ -1,189 +1,16 @@ import YooptaEditor, { createYooptaEditor, - Tools, useYooptaEditor, useYooptaFocused, YooEditor, YooptaBlockData, + YooptaContentValue, } from '@yoopta/editor'; -import Blockquote from '@yoopta/blockquote'; -import Paragraph from '@yoopta/paragraph'; -import Headings from '@yoopta/headings'; -import Image from '@yoopta/image'; -import { Bold, Italic, Highlight, CodeMark, Strike, Underline } from '@yoopta/marks'; -import Callout from '@yoopta/callout'; -import Lists from '@yoopta/lists'; -import Link from '@yoopta/link'; -import Video from '@yoopta/video'; -import File from '@yoopta/file'; -import Embed from '@yoopta/embed'; -import AccordionPlugin from '@yoopta/accordion'; -import ActionMenuList, { DefaultActionMenuRender } from '@yoopta/action-menu-list'; -import LinkTool, { DefaultLinkToolRender } from '@yoopta/link-tool'; -import Toolbar, { DefaultToolbarRender } from '@yoopta/toolbar'; +import { html, markdown, plainText } from '@yoopta/exports'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { uploadToCloudinary } from '../../utils/cloudinary'; - -import Code from '@yoopta/code'; -import { ActionNotionMenuExample } from '../../components/ActionMenuExamples/NotionExample/ActionNotionMenuExample'; -import { NotionToolbar } from '../../components/Toolbars/NotionToolbar/NotionToolbar'; -import { ACCORDION_BLOCK } from '../../components/customPlugins/Accordion/Accordion'; -// import Accordion from '../../components/customPlugins/Accordion/src'; -// import Mention from '@yoopta/mention'; - -const plugins = [ - AccordionPlugin, - Code, - File.extend({ - options: { - onUpload: async (file: File) => { - const data = await uploadToCloudinary(file, 'auto'); - - return { - src: data.secure_url, - format: data.format, - name: data.name, - size: data.bytes, - }; - }, - }, - }), - Paragraph.extend({ - options: { - HTMLAttributes: { - className: 'paragraph-element-extended', - }, - }, - }), - Image.extend({ - // renders: { - // image: ({ attributes, children, element, blockId }) => { - // return ( - //
- // - // {children} - //
- // ); - // }, - // }, - options: { - maxSizes: { - maxHeight: 800, - }, - HTMLAttributes: { - className: 'image-element-extended', - }, - - onUpload: async (file: File) => { - const data = await uploadToCloudinary(file); - - return { - src: data.secure_url, - alt: 'cloudinary', - fit: 'fill', - sizes: { - width: data.width, - height: data.height, - }, - }; - }, - }, - }), - Headings.HeadingOne.extend({ - options: { - HTMLAttributes: { - className: 'heading-one-element-extended', - style: { - color: 'red !important', - }, - }, - }, - }), - Headings.HeadingTwo.extend({ - options: { - HTMLAttributes: { - className: 'heading-two-element-extended', - }, - }, - }), - Headings.HeadingThree, - Blockquote.extend({ - options: { - HTMLAttributes: { - className: 'blockquote-element-extended', - }, - }, - }), - Callout.extend({ - options: { - HTMLAttributes: { - className: 'callout-element-extended', - }, - }, - }), - Lists.BulletedList.extend({ - options: { - HTMLAttributes: { - className: 'bulleted-list-element-extended', - }, - }, - }), - Lists.NumberedList, - Lists.TodoList, - Embed, - Video.extend({ - options: { - HTMLAttributes: { - className: 'video-element-extended', - }, - onUpload: async (file: File) => { - const data = await uploadToCloudinary(file, 'video'); - return { - src: data.secure_url, - alt: 'cloudinary', - fit: 'cover', - sizes: { - width: data.width, - height: data.height, - }, - }; - }, - }, - }), - Link.extend({ - options: { - HTMLAttributes: { - className: 'link-element', - }, - }, - }), -]; - -const MARKS = [Bold, Italic, Highlight, CodeMark, Strike, Underline]; - -const TOOLS: Tools = { - ActionMenu: { - // render: ActionNotionMenuExample, - render: DefaultActionMenuRender, - tool: ActionMenuList, - props: { - // items: ['Callout', 'Blockquote', 'HeadingOne', 'HeadingTwo', 'HeadingThree', 'Image', 'File'], - }, - }, - Toolbar: { - render: DefaultToolbarRender, - // render: NotionToolbar, - tool: Toolbar, - }, - LinkTool: { - render: DefaultLinkToolRender, - tool: LinkTool, - }, -}; +import { MARKS } from '../../utils/yoopta/marks'; +import { YOOPTA_PLUGINS } from '../../utils/yoopta/plugins'; +import { TOOLS } from '../../utils/yoopta/tools'; export type YooptaChildrenValue = Record; @@ -193,7 +20,16 @@ const BasicExample = () => { const [readOnly, setReadOnly] = useState(false); useEffect(() => { - editor.on('block:copy', (value) => console.log('BLOCK COPY', value)); + const handleCopy = (value) => console.log('BLOCK COPY', value); + const handleFocus = (focused) => console.log('FOCUS', focused); + + editor.on('block:copy', handleCopy); + editor.on('focus', handleFocus); + + return () => { + editor.off('block:copy', handleCopy); + editor.off('focus', handleFocus); + }; }, []); const onSubmit = () => { @@ -202,53 +38,272 @@ const BasicExample = () => { }; return ( -
- - - -
+ <> + {/*
+ +
*/} + {/*
+ +
*/} +
+ + + +
+ ); }; const Buttons = ({ onSubmit }: any) => { const editor = useYooptaEditor(); const isFocused = useYooptaFocused(); + const [mdValue, setMdValue] = useState(''); return (
-