diff options
-rw-r--r-- | css/prosemirror.scss | 64 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/EditorFactory.js | 31 | ||||
-rw-r--r-- | src/components/EditorWrapper.vue | 28 | ||||
-rw-r--r-- | src/components/MenuBar.vue | 6 | ||||
-rw-r--r-- | src/files.js | 16 | ||||
-rw-r--r-- | src/nodes/PlainTextDocument.js | 36 | ||||
-rw-r--r-- | src/nodes/index.js | 4 | ||||
-rw-r--r-- | src/tests/markdown.spec.js (renamed from src/tests/index.spec.js) | 0 | ||||
-rw-r--r-- | src/tests/plaintext.spec.js | 78 |
10 files changed, 245 insertions, 19 deletions
diff --git a/css/prosemirror.scss b/css/prosemirror.scss index 69d3f71da..6d6f45423 100644 --- a/css/prosemirror.scss +++ b/css/prosemirror.scss @@ -1,5 +1,5 @@ /* Document rendering styles */ -.ProseMirror { +#editor-wrapper .ProseMirror { margin-top: 44px; height: 100%; position: relative; @@ -133,3 +133,65 @@ .ProseMirror-focused .ProseMirror-gapcursor { display: block; } +#editor-wrapper:not(.richEditor) .ProseMirror { + pre { + background-color: var(--color-main-background); + + &::before { + content: attr(data-language); + text-transform: uppercase; + display: block; + text-align: right; + font-weight: bold; + font-size: 0.6rem; + } + code { + .hljs-comment, + .hljs-quote { + color: #999999; + } + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f2777a; + } + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #f99157; + } + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #99cc99; + } + .hljs-title, + .hljs-section { + color: #ffcc66; + } + .hljs-keyword, + .hljs-selector-tag { + color: #6699cc; + } + .hljs-emphasis { + font-style: italic; + } + .hljs-strong { + font-weight: 700; + } + } + } +} + + diff --git a/package.json b/package.json index 528610549..6f224a804 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "NODE_ENV=test jest --coverage" }, "dependencies": { + "highlight.js": "^9.15.8", "nextcloud-axios": "^0.2.0", "nextcloud-server": "^0.15.10", "nextcloud-vue": "^0.11.4", diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 3bd7b27fe..2026a56c8 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -19,7 +19,7 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -import { Editor } from 'tiptap' +import { Editor, Text } from 'tiptap' import { HardBreak, Heading, @@ -30,15 +30,18 @@ import { ListItem, Blockquote, CodeBlock, + CodeBlockHighlight, HorizontalRule, History } from 'tiptap-extensions' import { Strong, Italic, Strike } from './marks' -import { Image } from './nodes' +import { Image, PlainTextDocument } from './nodes' import MarkdownIt from 'markdown-it' import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown' - +import cpp from 'highlight.js/lib/languages/cpp' +import javascript from 'highlight.js/lib/languages/javascript' +import css from 'highlight.js/lib/languages/css' const createEditor = ({ content, onUpdate, extensions, enableRichEditing }) => { let richEditingExtensions = [] if (enableRichEditing) { @@ -48,6 +51,7 @@ const createEditor = ({ content, onUpdate, extensions, enableRichEditing }) => { new Strong(), new Italic(), new Strike(), + new HardBreak(), new HorizontalRule(), new BulletList(), new OrderedList(), @@ -57,16 +61,26 @@ const createEditor = ({ content, onUpdate, extensions, enableRichEditing }) => { new Link(), new Image() ] + } else { + richEditingExtensions = [ + new PlainTextDocument(), + new Text(), + new CodeBlockHighlight({ + languages: { + cpp, css, javascript + } + }) + ] } extensions = extensions || [] return new Editor({ content: content, onUpdate: onUpdate, extensions: [ - new HardBreak(), ...richEditingExtensions, new History() - ].concat(extensions) + ].concat(extensions), + useBuiltInExtensions: enableRichEditing }) } @@ -95,5 +109,10 @@ const createMarkdownSerializer = (_nodes, _marks) => { ) } +const serializePlainText = (tiptap) => { + const doc = tiptap.getHTML().replace(/<[^>]*>?/gm, '') + return new DOMParser().parseFromString(doc, 'text/html').body.textContent +} + export default createEditor -export { markdownit, createEditor, createMarkdownSerializer } +export { markdownit, createEditor, createMarkdownSerializer, serializePlainText } diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index dcbe57b18..21b68a0ee 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -27,9 +27,10 @@ {{ t('text', 'The document has been changed outside of the editor. The changes cannot be applied.') }} </p> </div> - <div v-if="currentSession && active" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading}"> + <div v-if="currentSession && active" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading, 'richEditor': isRichEditor}"> <div id="editor"> - <menu-bar v-if="!syncError && !readOnly" ref="menubar" :editor="tiptap"> + <menu-bar v-if="!syncError && !readOnly" ref="menubar" :editor="tiptap" + :is-rich-editor="isRichEditor"> <div v-if="currentSession && active" id="editor-session-list"> <div v-tooltip="lastSavedStatusTooltip" class="save-status" :class="lastSavedStatusClass"> {{ lastSavedStatus }} @@ -39,7 +40,7 @@ </session-list> </div> </menu-bar> - <menu-bubble v-if="!readOnly" :editor="tiptap" /> + <menu-bubble v-if="!readOnly && isRichEditor" :editor="tiptap" /> <editor-content v-show="initialLoading" class="editor__content" :editor="tiptap" /> </div> <read-only-editor v-if="hasSyncCollission" :content="syncError.data.outsideChange" /> @@ -54,7 +55,7 @@ import Vue from 'vue' import { SyncService, ERROR_TYPE } from './../services/SyncService' import { endpointUrl, getRandomGuestName } from './../helpers' -import { createEditor, markdownit, createMarkdownSerializer } from './../EditorFactory' +import { createEditor, markdownit, createMarkdownSerializer, serializePlainText } from './../EditorFactory' import { EditorContent } from 'tiptap' import { Collaboration } from 'tiptap-extensions' @@ -172,6 +173,9 @@ export default { }, isPublic() { return document.getElementById('isPublic') && document.getElementById('isPublic').value === '1' + }, + isRichEditor() { + return this.mime === 'text/markdown' } }, watch: { @@ -218,9 +222,15 @@ export default { guestName, forceRecreate: this.forceRecreate, serialize: (document) => { - const markdown = (createMarkdownSerializer(this.tiptap.nodes, this.tiptap.marks)).serialize(document) - console.debug('serialized document', { markdown }) - return markdown + if (this.isRichEditor) { + const markdown = (createMarkdownSerializer(this.tiptap.nodes, this.tiptap.marks)).serialize(document) + console.debug('serialized document', { markdown }) + return markdown + } + const file = serializePlainText(this.tipta) + console.debug('serialized document', { file }) + return file + } }) .on('opened', ({ document, session }) => { @@ -243,7 +253,7 @@ export default { }) .on('loaded', ({ documentSource }) => { this.tiptap = createEditor({ - content: markdownit.render(documentSource), + content: this.isRichEditor ? markdownit.render(documentSource) : '<pre>' + window.escapeHTML(documentSource) + '</pre>', onUpdate: ({ state }) => { this.syncService.state = state }, @@ -268,7 +278,7 @@ export default { } }) ], - enableRichEditing: true + enableRichEditing: this.isRichEditor }) this.syncService.state = this.tiptap.state }) diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index de29da886..798921977 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -23,7 +23,7 @@ <template> <editor-menu-bar v-slot="{ commands, isActive }" :editor="editor"> <div class="menubar"> - <div ref="menubar" class="menubar-icons"> + <div v-if="isRichEditor" ref="menubar" class="menubar-icons"> <template v-for="(icon, $index) in allIcons"> <button v-if="icon.class" v-show="$index < iconCount" :key="icon.label" :title="icon.label" @@ -91,6 +91,10 @@ export default { type: Object, required: false, default: null + }, + isRichEditor: { + type: Boolean, + default: true } }, data: () => { diff --git a/src/files.js b/src/files.js index 8d0fc2f93..a25cc8791 100644 --- a/src/files.js +++ b/src/files.js @@ -26,6 +26,20 @@ import { registerFileActionFallback, registerFileCreate } from './helpers' __webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line __webpack_public_path__ = OC.linkTo('text', 'js/') // eslint-disable-line +const supportedTextMimeTypes = [ + 'text/plain', + 'application/cmd', + 'application/javascript', + 'application/json', + 'application/xml', + 'application/x-empty', + 'application/x-msdos-program', + 'application/x-php', + 'application/x-pearl', + 'application/x-text', + 'application/yaml' +] + document.addEventListener('DOMContentLoaded', () => { if (typeof OCA.Viewer === 'undefined') { console.error('Viewer app is not installed') @@ -34,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => { } OCA.Viewer.registerHandler({ id: 'text', - mimes: ['text/markdown', 'text/plain'], + mimes: ['text/markdown', ...supportedTextMimeTypes], component: FilesEditor, group: null }) diff --git a/src/nodes/PlainTextDocument.js b/src/nodes/PlainTextDocument.js new file mode 100644 index 000000000..aa169d0d4 --- /dev/null +++ b/src/nodes/PlainTextDocument.js @@ -0,0 +1,36 @@ +/* + * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { Node } from 'tiptap' +export default class PlainTextDocument extends Node { + + get name() { + return 'doc' + } + + get schema() { + return { + content: 'block' + } + } + +} diff --git a/src/nodes/index.js b/src/nodes/index.js index b51a01d03..79b5d4ebd 100644 --- a/src/nodes/index.js +++ b/src/nodes/index.js @@ -21,7 +21,9 @@ */ import Image from './Image' +import PlainTextDocument from './PlainTextDocument' export { - Image + Image, + PlainTextDocument } diff --git a/src/tests/index.spec.js b/src/tests/markdown.spec.js index b0d33e341..b0d33e341 100644 --- a/src/tests/index.spec.js +++ b/src/tests/markdown.spec.js diff --git a/src/tests/plaintext.spec.js b/src/tests/plaintext.spec.js new file mode 100644 index 000000000..6b9b54a63 --- /dev/null +++ b/src/tests/plaintext.spec.js @@ -0,0 +1,78 @@ +import { markdownit, createEditor, createMarkdownSerializer, serializePlainText } from './../EditorFactory'; +import spec from "./fixtures/spec" + +const markdownToDocument = (markdown) => { + const tiptap = createEditor({ + content: markdownit.render(markdown), + enableRichEditing: false + }) + const serializer = createMarkdownSerializer(tiptap.nodes, tiptap.marks) + return tiptap.state.doc +} + +const plaintextThroughEditor = (markdown) => { + const content = `<pre>${markdown}</pre>` + const tiptap = createEditor({ + content: content, + enableRichEditing: false + }) + return serializePlainText(tiptap) || '' +} + +describe('Commonmark', () => { + beforeAll(() => { + // Make sure html tests pass + // entry.section === 'HTML blocks' || entry.section === 'Raw HTML' + markdownit.set({ html: true}) + }) + afterAll(() => { + markdownit.set({ html: false}) + }) + + // failures because of some additional newline in markdownit + const skippedMarkdownTests = [ + 181, 202, 203 + ]; + + spec.forEach((entry) => { + if (skippedMarkdownTests.indexOf(entry.example) !== -1) { + return + } + test('commonmark ' + entry.example, () => { + expect(markdownit.render(entry.markdown)).toBe(entry.html, entry) + }) + }) +}) + +describe('Markdown though editor', () => { + test('headlines', () => { + expect(plaintextThroughEditor('# Test')).toBe('# Test') + expect(plaintextThroughEditor('## Test')).toBe('## Test') + expect(plaintextThroughEditor('### Test')).toBe('### Test') + expect(plaintextThroughEditor('#### Test')).toBe('#### Test') + expect(plaintextThroughEditor('##### Test')).toBe('##### Test') + }) + test('inline format', () => { + expect(plaintextThroughEditor('**Test**')).toBe('**Test**') + expect(plaintextThroughEditor('__Test__')).toBe('__Test__') + expect(plaintextThroughEditor('_Test_')).toBe('_Test_') + expect(plaintextThroughEditor('~~Test~~')).toBe('~~Test~~') + }) + test('ul', () => { + expect(plaintextThroughEditor('- foo\n- bar')).toBe('- foo\n- bar') + expect(plaintextThroughEditor('- foo\n\n- bar')).toBe('- foo\n\n- bar') + expect(plaintextThroughEditor('- foo\n\n\n- bar')).toBe('- foo\n\n\n- bar') + }) + test('ol', () => { + expect(plaintextThroughEditor('1. foo\n2. bar')).toBe('1. foo\n2. bar') + }) + test('paragraph', () => { + expect(plaintextThroughEditor('foo\nbar\n\nfoobar\n\tfoobar')).toBe('foo\nbar\n\nfoobar\n\tfoobar') + }) + test('links', () => { + expect(plaintextThroughEditor('[test](foo)')).toBe('[test](foo)') + }) + test('images', () => { + expect(plaintextThroughEditor('![test](foo)')).toBe('![test](foo)') + }) +}) |