From d002f2ae3e5bd5253298ddd391f2c8d4102af8de Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 29 Oct 2019 18:22:42 -0700 Subject: [PATCH] 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"