diff --git a/src/app.tsx b/src/app.tsx index b5227ce..5368f5d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,9 +4,17 @@ import Container from "./container"; import "./app.css"; import "./typography.css"; import { listenLinks } from "./utils.electron"; +import { remote } from 'electron'; listenLinks(); +// Gives me a quick inspect element for debugging. +// todo: When I re-productionize the app, make this a debug only +// handler +window.onauxclick = (e) => { + remote.getCurrentWindow().webContents.inspectElement(e.x, e.y) +} + ReactDOM.render(
diff --git a/src/views/editor/EditDocument.tsx b/src/views/editor/EditDocument.tsx index 06be05d..fe29cb5 100644 --- a/src/views/editor/EditDocument.tsx +++ b/src/views/editor/EditDocument.tsx @@ -20,12 +20,13 @@ function EditDocument(props: Props) { loading: loadingDoc, loadingErr: loadingDocErr, docState, + initialState, } = useEditableDocument(props.documentId); const { journals, loading: loadingJournals, loadingErr: loadingJournalsErr } = useJournals() const loading = loadingDoc || loadingJournals const loadingErr = loadingDocErr || loadingJournalsErr - const [showAST, setShowAST] = React.useState(false); + const [showAST, setShowAST] = React.useState<'original' | 'current' | null>(null); function renderError() { @@ -46,9 +47,11 @@ function EditDocument(props: Props) { return journal ? journal.name : 'Unknown journal'; } - function showASTOrEditor(showAST: boolean) { - if (showAST) { + function showASTOrEditor(showAST: 'original' | 'current' | null) { + if (showAST === 'current') { return + } else if (showAST === 'original') { + return } else { return } @@ -65,9 +68,9 @@ function EditDocument(props: Props) { onChange={(e: any) => docState!.title = e.target.value} value={docState && docState.title || ''} /> - + +

/{getName(docState?.journalId)}

- {showASTOrEditor(showAST)} @@ -96,4 +99,14 @@ const ASTExplorer = (p: ASTProps) => {
{slateToMdast(p.slateNodes)}
) +} + +const OriginalASTExplorer = ({ initialState } : any) => { + return ( +
+
{initialState.raw}
+
{JSON.stringify(initialState.mdast, null, 2)}
+
{JSON.stringify(initialState.slate, null, 2)}
+
+ ) } \ No newline at end of file diff --git a/src/views/editor/blocks/images.tsx b/src/views/editor/blocks/images.tsx new file mode 100644 index 0000000..b1d6915 --- /dev/null +++ b/src/views/editor/blocks/images.tsx @@ -0,0 +1,149 @@ + +import unified from "unified"; +import markdown from "remark-parse"; +import remarkGfm from 'remark-gfm' +import { remarkToSlate, slateToRemark, mdastToSlate } from "remark-slate-transformer"; +const parser = unified().use(markdown).use(remarkGfm as any) + +function isImageUrl(url: string) { + if (!url) return false; + + const mdast = parser.parse(url) + console.log(mdast); + console.log(mdastToSlate(mdast as any)) // expects Root, parser returns "Node" (its actually a root in my case) +} + + +// const isImageUrl = url => { +// if (!url) return false +// if (!isUrl(url)) return false +// const ext = new URL(url).pathname.split('.').pop() +// return imageExtensions.includes(ext) +// } +// https://cdn.shopify.com/s/files/1/3106/5828/products/IMG_9385_1024x1024@2x.jpg?v=1577795595 +// const imageExtensionRegex = + +// Copied from this repo: https://github.com/arthurvr/image-extensions +// Which is an npm package that is just a json file +const imageExtensions = [ + "ase", + "art", + "bmp", + "blp", + "cd5", + "cit", + "cpt", + "cr2", + "cut", + "dds", + "dib", + "djvu", + "egt", + "exif", + "gif", + "gpl", + "grf", + "icns", + "ico", + "iff", + "jng", + "jpeg", + "jpg", + "jfif", + "jp2", + "jps", + "lbm", + "max", + "miff", + "mng", + "msp", + "nitf", + "ota", + "pbm", + "pc1", + "pc2", + "pc3", + "pcf", + "pcx", + "pdn", + "pgm", + "PI1", + "PI2", + "PI3", + "pict", + "pct", + "pnm", + "pns", + "ppm", + "psb", + "psd", + "pdd", + "psp", + "px", + "pxm", + "pxr", + "qfx", + "raw", + "rle", + "sct", + "sgi", + "rgb", + "int", + "bw", + "tga", + "tiff", + "tif", + "vtf", + "xbm", + "xcf", + "xpm", + "3dv", + "amf", + "ai", + "awg", + "cgm", + "cdr", + "cmx", + "dxf", + "e2d", + "egt", + "eps", + "fs", + "gbr", + "odg", + "svg", + "stl", + "vrml", + "x3d", + "sxd", + "v2d", + "vnd", + "wmf", + "emf", + "art", + "xar", + "png", + "webp", + "jxr", + "hdp", + "wdp", + "cur", + "ecw", + "iff", + "lbm", + "liff", + "nrrd", + "pam", + "pcx", + "pgf", + "sgi", + "rgb", + "rgba", + "bw", + "int", + "inta", + "sid", + "ras", + "sun", + "tga" +] \ No newline at end of file diff --git a/src/views/editor/blocks/links.tsx b/src/views/editor/blocks/links.tsx new file mode 100644 index 0000000..8df20ee --- /dev/null +++ b/src/views/editor/blocks/links.tsx @@ -0,0 +1,290 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { ReactEditor, RenderElementProps, useSlate } from "slate-react"; +import { Transforms, Element as SlateElement, Editor, Selection, Range } from "slate"; +import { isLinkElement, LinkElement } from '../util'; +import { css } from "emotion"; +import { TextInputField, Button } from 'evergreen-ui' + + +const urlRegex = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; +export const urlMatcher = new RegExp(urlRegex); + +const createLinkNode = (href: string, text: string) => ({ + type: "link", + href, + children: [{ text }] +}); + +// guessing at this one, wasn't in tutorial +function createParagraphNode(children: any[]) { + return { + type: 'paragraph', + children, + } +} + +export const removeLink = (editor: Editor, node: LinkElement, opts = {}) => { + Transforms.unwrapNodes(editor, { + ...opts, + match: n => n === node, + }); +}; + +// todo: re-order url for consistency +export const insertLink = (editor: Editor, url: string, node: SlateElement | Selection) => { + // If selection is already a link, update the URL + if (isLinkElement(node)) { + Transforms.setNodes( + editor, + { url } as any, + { + match: n => n === node, + // setNodes defaults to current selection, but it likely changed since this was called. + // if user clicked through menu's to activate link. + // todo: investigate whether mutating the node's URL directly accomplishes this + at: [], + }) + return; + } + + const selection = node as Selection; + const isCollapsed = selection && Range.isCollapsed(selection) + + if (isCollapsed) { + // todo: This is for linkifying a focused point... does this even make sense? + // I think checking for collapsed vs expanding at a higher level might make + // more sense + const link: LinkElement = { + type: 'link', + url, + title: null, + children: [{ text: url }], + } + Transforms.insertNodes(editor, link, { at: selection! }) + } else { + const link: LinkElement = { + type: 'link', + url, + title: null, + children: [], + } + Transforms.wrapNodes(editor, link, { split: true, at: selection! }) + Transforms.collapse(editor, { edge: 'end', }) + } +} + +/** + * Encompass the link view and edit menu and associated listeners + */ +export function EditLinkMenus() { + const [isEditing, setEditingState] = useState(false); + const [isViewing, setIsViewing] = useState(false); + const [editUrl, setEditUrl] = useState('') + + // see setCachedSelection + // todo: investigate useRef (does not trigger render) instead of useState + const [cachedSelection, setCachedSelectionState] = useState(null); + + const editor = useSlate(); + const menu = useRef() + + // cache the text selection or link and conditionally toggle viewing menu + function setCachedSelection(to: LinkElement | Selection) { + setCachedSelectionState(to); + + // If existing link, enable the view menu + if (isLinkElement(to) && menu.current) { + const domNode = ReactEditor.toDOMNode(editor as ReactEditor, to); + const rect = domNode.getBoundingClientRect(); + + // The rect.top is relative to the window + // The menu's top is relative to the container (or maybe the body) + // So once we scroll we need to push the menu down by scrollY, in addition to its + // normal offset + menu.current.style.top = rect.top + 8 + rect.height + window.scrollY + 'px'; + menu.current.style.left = rect.left + 16 + window.scrollX + 'px'; + setIsViewing(true); + } else { + // Otherwise its highlighted regular text. Eventually we might enable the edit menu + // to add a new linke (as of now, you paste from clipboard onto selected text) + setIsViewing(false); + if (!menu.current) return; + menu.current.style.left = '-10000px'; + } + } + + // Close menus if user clicks or types in the document + useEffect(() => { + function handleClickOutside(event: Event) { + // todo: figure out types + if (menu.current && !menu.current.contains(event.target as any)) { + setIsViewing(false); + setEditing(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keypress", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keypress", handleClickOutside); + }; + }, [menu]); + + // toggle editing and preload or clean-up state + function setEditing(editing: boolean) { + // When exiting edit mode, unset the url form value + // Cancelling or Save completed + if (!editing) { + + setEditUrl(''); + + // Intent was to close menu, but the view state re-populates because I think the + // re-render pulls the prior selection out of editor.selection (even though there's no + // cursor in the UI) + // todo: Consider re-enabling viewing when existing editing... + setNull(); + } else { + setIsViewing(false); + + if (isLinkElement(cachedSelection)) { + setEditUrl(cachedSelection.url); + } + } + + setEditingState(editing); + } + + // conditionally nullify stored linkNode + // I feel like React did this value checking for you, but I got a loop shrug + function setNull() { + if (cachedSelection !== null) { + setCachedSelectionState(null); + // hide menu off screen + if (!menu.current) return; + menu.current.style.left = '-10000px'; + } + } + + function save() { + if (!cachedSelection) { + // todo: When we use the edit menu for new links this will need to work + // for now you can only add links by highlighting text and pasting from clipboard + console.error('save called but linkNode is null. Editing a new link? Need to implement!'); + return; + } + + // Validate URL + if (editUrl) { + // todo: if editUrl is blank, but linkNode is not... should ac + // create or replace a linkNode + insertLink(editor, editUrl, cachedSelection) + } else { + if (isLinkElement(cachedSelection)) { + removeLink(editor, cachedSelection); + } + } + + setEditing(false); + } + + function cancelEdit() { + setEditing(false); + } + + function unlink() { + if (!isLinkElement(cachedSelection)) return; + + // unwrap link node + removeLink(editor, cachedSelection) + } + + // conditionally cache the editor's selection + useEffect(() => { + // If already editing, stop tracking changes to what's selected + // and rely on the existing cached selection to be updated after editing is + // completed + if (isEditing || isViewing) return; + + // track the selected text so we know if a link is focused + if (editor.selection) { + const [node] = Editor.node(editor, editor.selection); + + + // calling Editor.parent on root (editor) node throws an exception + // This case also passes when I expand a selection beyond the URL in question however + if (Editor.isEditor(node)) { + setNull(); + return; + } + + const [parent] = Editor.parent(editor, editor.selection) + + if (isLinkElement(parent)) { + // Only update state if the element changed + if (cachedSelection !== parent) { + setCachedSelection(parent); + } + } else { + setCachedSelection(editor.selection) + } + } else { + setNull(); + } + }) + + function renderEditingForm() { + return ( +
+

Edit Link Form

+ setEditUrl(e.target.value)} + /> +
+ + +
+
+ ) + } + + // For viewing the URL and toggling edit for existing links + function renderViewingForm() { + return ( +
+

View Link Form

+

+ {isLinkElement(cachedSelection) && cachedSelection.url || ''} +

+
+ + +
+
+ ) + } + + // todo: fix ref type + // todo: review accessibility, set menu as "disabled" when its not in view + return ( +
+ {isEditing && renderEditingForm() || renderViewingForm() } +
+ ) +} \ No newline at end of file diff --git a/src/views/editor/blocks/markdown.tsx b/src/views/editor/blocks/markdown.tsx new file mode 100644 index 0000000..c5cb934 --- /dev/null +++ b/src/views/editor/blocks/markdown.tsx @@ -0,0 +1,165 @@ +import Prism from "prismjs"; +import React, { useCallback } from "react"; +import { Text } from "slate"; +import { css } from "emotion"; + +Prism.languages.markdown = Prism.languages.extend("markup", {}); +(Prism.languages as any).insertBefore("markdown", "prolog", { + blockquote: { pattern: /^>(?:[\t ]*>)*/m, alias: "punctuation" }, + code: [ + { pattern: /^(?: {4}|\t).+/m, alias: "keyword" }, + { pattern: /``.+?``|`[^`\n]+`/, alias: "keyword" }, + ], + title: [ + { + pattern: /\w+.*(?:\r?\n|\r)(?:==+|--+)/, + alias: "important", + inside: { punctuation: /==+$|--+$/ }, + }, + { + pattern: /(^\s*)#+.+/m, + lookbehind: !0, + alias: "important", + inside: { punctuation: /^#+|#+$/ }, + }, + ], + hr: { + pattern: /(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m, + lookbehind: !0, + alias: "punctuation", + }, + list: { + pattern: /(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m, + lookbehind: !0, + alias: "punctuation", + }, + "url-reference": { + pattern: + /!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/, + inside: { + variable: { pattern: /^(!?\[)[^\]]+/, lookbehind: !0 }, + string: /(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/, + punctuation: /^[\[\]!:]|[<>]/, + }, + alias: "url", + }, + bold: { + pattern: /(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/, + lookbehind: !0, + inside: { punctuation: /^\*\*|^__|\*\*$|__$/ }, + }, + italic: { + pattern: /(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/, + lookbehind: !0, + inside: { punctuation: /^[*_]|[*_]$/ }, + }, + url: { + pattern: + /!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/, + inside: { + variable: { pattern: /(!?\[)[^\]]+(?=\]$)/, lookbehind: !0 }, + string: { pattern: /"(?:\\.|[^"\\])*"(?=\)$)/ }, + }, + }, +}); +(Prism.languages.markdown.bold as any).inside.url = Prism.util.clone( + Prism.languages.markdown.url, +); +(Prism.languages.markdown.italic as any).inside.url = Prism.util.clone( + Prism.languages.markdown.url, +); +(Prism.languages.markdown.bold as any).inside.italic = Prism.util.clone( + Prism.languages.markdown.italic, +); +(Prism.languages.markdown.italic as any).inside.bold = Prism.util.clone( + Prism.languages.markdown.bold, +); + + +export const useDecorateMarkdown = () => { + return useCallback(([node, path]) => { + const ranges: any = []; + + if (!Text.isText(node)) { + return ranges; + } + + const getLength = (token: any) => { + if (typeof token === "string") { + return token.length; + } else if (typeof token.content === "string") { + return token.content.length; + } else { + return token.content.reduce((l: any, t: any) => l + getLength(t), 0); + } + }; + + const tokens = Prism.tokenize(node.text, Prism.languages.markdown); + let start = 0; + + for (const token of tokens) { + const length = getLength(token); + const end = start + length; + + if (typeof token !== "string") { + ranges.push({ + [token.type]: true, + anchor: { path, offset: start }, + focus: { path, offset: end }, + }); + } + + start = end; + } + + return ranges; + }, []); +} + +export const MarkdownLeaf = ({ attributes, children, leaf }: any) => { + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/views/editor/blocks/menu.tsx b/src/views/editor/blocks/menu.tsx new file mode 100644 index 0000000..a6cb876 --- /dev/null +++ b/src/views/editor/blocks/menu.tsx @@ -0,0 +1,117 @@ +import React, { PropsWithChildren, Ref } from 'react'; +import ReactDOM from 'react-dom'; +import { cx, css } from 'emotion'; +import { useRef, useEffect } from 'react'; +import { useSlate, ReactEditor } from 'slate-react'; +import { Editor, Range } from 'slate'; + +/** + * Contents started from: + * https://github.com/ianstormtaylor/slate/blob/main/site/components.tsx + * + * Partially used while developing another feature before rolling my own. + * Will revisit using these for the proper hovering toolbar. + */ + +interface BaseProps { + className: string + [key: string]: unknown +} + +type OrNull = T | null +type OrUndef = T | undefined; + +export const Menu = React.forwardRef( + ( + { className, ...props }: PropsWithChildren, + ref: Ref + ) => ( +
* { + display: inline-block; + } + & > * + * { + margin-left: 15px; + } + ` + )} + /> + ) +) + +export const Portal = ({ children }: PropsWithChildren) => { + return typeof document === 'object' + ? ReactDOM.createPortal(children, document.body) + : null +} + + + +export const HoveringToolbar = () => { + const ref = useRef() + const editor = useSlate() as ReactEditor; + + + + useEffect(() => { + const toolbar = ref.current + const { selection } = editor + + if (!toolbar) { + return + } + + if ( + !selection || + !ReactEditor.isFocused(editor as ReactEditor) || + Range.isCollapsed(selection) || + Editor.string(editor, selection) === '' + ) { + toolbar.removeAttribute('style') + return + } + + // todo: review whether doing this through Slate (like i do for links) is preferable + const domSelection = window.getSelection() + + // todo: handle null ref + const domRange = domSelection!.getRangeAt(0) + const rect = domRange.getBoundingClientRect() + toolbar.style.opacity = '1' + toolbar.style.top = `${rect.top + window.pageYOffset - toolbar.offsetHeight}px` + toolbar.style.left = `${rect.left + + window.pageXOffset - + toolbar.offsetWidth / 2 + + rect.width / 2}px` + }) + + return ( + + + {/* + + */} + + + ) +} \ No newline at end of file diff --git a/src/views/editor/editor.tsx b/src/views/editor/editor.tsx index 5ccb056..a22516c 100644 --- a/src/views/editor/editor.tsx +++ b/src/views/editor/editor.tsx @@ -1,83 +1,12 @@ -import Prism from "prismjs"; -import React, { useState, useCallback, useMemo, useEffect } from "react"; +import React, { useCallback, useMemo } from "react"; import { Slate, Editable, withReact, ReactEditor, RenderElementProps } from "slate-react"; -import { Text, createEditor, Node, Element } from "slate"; +import { createEditor, Node } from "slate"; import { withHistory } from "slate-history"; import { css } from "emotion"; -import { withImages } from './withImages'; -import { isImageElement, ImageElement } from './util'; - -Prism.languages.markdown = Prism.languages.extend("markup", {}); -(Prism.languages as any).insertBefore("markdown", "prolog", { - blockquote: { pattern: /^>(?:[\t ]*>)*/m, alias: "punctuation" }, - code: [ - { pattern: /^(?: {4}|\t).+/m, alias: "keyword" }, - { pattern: /``.+?``|`[^`\n]+`/, alias: "keyword" }, - ], - title: [ - { - pattern: /\w+.*(?:\r?\n|\r)(?:==+|--+)/, - alias: "important", - inside: { punctuation: /==+$|--+$/ }, - }, - { - pattern: /(^\s*)#+.+/m, - lookbehind: !0, - alias: "important", - inside: { punctuation: /^#+|#+$/ }, - }, - ], - hr: { - pattern: /(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m, - lookbehind: !0, - alias: "punctuation", - }, - list: { - pattern: /(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m, - lookbehind: !0, - alias: "punctuation", - }, - "url-reference": { - pattern: - /!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/, - inside: { - variable: { pattern: /^(!?\[)[^\]]+/, lookbehind: !0 }, - string: /(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/, - punctuation: /^[\[\]!:]|[<>]/, - }, - alias: "url", - }, - bold: { - pattern: /(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/, - lookbehind: !0, - inside: { punctuation: /^\*\*|^__|\*\*$|__$/ }, - }, - italic: { - pattern: /(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/, - lookbehind: !0, - inside: { punctuation: /^[*_]|[*_]$/ }, - }, - url: { - pattern: - /!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/, - inside: { - variable: { pattern: /(!?\[)[^\]]+(?=\]$)/, lookbehind: !0 }, - string: { pattern: /"(?:\\.|[^"\\])*"(?=\)$)/ }, - }, - }, -}); -(Prism.languages.markdown.bold as any).inside.url = Prism.util.clone( - Prism.languages.markdown.url, -); -(Prism.languages.markdown.italic as any).inside.url = Prism.util.clone( - Prism.languages.markdown.url, -); -(Prism.languages.markdown.bold as any).inside.italic = Prism.util.clone( - Prism.languages.markdown.italic, -); -(Prism.languages.markdown.italic as any).inside.bold = Prism.util.clone( - Prism.languages.markdown.bold, -); +import { withHelpers } from './withHelpers'; +import { isImageElement, isLinkElement, ImageElement } from './util'; +import { EditLinkMenus } from './blocks/links'; +import { useDecorateMarkdown, MarkdownLeaf } from './blocks/markdown'; export interface Props { saving: boolean; @@ -90,8 +19,16 @@ const renderElement = (props: RenderElementProps) => { const { attributes, children, element } = props // NOTE: This is being called constantly as text is selected, eww + // todo: I could use !isTypedElement, return early, then use a switch with + // type discrimination here to avoid the need for these type checking if (isImageElement(element)) { return + } else if (isLinkElement(element)) { + return ( + + {children} + + ) } else { return

{children}

} @@ -101,11 +38,8 @@ interface ImageElementProps extends RenderElementProps { element: ImageElement; } -const Image = ({ attributes, children, element }: ImageElementProps) => { - // Used in the example to conditionally set a drop shadow when image is hovered. - // const selected = useSelected() - // const focused = useFocused() +const Image = ({ attributes, children, element }: ImageElementProps) => { return (
@@ -123,50 +57,18 @@ const Image = ({ attributes, children, element }: ImageElementProps) => { ) } -const MarkdownPreviewExample = (props: Props) => { - const renderLeaf = useCallback((props) => , []); - // as ReactEditor fixes +/** + * Slate editor with all the fixins + */ +const FancyPantsEditor = (props: Props) => { + const renderLeaf = useCallback((props) => , []); + const decorateMarkdown = useDecorateMarkdown(); + + // todo: `as ReactEditor` fixes // Argument of type 'BaseEditor' is not assignable to parameter of type 'ReactEditor'. // Real fix is probably here: https://docs.slatejs.org/concepts/12-typescript - const editor = useMemo(() => withImages(withHistory(withReact(createEditor() as ReactEditor))), []); - const decorate = useCallback(([node, path]) => { - const ranges: any = []; - - if (!Text.isText(node)) { - return ranges; - } - - const getLength = (token: any) => { - if (typeof token === "string") { - return token.length; - } else if (typeof token.content === "string") { - return token.content.length; - } else { - return token.content.reduce((l: any, t: any) => l + getLength(t), 0); - } - }; - - const tokens = Prism.tokenize(node.text, Prism.languages.markdown); - let start = 0; - - for (const token of tokens) { - const length = getLength(token); - const end = start + length; - - if (typeof token !== "string") { - ranges.push({ - [token.type]: true, - anchor: { path, offset: start }, - focus: { path, offset: end }, - }); - } - - start = end; - } - - return ranges; - }, []); + const editor = useMemo(() => withHelpers(withHistory(withReact(createEditor() as ReactEditor))), []); return ( { value={props.value} onChange={(value) => props.setValue(value)} > + ); }; -const Leaf = ({ attributes, children, leaf }: any) => { - return ( - - {children} - - ); -}; - -export default MarkdownPreviewExample; +export default FancyPantsEditor; diff --git a/src/views/editor/useEditableDocument.ts b/src/views/editor/useEditableDocument.ts index 2a8ccfd..df07393 100644 --- a/src/views/editor/useEditableDocument.ts +++ b/src/views/editor/useEditableDocument.ts @@ -6,7 +6,8 @@ import { observable, autorun, toJS } from "mobx"; import { toaster } from "evergreen-ui"; import client, { Client } from "../../client"; import { Node } from "slate"; -import { SlateTransformer } from "./util"; +import { SlateTransformer, stringToMdast } from "./util"; +import { Root as MDASTRoot } from "mdast"; interface NewDocument { journalId: string; @@ -68,6 +69,11 @@ export function useEditableDocument(documentId: string) { const [loading, setLoading] = React.useState(true); const [loadingErr, setLoadingErr] = React.useState(null); + // For debugging, save the original text and conversions + const [rawOriginal, setRawOriginal] = React.useState(""); + const [mdastOriginal, setMdastOriginal] = React.useState(); + const [slateOriginal, setSlateOriginal] = React.useState(); + const setEditorValue = (v: Node[]) => { setDirty(true); setSlateContent(v); @@ -88,7 +94,14 @@ export function useEditableDocument(documentId: string) { if (!isEffectMounted) return; setDocState(new EditableDocument(client, doc)); - setSlateContent(SlateTransformer.nodify(toJS(doc.content))); + + // note: I can't remember if I _needed_ to call toJS here... + const content = toJS(doc.content); + const slateNodes = SlateTransformer.nodify(content); + setSlateContent(slateNodes); + setSlateOriginal(slateNodes); + setRawOriginal(content); + setMdastOriginal(stringToMdast.parse(content) as MDASTRoot); setDirty(false); setLoading(false); } catch (err) { @@ -112,6 +125,11 @@ export function useEditableDocument(documentId: string) { loading, loadingErr, docState, + initialState: { + raw: rawOriginal, + slate: slateOriginal, + mdast: mdastOriginal, + }, }; } diff --git a/src/views/editor/util.ts b/src/views/editor/util.ts index 6382ab7..fbab246 100644 --- a/src/views/editor/util.ts +++ b/src/views/editor/util.ts @@ -1,6 +1,6 @@ // https://github.com/inokawa/remark-slate-transformer/ import unified from "unified"; -import markdown from "remark-parse"; +import remarkParse from "remark-parse"; import stringify from "remark-stringify"; import { remarkToSlate, @@ -10,18 +10,27 @@ import { import { Element as SlateElement, Node as SlateNode } from "slate"; export const slateToString = unified().use(slateToRemark).use(stringify); -const stringToSlate = unified().use(markdown).use(remarkToSlate); -// export const slateToMdast = unified().use(slateToRemark); + +// Intermediate markdown parser, exported here so I could store the intermediate +// mdast state prior to parsing to Slate DOM for debugging purposes +export const stringToMdast = unified().use(remarkParse); +const stringToSlate = stringToMdast.use(remarkToSlate); /** * Helper to convert markdown text into Slate nodes, and vice versa */ export class SlateTransformer { + /** + * Convert raw text to a Slate DOM + */ static nodify(text: string): SlateNode[] { // Not sure which plugin adds result but its definitely there... return (stringToSlate.processSync(text) as any).result; } + /** + * Create an empty Slate DOM, intended for new empty documents. + */ static createEmptyNodes() { return [{ children: [{ text: "" }] }]; } @@ -52,6 +61,12 @@ export interface ImageElement extends TypedNode { // other properties too, like for label } +export interface LinkElement extends TypedNode { + type: "link"; + title: string | null; + url: string; +} + // Extend slates isElement check to also check that it has a "type" property, // which all custom elements will have // https://docs.slatejs.org/concepts/02-nodes#element @@ -63,6 +78,10 @@ export function isImageElement(node: any): node is ImageElement { return isTypedElement(node) && node.type === "image"; } +export function isLinkElement(node: any): node is LinkElement { + return isTypedElement(node) && node.type === "link"; +} + /** * Convert Slate DOM to MDAST for visualization. * TODO: This probably should be co-located with the ASTViewer @@ -79,3 +98,18 @@ export function slateToMdast(nodes: SlateNode[]) { 2 ); } + +/** + * Print the return value from a slate `Editor.nodes` (or comprable) call + */ +export function printNodes(nodes: any) { + // Slate's retrieval calls return a generator, and `Array.from` wasn't working + // Maybe spread syntax? + let results = []; + + for (const node of nodes) { + results.push(node); + } + + console.log(JSON.stringify(results, null, 2)); +} diff --git a/src/views/editor/withHelpers.test.tsx b/src/views/editor/withHelpers.test.tsx new file mode 100644 index 0000000..9f01755 --- /dev/null +++ b/src/views/editor/withHelpers.test.tsx @@ -0,0 +1,53 @@ + +describe('pasting text with images', function() { + test('image, text, image'); + test('text, image, text'); +}) + +/* + Example I was using while developing and discovering the tricky features here: + + Some text here +![Image description](/Users/me/notes/design/2020/04/attachments/Screen%20Shot%202020-04-20%20at%2010.43.18%20AM.png) +Ready to be gobbled up? + + + +Flip the above around so its image, text, image, for same effect. + */ + +describe('links', function() { + // There's currently a behavior where if a link is on its own line, and I try to start + // typing around it, it moves the cursor to the next line as though I pressed enter. + // Not sure what the issue is. + it('(regression) lets you type past a link when a link is on its own line') + it('replaces link when linking inside an existing link') + it('converts markdown text to link on paste') + + describe('viewing link', function() { + it('displays a pop-up view menu when a link (and only a link) is focused or highlighted') + it('clicking remove unlinks the link') + }) + + describe('existing link', function() { + it('opens view menu when an existing link is clicked') + it('lets you modify an existing link when clicking edit, saving updates the link') + it('clicking cancel after edit deselects the link') + it('todo: emptying the url box and hitting save unwraps the link') + }) + + describe('creating a new link', function() { + it('lets you paste a url over highlighted text to create a link') + it('opens the edit menu for selected text') + }) + + describe('menu open and close behaviors', function() { + it('opens when you click on a link') + it('does not open if you highlight beyond a links borderes') + it('does not open if you select multiple links') + // todo: But what if you click edit, then want to copy a url from the text? Hmmm. + it('closes both the view and edit menu when you click outside of it') + it('closes both the view and edit menu when you click cancel or remove') + it('closes both the view and edit menu when you _type_ outsdie of it') + }) +}) \ No newline at end of file diff --git a/src/views/editor/withHelpers.tsx b/src/views/editor/withHelpers.tsx new file mode 100644 index 0000000..aaef502 --- /dev/null +++ b/src/views/editor/withHelpers.tsx @@ -0,0 +1,119 @@ + + +import { Text, Transforms, Node as SlateNode, Range, Path as SlatePath, createEditor, Descendant, Editor, Element as SlateElement } from 'slate' +import { ReactEditor } from 'slate-react'; + +// todo: centralize these utilities -- they are also used in a few other places and can get out of sync +// which would produce weird bugs! +import unified from "unified"; +import markdown from "remark-parse"; +import remarkGfm from 'remark-gfm' +import { remarkToSlate, slateToRemark, mdastToSlate } from "remark-slate-transformer"; +const parser = unified().use(markdown).use(remarkGfm as any) +import { isTypedElement, isLinkElement } from './util'; +import { insertLink, urlMatcher } from './blocks/links'; + + +/** + * Image and link helpers. Maybe more. + * + * Will look at refactoring once I finish the first phase of wysiwyg work and understand it better. + * @param editor + * @returns + */ +export const withHelpers = (editor: ReactEditor) => { + const { isVoid, normalizeNode, isInline } = editor + + // If the element is an image type, make it non-editable + // https://docs.slatejs.org/concepts/02-nodes#voids + editor.isVoid = element => { + // type is a custom property + return (element as any).type === 'image' ? true : isVoid(element) + } + + // If links are not treated as inline, they'll be picked up by the unwrapping + // normalization step and turned into regular text + // todo: move to withLinks helper? + editor.isInline = element => { + return isLinkElement(element) ? true : isInline(element) + } + + // I was working on: type in markdown image text, hit enter, it shoudl convert to image + // but then thought... I always either paste in image urls OR drag and drop + // Then again...if I was going to paste an image, I could also paste it inside of a real markdown + // image tag... or infer it from an image url being pasted... but that could be annoying... + // ...I can see why Notion prompts you with a dropdown + // editor.insertBreak = () => { + // if (editor.selection?.focus.path) { + // // If the parent contains an image, but is _not_ an image node, turn it into one... + // const parentPath = SlatePath.parent(editor.selection.focus.path); + // const parentNode = SlateNode.get(editor, parentPath); + // } + + // insertBreak() + // } + + + // pasted data + editor.insertData = (data: DataTransfer) => { + const text = data.getData('text/plain'); + const { files } = data + + // todo: This is copy pasta from their official examples + // Implement it for real, once image uploading is decided upon + if (files && files.length > 0) { + for (const file of files) { + const reader = new FileReader() + const [mime] = file.type.split('/') + + if (mime === 'image') { + reader.addEventListener('load', () => { + const url = reader.result + // insertImage(editor, url); + }) + + reader.readAsDataURL(file) + } + } + } else if (text && text.match(urlMatcher)) { + // and isText? + insertLink(editor, text, editor.selection) + } else { + // NOTE: Calling this for all pasted data is quite experimental + // and will need to change. + convertAndInsert(editor, text) + } + } + + // Originally added to fix the case where an a mix of markdown image and text is copied, + // but because of markdown rules that require multiple newlines between paragraphs, + // slate was gobbling up images or text depending on the order + // todo: add test cases + // https://docs.slatejs.org/concepts/11-normalizing + editor.normalizeNode = entry => { + const [node, path] = entry; + + if (isTypedElement(node) && node.type === 'paragraph') { + for (const [child, childPath] of SlateNode.children(editor, path)) { + if (SlateElement.isElement(child) && !editor.isInline(child)) { + Transforms.unwrapNodes(editor, { at: childPath }) + return + } + } + } + + // Fall back to the original `normalizeNode` to enforce other constraints. + normalizeNode(entry) + } + + return editor +} + +/** + * Convert text to mdast -> SlateJSON, then insert into the document + */ +function convertAndInsert(editor: ReactEditor, text: string) { + const mdast = parser.parse(text); + const slateNodes = mdastToSlate(mdast as any) + Transforms.insertNodes(editor, slateNodes); +} diff --git a/src/views/editor/withImages.test.tsx b/src/views/editor/withImages.test.tsx deleted file mode 100644 index b7f8151..0000000 --- a/src/views/editor/withImages.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ - -describe('pasting text with images', function() { - test('image, text, image'); - test('text, image, text'); -}) - -/* - Example I was using while developing and discovering the tricky features here: - - Some text here -![Image description](/Users/me/notes/design/2020/04/attachments/Screen%20Shot%202020-04-20%20at%2010.43.18%20AM.png) -Ready to be gobbled up? - - - -Flip the above around so its image, text, image, for same effect. - */ \ No newline at end of file diff --git a/src/views/editor/withImages.tsx b/src/views/editor/withImages.tsx deleted file mode 100644 index ba3e5a1..0000000 --- a/src/views/editor/withImages.tsx +++ /dev/null @@ -1,238 +0,0 @@ - - -import { Transforms, Node as SlateNode, createEditor, Descendant, Editor, Element as SlateElement } from 'slate' -import { ReactEditor } from 'slate-react'; - -// todo: centralize these utilities -import unified from "unified"; -import markdown from "remark-parse"; -import remarkGfm from 'remark-gfm' -import { remarkToSlate, slateToRemark, mdastToSlate } from "remark-slate-transformer"; -const parser = unified().use(markdown).use(remarkGfm as any) -import { isTypedElement } from './util'; - - -export const withImages = (editor: ReactEditor) => { - const { insertData, isVoid, insertBreak, normalizeNode } = editor - - // If the element is an image type, make it non-editable - // https://docs.slatejs.org/concepts/02-nodes#voids - editor.isVoid = element => { - // type is a custom property - return (element as any).type === 'image' ? true : isVoid(element) - } - - // pasted data - editor.insertData = (data: DataTransfer) => { - const text = data.getData('text/plain'); - const { files } = data - - // todo: This is copy pasta from their official examples - // Implement it for real, once image uploading is decided upon - if (files && files.length > 0) { - for (const file of files) { - const reader = new FileReader() - const [mime] = file.type.split('/') - - if (mime === 'image') { - reader.addEventListener('load', () => { - const url = reader.result - // insertImage(editor, url); - }) - - reader.readAsDataURL(file) - } - } - } else { - // NOTE: Calling this for all pasted data is quite experimental - // and will need to change. - convertAndInsert(editor, text) - } - } - - // Originally added to fix the case where an a mix of markdown image and text is copied, - // but because of markdown rules that require multiple newlines between paragraphs, - // slate was gobbling up images or text depending on the order - // todo: add test cases - // https://docs.slatejs.org/concepts/11-normalizing - editor.normalizeNode = entry => { - const [node, path] = entry; - - // If the element is a paragraph, ensure its children are valid. - if (isTypedElement(node) && node.type === 'paragraph') { - for (const [child, childPath] of SlateNode.children(editor, path)) { - if (SlateElement.isElement(child) && !editor.isInline(child)) { - Transforms.unwrapNodes(editor, { at: childPath }) - return - } - } - } - - // Fall back to the original `normalizeNode` to enforce other constraints. - normalizeNode(entry) - } - - return editor -} - -// const insertImage = (editor, url) => { -// const text = { text: '' } -// const image: ImageElement = { type: 'image', url, children: [text] } -// Transforms.insertNodes(editor, image) -// } - -function isImageUrl(url: string) { - if (!url) return false; - - const mdast = parser.parse(url) - console.log(mdast); - console.log(mdastToSlate(mdast as any)) // expects Root, parser returns "Node" (its actually a root in my case) -} - -function convertAndInsert(editor: ReactEditor, text: string) { - const mdast = parser.parse(text); - const slateNodes = mdastToSlate(mdast as any) - console.log(mdast) - // const nodes: SlateNode[] = (slateNodes[0] as any).children; - console.log(slateNodes) - - // nodes.forEach(node => { - // Transforms.insertNodes(editor, node) - // }) - - Transforms.insertNodes(editor, slateNodes); -} - -// const isImageUrl = url => { -// if (!url) return false -// if (!isUrl(url)) return false -// const ext = new URL(url).pathname.split('.').pop() -// return imageExtensions.includes(ext) -// } -// https://cdn.shopify.com/s/files/1/3106/5828/products/IMG_9385_1024x1024@2x.jpg?v=1577795595 -// const imageExtensionRegex = - -// Copied from this repo: https://github.com/arthurvr/image-extensions -// Which is an npm package that is just a json file -const imageExtensions = [ - "ase", - "art", - "bmp", - "blp", - "cd5", - "cit", - "cpt", - "cr2", - "cut", - "dds", - "dib", - "djvu", - "egt", - "exif", - "gif", - "gpl", - "grf", - "icns", - "ico", - "iff", - "jng", - "jpeg", - "jpg", - "jfif", - "jp2", - "jps", - "lbm", - "max", - "miff", - "mng", - "msp", - "nitf", - "ota", - "pbm", - "pc1", - "pc2", - "pc3", - "pcf", - "pcx", - "pdn", - "pgm", - "PI1", - "PI2", - "PI3", - "pict", - "pct", - "pnm", - "pns", - "ppm", - "psb", - "psd", - "pdd", - "psp", - "px", - "pxm", - "pxr", - "qfx", - "raw", - "rle", - "sct", - "sgi", - "rgb", - "int", - "bw", - "tga", - "tiff", - "tif", - "vtf", - "xbm", - "xcf", - "xpm", - "3dv", - "amf", - "ai", - "awg", - "cgm", - "cdr", - "cmx", - "dxf", - "e2d", - "egt", - "eps", - "fs", - "gbr", - "odg", - "svg", - "stl", - "vrml", - "x3d", - "sxd", - "v2d", - "vnd", - "wmf", - "emf", - "art", - "xar", - "png", - "webp", - "jxr", - "hdp", - "wdp", - "cur", - "ecw", - "iff", - "lbm", - "liff", - "nrrd", - "pam", - "pcx", - "pgf", - "sgi", - "rgb", - "rgba", - "bw", - "int", - "inta", - "sid", - "ras", - "sun", - "tga" -] \ No newline at end of file