summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js41
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js16
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js70
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',
],
});