diff --git a/.babelrc b/.babelrc index 5c0a565..85e5e8c 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,6 @@ { - "presets": ["@babel/env", "@babel/react"], - "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-transform-runtime"] + "presets": [ "@babel/preset-env", "@babel/preset-react"], + "plugins": [ + "@babel/plugin-proposal-class-properties" + ] } diff --git a/.gitignore b/.gitignore index 6f986ee..13f135f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ pids *.seed *.pid.lock +ext.json + # Directory for instrumented libs generated by jscoverage/JSCover lib-cov diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544138b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/README.md b/README.md index 6e64cb7..424ae4b 100644 --- a/README.md +++ b/README.md @@ -1 +1,154 @@ # Markdown Basic + +
+ +[![License](https://img.shields.io/github/license/sn-extensions/markdown-basic?color=blue)](https://github.com/sn-extensions/markdown-basic/blob/master/LICENSE) +[![GitHub issues and feature requests](https://img.shields.io/github/issues/sn-extensions/markdown-basic.svg)](https://github.com/sn-extensions/markdown-basic/issues/) +[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.org/slack) +[![GitHub Stars](https://img.shields.io/github/stars/sn-extensions/markdown-basic?style=social)](https://github.com/sn-extensions/markdown-basic) + +
+ +## Introduction + +Markdown Basic is a [custom editor](https://standardnotes.org/help/77/what-are-editors) for [Standard Notes](https://standardnotes.org), a free, open-source, and [end-to-end encrypted](https://standardnotes.org/knowledge/2/what-is-end-to-end-encryption) notes app. + +## Features + +- Markdown via Markdown-It +- Syntax Highlighting via Highlight.js +- Optional split pane view +- Task Lists +- Tables +- Footnotes +- Inline external images + +## Installation + +1. Register for an account at Standard Notes using the [Desktop App](https://standardnotes.org/download) or [Web app](https://app.standardnotes.org). Remember to use a strong and memorable password. +2. Sign up for [Standard Notes Extended](https://dashboard.standardnotes.org/member). Then, follow the instructions [here](https://standardnotes.org/help/29/how-do-i-install-extensions-once-i-ve-signed-up-for-extended) or continue. +3. Click **Extensions** in the lower left corner. +4. Under **Repository**, find **Markdown Basic**. +5. Click **Install**. +6. Close the **Extensions** pop-up. +7. At the top of your note, click **Editor**, then click **Markdown Basic**. +8. Click **Continue**, and you are done! + +After you have installed the editor on the web or desktop app, it will automatically sync to your [mobile app](https://standardnotes.org/download) after you log in. + +## Style Guide + +| Result | Markdown | +| :----------------- | :------------------------------------------- | +| **Bold** | \*\*text\*\* or \_\_text\_\_ | +| _Emphasize_ | \*text\* or \_text\_ | +| ~~Strike-through~~ | \~\~text\~\~ | +| Link | [text]\(http://) | +| Image | ![text]\(http://) | +| `Inline Code` | \`code\` | +| Code Block | \`\`\`language

code

\`\`\` | +| Unordered List | \* item

- item

+ item | +| Ordered List | 1. item | +| Task List | `- [ ] Task` or `- [x] Task` | +| Blockquote | \> quote | +| H1 | # Heading | +| H2 | ## Heading | +| H3 | ### Heading | +| H4 | #### Heading | +| Section Breaks | `---` or `***` | + +## Tables + +Colons can be used to align columns. +Copy this into your editor to see what it renders: + +```md +| Tables | Are | Cool | +| ------------------ | :-----------: | ------: | +| col 2 is | centered | \$149 | +| col 3 is | right-aligned | \$4.17 | +| privacy is | neat | \$2.48 | +| rows don't need to | be pretty | what? | +| the last line is | unnecessary | really? | +| one more | row | Yay! 😆 | +``` + +## Footnotes + +The Markdown Basic editor supports footnotes. The footnote links do not work properly on mobile. Copy this into your note to see how they're used: + +```md +You can create footnote references that are short[^1] or long.[^2] +You can also create them inline.^[which may be easier, +since you don't need to pick an identifier and move down to type the note] +The footnotes are automatically numbered at the bottom of your note, +but you'll need to manually number your superscripts. +Make sure to count your variable[^variable] footnotes.[^5] + +[^1]: Here's a footnote. +[^2]: Here’s a footnote with multiple blocks. + + Subsequent paragraphs are indented to show that they belong to the previous footnote. + + { eight spaces for some code } + + The whole paragraph can be indented, or just the first + line. In this way, multi-paragraph footnotes work like + multi-paragraph list items. + +This paragraph won’t be part of the footnote, because it +isn’t indented. + +[^variable]: The variable footnote is the fourth footnote. +[^5]: This is the fifth footnote. +``` + +#### Not yet available: + +- KaTeX +- Printing + +## Customization + +To use a custom font size for your editor and rendered text, add this to your note and specify your font size (16px is 12pt). + +```css + +``` + +To use a custom font for your editor, add this to your note: + +```css + +``` + +To use a custom font for your rendered text, add this to your note: + +```css + +``` + +## License + +[GNU Affero General Public License v3.0](https://github.com/sn-extensions/markdown-basic/blob/master/LICENSE) + +## Development + +The instructions for local setup can be found [here](https://docs.standardnotes.org/extensions/local-setup). All commands are performed in the root directory: + +1. Fork the [repository](https://github.com/sn-extensions/markdown-basic) on GitHub +2. [Clone](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) your fork of the repository +3. Type `cd markdown-basic` +4. Run `npm install` to locally install the packages in `package.json` +5. Create `ext.json` as shown [here](https://docs.standardnotes.org/extensions/local-setup) with `url: "http://localhost:8004/dist/index.html"`. Optionally, create your `ext.json` as a copy of `ext.json.sample`. +6. Install http-server using `sudo npm install -g http-server` +7. Start the server at `http://localhost:8004` using `npm run server` +8. Import the extension into the [web](https://app.standardnotes.org) or [desktop](https://standardnotes.org/download) app with `http://localhost:8004/ext.json`. +9. To build the editor, open another command window and run `npm run build`. For live builds, use `npm run watch`. You can also run `npm run start` and open the editor at `http://localhost:8080`. + +## Further Resources + +- [GitHub](https://github.com/sn-extensions/markdown-basic/) +- [Issues and Feature Requests](https://github.com/sn-extensions/markdown-basic/issues) +- [Standard Notes Slack](https://standardnotes.org/slack) (for connecting with the Standard Notes Community) +- [Standard Notes Help Files](https://standardnotes.org/help) (for issues related to Standard Notes but unrelated to this editor) diff --git a/app/App.js b/app/App.tsx similarity index 52% rename from app/App.js rename to app/App.tsx index 1a228c8..b214cba 100644 --- a/app/App.js +++ b/app/App.tsx @@ -1,8 +1,9 @@ -import React from 'react'; -import Home from './components/Home'; +import 'regenerator-runtime/runtime'; +import * as React from 'react'; +import { Home } from './components/Home'; export default class App extends React.Component { - constructor(props) { + constructor(props: any) { super(props); } diff --git a/app/components/Home.js b/app/components/Home.js deleted file mode 100644 index c3a4192..0000000 --- a/app/components/Home.js +++ /dev/null @@ -1,312 +0,0 @@ -import React from 'react'; -import ComponentManager from 'sn-components-api'; -const MarkdownIt = require('markdown-it'); - -const EditMode = 0; -const SplitMode = 1; -const PreviewMode = 2; - -export default class Home extends React.Component { - - constructor(props) { - super(props); - - this.modes = [ - { mode: EditMode, label: "Edit", css: "edit" }, - { mode: SplitMode, label: "Split", css: "split" }, - { mode: PreviewMode, label: "Preview", css: "preview" }, - ]; - - this.state = { mode: this.modes[0] }; - } - - componentDidMount() { - this.simpleMarkdown = document.getElementById("simple-markdown"); - this.editor = document.getElementById("editor"); - this.preview = document.getElementById("preview"); - - this.configureMarkdown(); - this.connectToBridge(); - this.updatePreviewText(); - this.addChangeListener(); - - this.configureResizer(); - this.addTabHandler(); - - this.scrollTriggers = {}; - this.scrollHandlers = [ - { el: this.editor, handler: this.scrollHandler(this.editor, this.preview) }, - { el: this.preview, handler: this.scrollHandler(this.preview, this.editor) } - ]; - } - - componentWillUpdate(nextProps, nextState) { - var prevMode = this.state.mode.mode; - var nextMode = nextState.mode.mode; - - // If we changed to Split mode we add the scroll listeners - if (prevMode !== nextMode) { - if (nextMode === SplitMode) { - this.addScrollListeners(); - } else { - this.removeScrollListeners(); - } - } - } - - setModeFromModeValue(value) { - for (var mode of this.modes) { - if (mode.mode == value) { - this.setState({ mode: mode }); - return; - } - } - } - - changeMode(mode) { - this.setState({ mode: mode }); - if (this.note) { - this.componentManager.setComponentDataValueForKey("mode", mode.mode); - } - } - - configureMarkdown() { - const markdownitOptions = { - // automatically render raw links as anchors. - linkify: true - }; - - this.markdown = MarkdownIt(markdownitOptions) - .use(require('markdown-it-footnote')) - .use(require('markdown-it-task-lists')) - .use(require('markdown-it-highlightjs')); - - // Remember old renderer, if overriden, or proxy to default renderer - const defaultRender = this.markdown.renderer.rules.link_open || function (tokens, idx, options, env, self) { - return self.renderToken(tokens, idx, options); - }; - - this.markdown.renderer.rules.link_open = function (tokens, idx, options, env, self) { - // If you are sure other plugins can't add `target` - drop check below - var aIndex = tokens[idx].attrIndex('target'); - - if (aIndex < 0) { - tokens[idx].attrPush(['target', '_blank']); // add new attribute - } else { - tokens[idx].attrs[aIndex][1] = '_blank'; // replace value of existing attr - } - - // pass token to default renderer. - return defaultRender(tokens, idx, options, env, self); - }; - } - - connectToBridge() { - var permissions = [ - { - name: "stream-context-item" - } - ] - - this.componentManager = new ComponentManager(permissions, () => { - var savedMode = this.componentManager.componentDataValueForKey("mode"); - if (savedMode) { - this.setModeFromModeValue(savedMode); - } - - this.setState({ platform: this.componentManager.platform }); - }); - - // this.componentManager.loggingEnabled = true; - - this.componentManager.streamContextItem((note) => { - this.note = note; - - // Only update UI on non-metadata updates. - if (note.isMetadataUpdate) { - return; - } - - this.editor.value = note.content.text; - this.preview.innerHTML = this.markdown.render(note.content.text); - }); - - } - - truncateString(string, limit = 80) { - if (!string) { - return null; - } - if (string.length <= limit) { - return string; - } else { - return string.substring(0, limit) + "..."; - } - } - - updatePreviewText() { - var text = this.editor.value || ""; - this.preview.innerHTML = this.markdown.render(text); - return text; - } - - addChangeListener() { - document.getElementById("editor").addEventListener("input", (event) => { - if (this.note) { - // Be sure to capture this object as a variable, as this.note may be reassigned in `streamContextItem`, so by the time - // you modify it in the presave block, it may not be the same object anymore, so the presave values will not be applied to - // the right object, and it will save incorrectly. - let note = this.note; - - this.componentManager.saveItemWithPresave(note, () => { - note.content.text = this.updatePreviewText(); - note.content.preview_plain = this.truncateString(this.preview.textContent || this.preview.innerText); - note.content.preview_html = null; - }); - } - }) - } - - addScrollListeners() { - this.scrollHandlers.forEach(({ el, handler }) => el.addEventListener('scroll', handler)); - } - - removeScrollListeners() { - this.scrollHandlers.forEach(({ el, handler }) => el.removeEventListener('scroll', handler)); - } - - scrollHandler = (source, destination) => { - var frameRequested; - - return (event) => { - // Avoid the cascading effect by not handling the event if it was triggered initially by this element - if (this.scrollTriggers[source] === true) { - this.scrollTriggers[source] = false; - return; - } - this.scrollTriggers[source] = true; - - // Only request the animation frame once until it gets processed - if (frameRequested) { - return; - } - frameRequested = true; - - window.requestAnimationFrame(() => { - var target = event.target - var height = target.scrollHeight - target.clientHeight; - var ratio = parseFloat(target.scrollTop) / height; - var move = (destination.scrollHeight - destination.clientHeight) * ratio; - destination.scrollTop = move; - - frameRequested = false; - }); - } - } - - removeSelection() { - if (window.getSelection) { - window.getSelection().removeAllRanges(); - } else if (document.selection) { - document.selection.empty(); - } - } - - configureResizer() { - var pressed = false; - var startWidth = this.editor.offsetWidth; - var startX; - var lastDownX; - - var columnResizer = document.getElementById("column-resizer"); - var resizerWidth = columnResizer.offsetWidth; - - var safetyOffset = 15; - - columnResizer.addEventListener("mousedown", (event) => { - pressed = true; - lastDownX = event.clientX; - columnResizer.classList.add("dragging"); - this.editor.classList.add("no-selection"); - }) - - document.addEventListener("mousemove", (event) => { - if (!pressed) { - return; - } - - var x = event.clientX; - if (x < resizerWidth / 2 + safetyOffset) { - x = resizerWidth / 2 + safetyOffset; - } else if (x > this.simpleMarkdown.offsetWidth - resizerWidth - safetyOffset) { - x = this.simpleMarkdown.offsetWidth - resizerWidth - safetyOffset; - } - - var colLeft = x - resizerWidth / 2; - columnResizer.style.left = colLeft + "px"; - this.editor.style.width = (colLeft - safetyOffset) + "px"; - - this.removeSelection(); - }) - - document.addEventListener("mouseup", (event) => { - if (pressed) { - pressed = false; - columnResizer.classList.remove("dragging"); - this.editor.classList.remove("no-selection"); - } - }) - } - - addTabHandler() { - // Tab handler - this.editor.addEventListener('keydown', function (event) { - if (!event.shiftKey && event.which == 9) { - event.preventDefault(); - - // Using document.execCommand gives us undo support - if (!document.execCommand("insertText", false, "\t")) { - // document.execCommand works great on Chrome/Safari but not Firefox - var start = this.selectionStart; - var end = this.selectionEnd; - var spaces = " "; - - // Insert 4 spaces - this.value = this.value.substring(0, start) - + spaces + this.value.substring(end); - - // Place cursor 4 spaces away from where - // the tab key was pressed - this.selectionStart = this.selectionEnd = start + 4; - } - } - }); - } - - render() { - return ( -
- - -
- -
-
-
-
- ) - } - -} diff --git a/app/components/Home.tsx b/app/components/Home.tsx new file mode 100644 index 0000000..781614d --- /dev/null +++ b/app/components/Home.tsx @@ -0,0 +1,367 @@ +import * as React from 'react'; +const { EditorKit, EditorKitDelegate } = require('sn-editor-kit'); +const MarkdownIt = require('markdown-it'); + +type Mode = { + type: ModeType; + label: string; + css: string; +}; + +enum ModeType { + Edit = 0, + Split = 1, + Preview = 2, +} + +enum MouseEvent { + Down = 'mousedown', + Move = 'mousemove', + Up = 'mouseup', +} + +enum HtmlElementId { + ColumnResizer = 'column-resizer', + Editor = 'editor', + EditorContainer = 'editor-container', + Header = 'header', + Preview = 'preview', + SimpleMarkdown = 'simple-markdown', +} + +enum CssClassList { + Dragging = 'dragging', + NoSelection = 'no-selection', +} + +enum ComponentDataKey { + Mode = 'mode', +} + +const modes = [ + { type: ModeType.Edit, label: 'Edit', css: 'edit' } as Mode, + { type: ModeType.Split, label: 'Split', css: 'split' } as Mode, + { type: ModeType.Preview, label: 'Preview', css: 'preview' } as Mode, +]; + +/** + * MarkdownIt Options: + * html option allows inline HTML + * linkify option automatically renders raw links as anchors + * breaks option creates new lines without trailing spaces + */ + +const MarkdownItOptions = { + html: true, + linkify: true, + breaks: true, +}; + +const MarkdownParser = MarkdownIt(MarkdownItOptions) + .use(require('markdown-it-footnote')) + .use(require('markdown-it-task-lists')) + .use(require('markdown-it-highlightjs')); + +type HomeState = { + text?: string; + mode: Mode; + platform?: string; +}; + +const debugMode = false; + +const keyMap = new Map(); + +export class Home extends React.Component<{}, HomeState> { + editorKit: any; + note: any; + + constructor(props: any) { + super(props); + this.state = { + mode: modes[0], + text: '', + }; + } + + componentDidMount = () => { + this.configureEditorKit(); + this.configureMarkdown(); + this.configureResizer(); + }; + + truncateString = (string: string, limit = 80) => { + if (!string) { + return null; + } + if (string.length <= limit) { + return string; + } else { + return string.substring(0, limit) + '...'; + } + }; + + configureEditorKit() { + const delegate = new EditorKitDelegate({ + setEditorRawText: (text: string) => { + this.setState( + { + text, + }, + () => { + this.updatePreviewText(); + this.loadSavedMode(); + } + ); + }, + clearUndoHistory: () => { }, + getElementsBySelector: () => [] as any, + generateCustomPreview: (text: string) => { + this.updatePreviewText(); + const preview = document.getElementById(HtmlElementId.Preview); + const previewText = this.truncateString( + preview.textContent || preview.innerText + ); + return { + html: `
${previewText}
`, + plain: previewText, + }; + }, + }); + + this.editorKit = new EditorKit({ + delegate: delegate, + mode: 'plaintext', + supportsFilesafe: false, + }); + } + + saveNote = () => { + this.editorKit.onEditorValueChanged(this.state.text); + }; + + handleInputChange = (event: React.ChangeEvent) => { + const target = event.target; + const value = target.value; + + this.setState( + { + text: value, + }, + () => { + this.saveNote(); + this.updatePreviewText(); + } + ); + }; + + loadSavedMode = () => { + try { + const savedMode = this.editorKit.internal.componentManager.componentDataValueForKey( + ComponentDataKey.Mode + ); + if (savedMode) { + this.setModeFromModeType(savedMode); + } + this.setState({ + platform: this.editorKit.internal.componentManager.platform, + }); + } catch { } + }; + + setModeFromModeType = (value: ModeType) => { + for (const mode of modes) { + if (mode.type === value) { + this.logDebugMessage('setModeFromModeType mode: ', mode.type); + this.setState( + { + mode, + }, + () => { + this.updatePreviewText(); + } + ); + return; + } + } + }; + + changeMode = (mode: Mode) => { + this.setState( + { + mode, + }, + () => { + this.updatePreviewText(); + } + ); + this.logDebugMessage('changeMode mode: ', mode.type); + this.editorKit.internal.componentManager.setComponentDataValueForKey( + ComponentDataKey.Mode, + mode.type + ); + }; + + configureMarkdown = () => { + // Remember old renderer, if overriden, or proxy to default renderer + const defaultRender = + MarkdownParser.renderer.rules.link_open || + function (tokens: any, idx: any, options: any, env: any, self: any) { + return self.renderToken(tokens, idx, options); + }; + + MarkdownParser.renderer.rules.link_open = function ( + tokens: any, + idx: any, + options: any, + env: any, + self: any + ) { + // If you are sure other plugins can't add `target` - drop check below + const aIndex = tokens[idx].attrIndex('target'); + if (aIndex < 0) { + tokens[idx].attrPush(['target', '_blank']); // add new attribute + } else { + tokens[idx].attrs[aIndex][1] = '_blank'; // replace value of existing attr + } + // pass token to default renderer. + return defaultRender(tokens, idx, options, env, self); + }; + }; + + updatePreviewText = () => { + const preview = document.getElementById(HtmlElementId.Preview); + if (preview) { + preview.innerHTML = MarkdownParser.render(this.state.text); + } + }; + + removeSelection = () => { + if (window.getSelection) { + window.getSelection().removeAllRanges(); + } + }; + + configureResizer = () => { + const simpleMarkdown = document.getElementById( + HtmlElementId.SimpleMarkdown + ); + const editor = document.getElementById(HtmlElementId.Editor); + const columnResizer = document.getElementById(HtmlElementId.ColumnResizer); + let pressed = false; + let lastDownX; + + let resizerWidth = columnResizer.offsetWidth; + let safetyOffset = 15; + + columnResizer.addEventListener(MouseEvent.Down, (event) => { + pressed = true; + lastDownX = event.clientX; + columnResizer.classList.add(CssClassList.Dragging); + editor.classList.add(CssClassList.NoSelection); + }); + + document.addEventListener(MouseEvent.Move, (event) => { + if (!pressed) { + return; + } + + let x = event.clientX; + if (x < resizerWidth / 2 + safetyOffset) { + x = resizerWidth / 2 + safetyOffset; + } else if (x > simpleMarkdown.offsetWidth - resizerWidth - safetyOffset) { + x = simpleMarkdown.offsetWidth - resizerWidth - safetyOffset; + } + + const colLeft = x - resizerWidth / 2; + columnResizer.style.left = colLeft + 'px'; + editor.style.width = colLeft - safetyOffset + 'px'; + + this.removeSelection(); + }); + + document.addEventListener(MouseEvent.Up, (event) => { + if (pressed) { + pressed = false; + columnResizer.classList.remove(CssClassList.Dragging); + editor.classList.remove(CssClassList.NoSelection); + } + }); + }; + + onKeyDown = (event: React.KeyboardEvent) => { + keyMap.set(event.key, true); + if (!keyMap.get('Shift') && keyMap.get('Tab')) { + event.preventDefault(); + document.execCommand('insertText', false, '\t'); + } + }; + + onKeyUp = (event: React.KeyboardEvent) => { + keyMap.delete(event.key); + }; + + onBlur = () => { + keyMap.clear(); + }; + + logDebugMessage = (message: string, object: any) => { + if (debugMode) { + console.log(message, object); + } + }; + + render() { + return ( +
+
+
+
+ {modes.map((mode) => ( + + ))} +
+
+
+
+