@@ -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