summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions')
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js73
-rw-r--r--app/assets/javascripts/content_editor/extensions/details.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/math_inline.js35
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js51
-rw-r--r--app/assets/javascripts/content_editor/extensions/word_break.js29
8 files changed, 271 insertions, 1 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 25f5837d2a6..1ed1ab0315f 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -11,7 +11,8 @@ export default CodeBlockLowlight.extend({
parseHTML: (element) => extractLanguage(element),
},
class: {
- default: 'code highlight js-syntax-highlight',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ default: 'code highlight',
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
new file mode 100644
index 00000000000..deb5029a1f0
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -0,0 +1,73 @@
+import { Node } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { Decoration, DecorationSet } from 'prosemirror-view';
+import { isValidColorExpression } from '~/lib/utils/color_utils';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+const colorExpressionTypes = ['#', 'hsl', 'rgb'];
+
+const isValidColor = (color) => {
+ if (!colorExpressionTypes.some((type) => color.toLowerCase().startsWith(type))) {
+ return false;
+ }
+
+ return isValidColorExpression(color);
+};
+
+const highlightColors = (doc) => {
+ const decorations = [];
+
+ doc.descendants((node, position) => {
+ const { text, marks } = node;
+
+ if (!text || marks.length === 0 || marks[0].type.name !== 'code' || !isValidColor(text)) {
+ return;
+ }
+
+ const from = position;
+ const to = from + text.length;
+ const decoration = Decoration.inline(from, to, {
+ class: 'gl-display-inline-flex gl-align-items-center content-editor-color-chip',
+ style: `--gl-color-chip-color: ${text}`,
+ });
+
+ decorations.push(decoration);
+ });
+
+ return DecorationSet.create(doc, decorations);
+};
+
+export const colorDecoratorPlugin = new Plugin({
+ key: new PluginKey('colorDecorator'),
+ state: {
+ init(_, { doc }) {
+ return highlightColors(doc);
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? highlightColors(transaction.doc) : oldState;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+});
+
+export default Node.create({
+ name: 'colorChip',
+
+ parseHTML() {
+ return [
+ {
+ tag: '.gfm-color_chip',
+ ignore: true,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ addProseMirrorPlugins() {
+ return [colorDecoratorPlugin];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js
new file mode 100644
index 00000000000..e3d54ed01fd
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details.js
@@ -0,0 +1,36 @@
+import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+import DetailsWrapper from '../components/wrappers/details.vue';
+
+export const inputRegex = /^\s*(<details>)$/;
+
+export default Node.create({
+ name: 'details',
+ content: 'detailsContent+',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ group: 'block list',
+
+ parseHTML() {
+ return [{ tag: 'details' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ul', HTMLAttributes, 0];
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(DetailsWrapper);
+ },
+
+ addInputRules() {
+ return [wrappingInputRule(inputRegex, this.type)];
+ },
+
+ addCommands() {
+ return {
+ setDetails: () => ({ commands }) => commands.wrapInList('details'),
+ toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
new file mode 100644
index 00000000000..fb6c49d91aa
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export default Node.create({
+ name: 'detailsContent',
+ content: 'block+',
+ defining: true,
+
+ parseHTML() {
+ return [
+ { tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['li', HTMLAttributes, 0];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => this.editor.commands.splitListItem('detailsContent'),
+ 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
new file mode 100644
index 00000000000..64c84fe046b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -0,0 +1,20 @@
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import FrontmatterWrapper from '../components/wrappers/frontmatter.vue';
+import CodeBlockHighlight from './code_block_highlight';
+
+export default CodeBlockHighlight.extend({
+ name: 'frontmatter',
+ parseHTML() {
+ return [
+ {
+ tag: 'pre[data-lang-params="frontmatter"]',
+ preserveWhitespace: 'full',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+ addNodeView() {
+ return new VueNodeViewRenderer(FrontmatterWrapper);
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js
new file mode 100644
index 00000000000..60f5288dcf6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/math_inline.js
@@ -0,0 +1,35 @@
+import { Mark, markInputRule } from '@tiptap/core';
+import { __ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm;
+
+export default Mark.create({
+ name: 'mathInline',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'code.math[data-math-style=inline]',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'code',
+ {
+ title: __('Inline math'),
+ 'data-toggle': 'tooltip',
+ class: 'gl-inset-border-1-gray-400',
+ ...HTMLAttributes,
+ },
+ 0,
+ ];
+ },
+
+ addInputRules() {
+ return [markInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
new file mode 100644
index 00000000000..9e31158837e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -0,0 +1,51 @@
+import { Node } from '@tiptap/core';
+import { InputRule } from 'prosemirror-inputrules';
+import { s__ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/];
+
+export default Node.create({
+ name: 'tableOfContents',
+
+ inline: false,
+
+ group: 'block',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'ul.section-nav',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML() {
+ return [
+ 'div',
+ {
+ class:
+ 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5',
+ },
+ s__('ContentEditor|Table of Contents'),
+ ];
+ },
+
+ addInputRules() {
+ const { type } = this;
+
+ return inputRuleRegExps.map(
+ (regex) =>
+ new InputRule(regex, (state, match, start, end) => {
+ const { tr } = state;
+
+ if (match) {
+ tr.replaceWith(start - 1, end, type.create());
+ }
+
+ return tr;
+ }),
+ );
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js
new file mode 100644
index 00000000000..93b42466850
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/word_break.js
@@ -0,0 +1,29 @@
+import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
+
+export const inputRegex = /^<wbr>$/;
+
+export default Node.create({
+ name: 'wordBreak',
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ atom: true,
+
+ defaultOptions: {
+ HTMLAttributes: {
+ class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm',
+ },
+ },
+
+ parseHTML() {
+ return [{ tag: 'wbr' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), '-'];
+ },
+
+ addInputRules() {
+ return [nodeInputRule(inputRegex, this.type)];
+ },
+});