From d002f2ae3e5bd5253298ddd391f2c8d4102af8de Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 29 Oct 2019 18:22:42 -0700 Subject: [PATCH 1/2] Initial draft of CodeContent component --- package.json | 1 + src/renderer/components/Root.tsx | 1 + .../components/content-types/CodeContent.css | 254 +++++++++++++++ .../components/content-types/CodeContent.tsx | 307 ++++++++++++++++++ yarn.lock | 5 + 5 files changed, 568 insertions(+) create mode 100644 src/renderer/components/content-types/CodeContent.css create mode 100644 src/renderer/components/content-types/CodeContent.tsx diff --git a/package.json b/package.json index 66b7c436..3540d838 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "base64-js": "^1.3.1", "bs58": "^4.0.1", "classnames": "^2.2.6", + "codemirror": "5.49.2", "data-urls": "^1.1.0", "diff-match-patch": "^1.0.4", "discovery-cloud-client": "^0.0.3", diff --git a/src/renderer/components/Root.tsx b/src/renderer/components/Root.tsx index 476ce5c3..99b89be2 100644 --- a/src/renderer/components/Root.tsx +++ b/src/renderer/components/Root.tsx @@ -20,6 +20,7 @@ import './content-types/storage-peer' // other single-context components import './content-types/TextContent' +import './content-types/CodeContent' import './content-types/ThreadContent' import './content-types/UrlContent' import './content-types/files/ImageContent' diff --git a/src/renderer/components/content-types/CodeContent.css b/src/renderer/components/content-types/CodeContent.css new file mode 100644 index 00000000..4fac2d82 --- /dev/null +++ b/src/renderer/components/content-types/CodeContent.css @@ -0,0 +1,254 @@ +.CodeMirrorEditor { + cursor: text; + text-align: left; + color: var(--colorBlack); + width: 100%; + height: 100%; + box-sizing: border-box; + background: white; + border: solid 1px var(--colorPaleGrey); + overflow: auto; +} +.CodeMirrorEditor:focus, +.CodeMirrorEditor:active { + outline: none; +} +.CodeMirrorEditor::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.CodeMirrorEditor::-webkit-scrollbar-track { + background-color: transparent; + margin-bottom: 10px; +} + +.CodeMirrorEditor::-webkit-scrollbar-thumb { + background-color: transparent; +} + +.CodeMirrorEditor:hover::-webkit-scrollbar-track { + background-color: #f3f3f3; +} + +.CodeMirrorEditor:hover::-webkit-scrollbar-thumb { + background-color: var(--colorPaleGrey); +} + +.CodeMirrorEditor::-webkit-scrollbar-thumb:hover { + background-color: #cbd5db; +} +.CodeMirrorEditor__editor { + box-sizing: border-box; + width: 100%; + padding: 10px; +} + +.CodeMirrorEditor div.CodeMirror { + height: auto; +} + +.CodeMirrorEditor div.CodeMirror-lines { + font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 20px; + padding: 0; +} + +.CodeMirrorEditor pre.CodeMirror-line { + padding: 0; +} + +.CodeMirrorEditor__renderer { + box-sizing: border-box; + width: 100%; + padding: 12px; +} + +.CodeMirrorEditor__renderer a { + color: #0645ad; + text-decoration: none; +} + +.CodeMirrorEditor__renderer a:visited { + color: #0b0080; +} + +.CodeMirrorEditor__renderer a:hover { + color: #06e; +} + +.CodeMirrorEditor__renderer a:active { + color: #faa700; +} + +.CodeMirrorEditor__renderer a:focus { + outline: thin dotted; +} + +.CodeMirrorEditor__renderer a:hover, +.CodeMirrorEditor__renderer a:active { + outline: 0; +} + +.CodeMirrorEditor__renderer h1, +.CodeMirrorEditor__renderer h2, +.CodeMirrorEditor__renderer h3, +.CodeMirrorEditor__renderer h4, +.CodeMirrorEditor__renderer h5, +.CodeMirrorEditor__renderer h6 { + font-weight: 600; + color: var(--colorBlueBlack); + margin-bottom: 8px; +} + +.CodeMirrorEditor__renderer h1 { + font-size: 18px; + line-height: 24px; + letter-spacing: -0.5px; +} + +.CodeMirrorEditor__renderer h2 { + font-size: 16px; + line-height: 20px; +} + +.CodeMirrorEditor__renderer h3 { + font-size: 12px; + line-height: 20px; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 600; +} + +.CodeMirrorEditor__renderer h4 { + color: var(--colorSecondaryGrey); + + font-weight: 600; +} + +.CodeMirrorEditor__renderer h5 { + font-size: 12px; + font-weight: 600; + color: var(--colorSecondaryGrey); +} + +.CodeMirrorEditor__renderer h6 { + font-weight: 600; +} + +.CodeMirrorEditor__renderer p { + font-size: 14px; + line-height: 20px; + margin-bottom: 8px; +} + +.CodeMirrorEditor__renderer blockquote { + color: var(--colorSecondaryGrey); + margin: 0; + padding-left: 12px; + border-left: 1px var(--colorSecondaryGrey) solid; +} + +.CodeMirrorEditor__renderer hr { + display: block; + border: 0; + border-top: 1px solid var(--colorInputGrey); + border-bottom: 1px solid var(--colorInputGrey); + margin: 1em 0; + padding: 0; +} + +.CodeMirrorEditor__renderer pre, +code, +kbd, +samp { + color: var(--colorBlack); + font-family: 'IBM Plex Mono', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', Courier, + monospace; + font-size: 12px; + line-height: 16px; + margin-bottom: 8px; +} + +.CodeMirrorEditor__renderer pre { + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; + tab-size: 2; + color: white; + background-color: var(--colorBlueBlack); + padding: 8px; + border-radius: 4px; +} + +.CodeMirrorEditor__renderer b, +strong { + font-weight: bold; +} + +.CodeMirrorEditor__renderer dfn { + font-style: italic; +} + +.CodeMirrorEditor__renderer ins { + background: #ff9; + color: var(--colorBlack); + text-decoration: none; +} + +.CodeMirrorEditor__renderer mark { + background: #ff0; + color: var(--colorBlack); + font-style: italic; + font-weight: bold; +} + +.CodeMirrorEditor__renderer sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.CodeMirrorEditor__renderer sup { + top: -0.5em; +} + +.CodeMirrorEditor__renderer sub { + bottom: -0.25em; +} + +.CodeMirrorEditor__renderer ul, +ol { + list-style-type: disc; + margin-left: 12px; + padding-left: 8px; + margin-bottom: 8px; +} + +.CodeMirrorEditor__renderer li p:last-child { + margin: 0; +} + +.CodeMirrorEditor__renderer dd { + margin: 0 0 0 2em; +} + +.CodeMirrorEditor__renderer table { + border-collapse: collapse; + border-spacing: 0; +} + +.CodeMirrorEditor__renderer td { + vertical-align: top; +} + +.CodeMirrorEditor__renderer :first-child { + margin-top: 0; +} + +.CodeMirrorEditor__renderer :last-child { + margin-bottom: 0; +} diff --git a/src/renderer/components/content-types/CodeContent.tsx b/src/renderer/components/content-types/CodeContent.tsx new file mode 100644 index 00000000..d8916d3c --- /dev/null +++ b/src/renderer/components/content-types/CodeContent.tsx @@ -0,0 +1,307 @@ +import React, { useRef, useEffect } from 'react' +import CodeMirror from 'codemirror' +import 'codemirror/addon/mode/loadmode' +import 'codemirror/mode/meta' +import '../../../../node_modules/codemirror/lib/codemirror.css' +import './CodeContent.css' +import DiffMatchPatch from 'diff-match-patch' +import Debug from 'debug' +import Automerge from 'automerge' +import { Handle } from 'hypermerge' +import ContentTypes from '../../ContentTypes' +import Badge from '../Badge' +import TitleEditor from '../TitleEditor' +import * as ContentData from '../../ContentData' +import { ContentProps } from '../Content' +import { useDocument, useStaticCallback } from '../../Hooks' + +const log = Debug('pushpin:code-mirror-editor') + +// This is plain text note component with inline editing. +// +// It's a tricky component because it needs to bridge the functional-reactive +// world of React with the imperative world of the CodeMirror editor, as well +// as some data model mismatch between the Automerge.Text property and the +// CodeMirror instance. +// +// Key ideas: +// * The Automerge.Text property and the CodeMirror editor are seperate state, +// but we sync them in both directions. It's not a pure one-way data flow +// as we have elsewhere in the app. +// * When the user does a local change in the editor, we pick that up and +// convert it into a corresponding Automerge.Text change. This causes +// updates to go out to other clients. +// * When we see an Automerge.Text update, we apply changes to the editor to +// converge the editor's contents to those indicated in the Automerge.Text. +// * Cursor state is managed only by CodeMirror. This means cursor state +// definetly remains correct when the user does local editing. Also when we +// apply remote ops to CodeMirror through its programtic editing APIs, the +// editor should automatically do the right thing with the user's cursor. +// +// This component is not "pure" in the literal sense. But PureComponent still +// seems to give the right caching behaviour, so for now we'll extend from it. + +interface CodeDoc { + title: string + text: Automerge.Text +} + +interface Props extends ContentProps { + uniquelySelected: boolean +} + +interface CodeMirrorProps { + text: Automerge.Text | null + title: string | null + selected?: boolean + change(cb: (doc: CodeDoc) => void): void +} + +CodeContent.minWidth = 6 +CodeContent.minHeight = 2 +CodeContent.defaultWidth = 12 +// no default height to allow it to grow +CodeContent.maxWidth = 24 +CodeContent.maxHeight = 36 + +CodeMirror.modeURL = 'codemirror/mode/%N/%N' + +export default function CodeContent(props: Props) { + const [doc, changeDoc] = useDocument(props.hypermergeUrl) + + const [ref] = useCodeMirror({ + text: doc && doc.text, + title: doc && doc.title, + selected: props.uniquelySelected, + change(cb) { + changeDoc((doc) => { + doc.text && cb(doc) + }) + }, + }) + + return ( +
+
+
+ ) +} + +function CodeInList(props: ContentProps) { + const [doc] = useDocument(props.hypermergeUrl) + function onDragStart(e: React.DragEvent) { + e.dataTransfer.setData('application/pushpin-url', props.url) + } + + if (!doc) return null + + return ( +
+ + + + +
+ ) +} + +function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { + const editorRef = useRef(null) + const codeMirrorRef = useRef(null) + const makeChange = useStaticCallback(change) + + log(title) + + useEffect(() => { + // Observe changes to the editor and make corresponding updates to the + // Automerge text. + function onCodeMirrorChange(codeMirror: CodeMirror, change: any) { + // We don't want to re-apply changes we already applied because of updates + // from Automerge. + if (change.origin === 'automerge') { + return + } + log('onCodeMirrorChange') + + // Convert from CodeMirror coordinate space to Automerge text/array API. + const at = codeMirror.indexFromPos(change.from) + const removedLength = change.removed.join('\n').length + const addedText: string = change.text.join('\n') + + makeChange(({ text }) => { + if (removedLength > 0) { + text.deleteAt!(at, removedLength) + } + + if (addedText.length > 0) { + text.insertAt!(at, ...addedText.split('')) + } + }) + } + + function onKeyDown(codeMirror: CodeMirror, e: React.KeyboardEvent) { + if (e.key !== 'Backspace') { + e.stopPropagation() + return + } + + // we normally prevent deletion by stopping event propagation + // but if the card is already empty and we hit delete, allow it + const text = codeMirror.getValue() + if (text.length !== 0) { + e.stopPropagation() + } + } + + // The props after `autofocus` are needed to get an editor that resizes + // according to the size of the text, without scrollbars or wrapping. + const codeMirror = CodeMirror(editorRef.current, { + autofocus: selected, + lineNumbers: false, + lineWrapping: true, + scrollbarStyle: 'null', + viewportMargin: Infinity, + }) + + codeMirrorRef.current = codeMirror + + codeMirror.on('change', onCodeMirrorChange) + codeMirror.on('keydown', onKeyDown) + + return () => { + codeMirror.off('change', onCodeMirrorChange) + codeMirror.off('keydown', onKeyDown) + } + }, []) + + // Transform updates from the Automerge text into imperative text changes + // in the editor. + useEffect(() => { + const codeMirror = codeMirrorRef.current + + // Short circuit if the text has not loaded yet. + if (!text || !codeMirror) { + return + } + + if (title) { + const offset = title.lastIndexOf('.') + const extension = offset >= 0 ? title.slice(offset + 1) : null + const info = extension && CodeMirror.findModeByExtension(extension) + const mode = codeMirror.getOption('mode') + if (info && info.mime !== mode) { + codeMirror.setOption('mode', info.mime) + CodeMirror.autoLoadMode(codeMirror, info.mode) + } + } + + // Short circuit if we don't need to apply any changes to the editor. This + // happens when we get a text update based on our own local edits. + const oldStr = codeMirror.getValue() + const newStr = text.join('') + if (oldStr === newStr) { + return + } + + // Otherwise find the diff between the current and desired contents, and + // apply corresponding editor ops to close them. + log('forceContents') + const dmp = new DiffMatchPatch() + const diff = dmp.diff_main(oldStr, newStr) + + // Buffer CM's dom updates + codeMirror.operation(() => { + // The diff lib doesn't give indicies so we need to compute them ourself as + // we go along. + for (let i = 0, at = 0; i < diff.length; i += 1) { + const [type, str] = diff[i] + + switch (type) { + case DiffMatchPatch.DIFF_EQUAL: { + at += str.length + break + } + + case DiffMatchPatch.DIFF_INSERT: { + const fromPos = codeMirror.posFromIndex(at) + codeMirror.replaceRange(str, fromPos, null, 'automerge') + at += str.length + break + } + + case DiffMatchPatch.DIFF_DELETE: { + const fromPos = codeMirror.posFromIndex(at) + const toPos = codeMirror.posFromIndex(at + str.length) + codeMirror.replaceRange('', fromPos, toPos, 'automerge') + break + } + + default: { + throw new Error(`Did not expect diff type ${type}`) + } + } + } + }) + }, [text]) + + // Ensure the CodeMirror editor is focused if we expect it to be. + useEffect(() => { + const codeMirror = codeMirrorRef.current + if (!codeMirror) { + return + } + + if (selected && !codeMirror.hasFocus()) { + log('ensureFocus.forceFocus') + codeMirror.focus() + } + }, [selected]) + + return [editorRef, codeMirrorRef.current] +} + +function stopPropagation(e: React.SyntheticEvent) { + e.stopPropagation() +} + +async function createFrom(contentData: ContentData.ContentData, handle: Handle, callback) { + const text = await ContentData.toString(contentData) + handle.change((doc) => { + const { name = '', extension } = contentData + doc.title = extension ? `${name}.${extension}` : name + doc.text = new Automerge.Text() + if (text) { + doc.text.insertAt!(0, ...text.split('')) + } + }) + callback() +} + +function create({ text, name, extension }, handle: Handle, callback) { + handle.change((doc) => { + doc.title = extension ? `${name}.${extension}` : name + doc.text = new Automerge.Text(text) + }) + + callback() +} + +const ICON = 'code' + +ContentTypes.register({ + type: 'code', + name: 'Code', + icon: ICON, + contexts: { + workspace: CodeContent, + board: CodeContent, + list: CodeInList, + }, + create, + createFrom, +}) diff --git a/yarn.lock b/yarn.lock index 08633cf7..30c5f1d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1986,6 +1986,11 @@ codecs@^2.0.0: resolved "https://registry.yarnpkg.com/codecs/-/codecs-2.0.0.tgz#680d1d4ac8ac3c8adbaa625c7ce06c6ee5792b50" integrity sha512-WXvpJRAgc693oqYvZte9uYEiL5YHtfrxyEq12uVny9oBJ1k37zSva5vVz7trsnt6R9Y15hEgOSC7VFZT2pfYnA== +codemirror@5.49.2: + version "5.49.2" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.49.2.tgz#c84fdaf11b19803f828b0c67060c7bc6d154ccad" + integrity sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ== + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" From 760984383a03ddb51162f29f3fa4e91a8b9c6f35 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 5 Nov 2019 13:51:34 -0800 Subject: [PATCH 2/2] Fix mode switching on title change --- .../components/content-types/CodeContent.css | 2 + .../components/content-types/CodeContent.tsx | 85 ++++++++++--------- 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/src/renderer/components/content-types/CodeContent.css b/src/renderer/components/content-types/CodeContent.css index 4fac2d82..bd54ec25 100644 --- a/src/renderer/components/content-types/CodeContent.css +++ b/src/renderer/components/content-types/CodeContent.css @@ -1,3 +1,5 @@ +@import '../../../../node_modules/codemirror/lib/codemirror.css'; + .CodeMirrorEditor { cursor: text; text-align: left; diff --git a/src/renderer/components/content-types/CodeContent.tsx b/src/renderer/components/content-types/CodeContent.tsx index d8916d3c..82cd4cc5 100644 --- a/src/renderer/components/content-types/CodeContent.tsx +++ b/src/renderer/components/content-types/CodeContent.tsx @@ -2,13 +2,12 @@ import React, { useRef, useEffect } from 'react' import CodeMirror from 'codemirror' import 'codemirror/addon/mode/loadmode' import 'codemirror/mode/meta' -import '../../../../node_modules/codemirror/lib/codemirror.css' import './CodeContent.css' import DiffMatchPatch from 'diff-match-patch' import Debug from 'debug' import Automerge from 'automerge' import { Handle } from 'hypermerge' -import ContentTypes from '../../ContentTypes' +import * as ContentTypes from '../../ContentTypes' import Badge from '../Badge' import TitleEditor from '../TitleEditor' import * as ContentData from '../../ContentData' @@ -43,7 +42,7 @@ const log = Debug('pushpin:code-mirror-editor') interface CodeDoc { title: string - text: Automerge.Text + source: Automerge.Text } interface Props extends ContentProps { @@ -51,7 +50,7 @@ interface Props extends ContentProps { } interface CodeMirrorProps { - text: Automerge.Text | null + source: Automerge.Text | null title: string | null selected?: boolean change(cb: (doc: CodeDoc) => void): void @@ -70,12 +69,12 @@ export default function CodeContent(props: Props) { const [doc, changeDoc] = useDocument(props.hypermergeUrl) const [ref] = useCodeMirror({ - text: doc && doc.text, + source: doc && doc.source, title: doc && doc.title, selected: props.uniquelySelected, change(cb) { changeDoc((doc) => { - doc.text && cb(doc) + doc.source && cb(doc) }) }, }) @@ -110,13 +109,11 @@ function CodeInList(props: ContentProps) { ) } -function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { +function useCodeMirror({ source, title, change, selected }: CodeMirrorProps) { const editorRef = useRef(null) const codeMirrorRef = useRef(null) const makeChange = useStaticCallback(change) - log(title) - useEffect(() => { // Observe changes to the editor and make corresponding updates to the // Automerge text. @@ -133,13 +130,13 @@ function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { const removedLength = change.removed.join('\n').length const addedText: string = change.text.join('\n') - makeChange(({ text }) => { + makeChange(({ source }) => { if (removedLength > 0) { - text.deleteAt!(at, removedLength) + source.deleteAt!(at, removedLength) } if (addedText.length > 0) { - text.insertAt!(at, ...addedText.split('')) + source.insertAt!(at, ...addedText.split('')) } }) } @@ -152,8 +149,8 @@ function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { // we normally prevent deletion by stopping event propagation // but if the card is already empty and we hit delete, allow it - const text = codeMirror.getValue() - if (text.length !== 0) { + const source = codeMirror.getValue() + if (source.length !== 0) { e.stopPropagation() } } @@ -184,26 +181,15 @@ function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { useEffect(() => { const codeMirror = codeMirrorRef.current - // Short circuit if the text has not loaded yet. - if (!text || !codeMirror) { + // Short circuit if the source has not loaded yet. + if (!source || !codeMirror) { return } - if (title) { - const offset = title.lastIndexOf('.') - const extension = offset >= 0 ? title.slice(offset + 1) : null - const info = extension && CodeMirror.findModeByExtension(extension) - const mode = codeMirror.getOption('mode') - if (info && info.mime !== mode) { - codeMirror.setOption('mode', info.mime) - CodeMirror.autoLoadMode(codeMirror, info.mode) - } - } - // Short circuit if we don't need to apply any changes to the editor. This - // happens when we get a text update based on our own local edits. + // happens when we get a source update based on our own local edits. const oldStr = codeMirror.getValue() - const newStr = text.join('') + const newStr = source.join('') if (oldStr === newStr) { return } @@ -247,7 +233,27 @@ function useCodeMirror({ text, title, change, selected }: CodeMirrorProps) { } } }) - }, [text]) + }, [source]) + + useEffect(() => { + const codeMirror = codeMirrorRef.current + + if (!codeMirror || !title) { + return + } + + const offset = title.lastIndexOf('.') + const extension = offset >= 0 ? title.slice(offset + 1) : null + const info = extension && CodeMirror.findModeByExtension(extension) + const mode = codeMirror.getOption('mode') + if (info && info.mime !== mode) { + codeMirror.setOption('mode', info.mime) + try { + CodeMirror.autoLoadMode(codeMirror, info.mode) + // eslint-disable-next-line no-empty + } catch (_) {} + } + }, [title]) // Ensure the CodeMirror editor is focused if we expect it to be. useEffect(() => { @@ -269,26 +275,23 @@ function stopPropagation(e: React.SyntheticEvent) { e.stopPropagation() } -async function createFrom(contentData: ContentData.ContentData, handle: Handle, callback) { - const text = await ContentData.toString(contentData) +async function createFrom(contentData: ContentData.ContentData, handle: Handle) { + const source = await ContentData.toString(contentData) handle.change((doc) => { - const { name = '', extension } = contentData + const { name = '', extension = null } = contentData doc.title = extension ? `${name}.${extension}` : name - doc.text = new Automerge.Text() - if (text) { - doc.text.insertAt!(0, ...text.split('')) + doc.source = new Automerge.Text() + if (source) { + doc.source.insertAt!(0, ...source.split('')) } }) - callback() } -function create({ text, name, extension }, handle: Handle, callback) { +function create({ source = '', name = 'note', extension = 'md' }, handle: Handle) { handle.change((doc) => { doc.title = extension ? `${name}.${extension}` : name - doc.text = new Automerge.Text(text) + doc.source = new Automerge.Text(source) }) - - callback() } const ICON = 'code'