diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
4 files changed, 93 insertions, 36 deletions
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 75d8581890f..514ab9699bc 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,5 +1,3 @@ -import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; - /* eslint-disable no-underscore-dangle */ export class ContentEditor { constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) { @@ -20,14 +18,19 @@ export class ContentEditor { } get changed() { - return this._pristineDoc?.eq(this.tiptapEditor.state.doc); + if (!this._pristineDoc) { + return !this.empty; + } + + return !this._pristineDoc.eq(this.tiptapEditor.state.doc); } get empty() { - const doc = this.tiptapEditor?.state.doc; + return this.tiptapEditor.isEmpty; + } - // Makes sure the document has more than one empty paragraph - return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); + get editable() { + return this.tiptapEditor.isEditable; } dispose() { @@ -55,24 +58,22 @@ export class ContentEditor { return this._assetResolver.renderDiagram(code, language); } + setEditable(editable = true) { + this._tiptapEditor.setOptions({ + editable, + }); + } + async setSerializedContent(serializedContent) { - const { _tiptapEditor: editor, _eventHub: eventHub } = this; + const { _tiptapEditor: editor } = this; const { doc, tr } = editor.state; - try { - eventHub.$emit(LOADING_CONTENT_EVENT); - const { document } = await this.deserialize(serializedContent); - - if (document) { - this._pristineDoc = document; - tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); - editor.view.dispatch(tr); - } + const { document } = await this.deserialize(serializedContent); - eventHub.$emit(LOADING_SUCCESS_EVENT); - } catch (e) { - eventHub.$emit(LOADING_ERROR_EVENT, e); - throw e; + if (document) { + this._pristineDoc = document; + tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); + editor.view.dispatch(tr); } } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 7a289df94ea..5ed7f3dc23d 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -127,7 +127,7 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, - PasteMarkdown, + PasteMarkdown.configure({ eventHub, renderMarkdown }), Reference, ReferenceDefinition, Sourcemap, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 472a0a4815b..ba0cad6c91c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -108,7 +108,10 @@ const defaultSerializerConfig = { }, nodes: { - [Audio.name]: renderPlayable, + [Audio.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [Blockquote.name]: preserveUnchanged((state, node) => { if (node.attrs.multiline) { state.write('>>>'); @@ -123,7 +126,7 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Diagram.name]: renderCodeBlock, + [Diagram.name]: preserveUnchanged(renderCodeBlock), [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); @@ -203,10 +206,10 @@ const defaultSerializerConfig = { }, overwriteSourcePreservationStrategy: true, }), - [TableOfContents.name]: (state, node) => { + [TableOfContents.name]: preserveUnchanged((state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); - }, + }), [Table.name]: preserveUnchanged(renderTable), [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, @@ -220,7 +223,10 @@ const defaultSerializerConfig = { else renderBulletList(state, node); }), [Text.name]: defaultMarkdownSerializer.nodes.text, - [Video.name]: renderPlayable, + [Video.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [WordBreak.name]: (state) => state.write('<wbr>'), ...HTMLNodes.reduce((serializers, htmlNode) => { return { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8a15633708f..ca290efca11 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,7 +1,10 @@ import { render } from '~/lib/gfm'; import { isValidAttribute } from '~/lib/dompurify'; +import { SAFE_AUDIO_EXT, SAFE_VIDEO_EXT, DIAGRAM_LANGUAGES } from '../constants'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; +const ALL_AUDIO_VIDEO_EXT = [...SAFE_AUDIO_EXT, ...SAFE_VIDEO_EXT]; + const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; const isTaskItem = (hastNode) => { @@ -17,6 +20,32 @@ const getTableCellAttrs = (hastNode) => ({ rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1, }); +const getMediaAttrs = (hastNode) => ({ + src: hastNode.properties.src, + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, + isReference: hastNode.properties.isReference === 'true', + title: hastNode.properties.title, + alt: hastNode.properties.alt, +}); + +const isMediaTag = (hastNode) => hastNode.tagName === 'img' && Boolean(hastNode.properties); + +const extractMediaFileExtension = (url) => { + try { + const parsedUrl = new URL(url, window.location.origin); + + return /\.(\w+)$/.exec(parsedUrl.pathname)?.[1] ?? null; + } catch { + return null; + } +}; + +const isCodeBlock = (hastNode) => hastNode.tagName === 'codeblock'; + +const isDiagramCodeBlock = (hastNode) => DIAGRAM_LANGUAGES.includes(hastNode.properties?.language); + +const getCodeBlockAttrs = (hastNode) => ({ language: hastNode.properties.language }); + const factorySpecs = { blockquote: { type: 'block', selector: 'blockquote' }, paragraph: { type: 'block', selector: 'p' }, @@ -45,8 +74,13 @@ const factorySpecs = { }, codeBlock: { type: 'block', - selector: 'codeblock', - getAttrs: (hastNode) => ({ ...hastNode.properties }), + selector: (hastNode) => isCodeBlock(hastNode) && !isDiagramCodeBlock(hastNode), + getAttrs: getCodeBlockAttrs, + }, + diagram: { + type: 'block', + selector: (hastNode) => isCodeBlock(hastNode) && isDiagramCodeBlock(hastNode), + getAttrs: getCodeBlockAttrs, }, horizontalRule: { type: 'block', @@ -121,16 +155,26 @@ const factorySpecs = { selector: 'pre', wrapInParagraph: true, }, + audio: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_AUDIO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, image: { type: 'inline', - selector: 'img', - getAttrs: (hastNode) => ({ - src: hastNode.properties.src, - canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, - isReference: hastNode.properties.isReference === 'true', - title: hastNode.properties.title, - alt: hastNode.properties.alt, - }), + selector: (hastNode) => + isMediaTag(hastNode) && + !ALL_AUDIO_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, + video: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, }, hardBreak: { type: 'inline', @@ -193,6 +237,11 @@ const factorySpecs = { language: hastNode.properties.language, }), }, + + tableOfContents: { + type: 'block', + selector: 'tableofcontents', + }, }; const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference']; @@ -250,6 +299,7 @@ export default () => { 'yaml', 'toml', 'json', + 'tableOfContents', ], }); |