diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions')
24 files changed, 432 insertions, 91 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js new file mode 100644 index 00000000000..25d4068c93f --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/audio.js @@ -0,0 +1,9 @@ +import Playable from './playable'; + +export default Playable.extend({ + name: 'audio', + defaultOptions: { + ...Playable.options, + mediaType: 'audio', + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index 45f53fe230b..4512ead44bc 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -1 +1,33 @@ -export { Blockquote as default } from '@tiptap/extension-blockquote'; +import { Blockquote } from '@tiptap/extension-blockquote'; +import { wrappingInputRule } from 'prosemirror-inputrules'; +import { getParents } from '~/lib/utils/dom_utils'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export const multilineInputRegex = /^\s*>>>\s$/gm; + +export default Blockquote.extend({ + addAttributes() { + return { + ...this.parent?.(), + + multiline: { + default: false, + parseHTML: (element) => { + const source = getMarkdownSource(element); + const parentsIncludeBlockquote = getParents(element).some( + (p) => p.nodeName.toLowerCase() === 'blockquote', + ); + + return source && !source.startsWith('>') && !parentsIncludeBlockquote; + }, + }, + }; + }, + + addInputRules() { + return [ + ...this.parent?.(), + wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js index 01ead571fe1..8d0faf7a9fe 100644 --- a/app/assets/javascripts/content_editor/extensions/bullet_list.js +++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js @@ -1 +1,19 @@ -export { BulletList as default } from '@tiptap/extension-bullet-list'; +import { BulletList } from '@tiptap/extension-bullet-list'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export default BulletList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + bullet: { + default: '*', + parseHTML(element) { + const bullet = getMarkdownSource(element)?.charAt(0); + + return '*+-'.includes(bullet) ? bullet : '*'; + }, + }, + }; + }, +}); 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 c6d32fb8547..25f5837d2a6 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -8,11 +8,7 @@ export default CodeBlockLowlight.extend({ return { language: { default: null, - parseHTML: (element) => { - return { - language: extractLanguage(element), - }; - }, + parseHTML: (element) => extractLanguage(element), }, class: { default: 'code highlight js-syntax-highlight', diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js new file mode 100644 index 00000000000..957fdede27b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/description_item.js @@ -0,0 +1,49 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export default Node.create({ + name: 'descriptionItem', + content: 'block+', + defining: true, + + addAttributes() { + return { + isTerm: { + default: true, + parseHTML: (element) => element.tagName.toLowerCase() === 'dt', + }, + }; + }, + + parseHTML() { + return [{ tag: 'dt' }, { tag: 'dd' }]; + }, + + renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) { + return [ + 'li', + mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }), + 0, + ]; + }, + + addKeyboardShortcuts() { + return { + Enter: () => { + return this.editor.commands.splitListItem('descriptionItem'); + }, + Tab: () => { + const { isTerm } = this.editor.getAttributes('descriptionItem'); + if (isTerm) + return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm }); + + return false; + }, + 'Shift-Tab': () => { + const { isTerm } = this.editor.getAttributes('descriptionItem'); + if (isTerm) return this.editor.commands.liftListItem('descriptionItem'); + + return this.editor.commands.updateAttributes('descriptionItem', { isTerm: true }); + }, + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js new file mode 100644 index 00000000000..a516dfad2b8 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/description_list.js @@ -0,0 +1,23 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { wrappingInputRule } from 'prosemirror-inputrules'; + +export const inputRegex = /^\s*(<dl>)$/; + +export default Node.create({ + name: 'descriptionList', + // eslint-disable-next-line @gitlab/require-i18n-strings + group: 'block list', + content: 'descriptionItem+', + + parseHTML() { + return [{ tag: 'dl' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0]; + }, + + addInputRules() { + return [wrappingInputRule(inputRegex, this.type)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js new file mode 100644 index 00000000000..c70d1700941 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/division.js @@ -0,0 +1,17 @@ +import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; + +export default Node.create({ + name: 'division', + content: 'block*', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', HTMLAttributes, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index d88b9f92215..de608c3aaa2 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -17,30 +17,18 @@ export default Node.create({ return { moji: { default: null, - parseHTML: (element) => { - return { - moji: element.textContent, - }; - }, + parseHTML: (element) => element.textContent, }, name: { default: null, - parseHTML: (element) => { - return { - name: element.dataset.name, - }; - }, + parseHTML: (element) => element.dataset.name, }, title: { default: null, }, unicodeVersion: { default: '6.0', - parseHTML: (element) => { - return { - unicodeVersion: element.dataset.unicodeVersion, - }; - }, + parseHTML: (element) => element.dataset.unicodeVersion, }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js new file mode 100644 index 00000000000..b2076894412 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/figure.js @@ -0,0 +1,16 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'figure', + content: 'block+', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'figure' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['figure', HTMLAttributes, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js new file mode 100644 index 00000000000..ffd1b474f03 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js @@ -0,0 +1,16 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'figureCaption', + content: 'inline*', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'figcaption' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['figcaption', HTMLAttributes, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js new file mode 100644 index 00000000000..54adb9efa0c --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -0,0 +1,66 @@ +import { Mark, mergeAttributes, markInputRule } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; +import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils'; + +const marks = [ + 'ins', + 'abbr', + 'bdo', + 'cite', + 'dfn', + 'mark', + 'small', + 'span', + 'time', + 'kbd', + 'q', + 'samp', + 'var', + 'ruby', + 'rp', + 'rt', +]; + +const attrs = { + time: ['datetime'], + abbr: ['title'], + span: ['dir'], + bdo: ['dir'], +}; + +export default marks.map((name) => + Mark.create({ + name, + + inclusive: false, + + defaultOptions: { + HTMLAttributes: {}, + }, + + addAttributes() { + return (attrs[name] || []).reduce( + (acc, attr) => ({ + ...acc, + [attr]: { + default: null, + parseHTML: (element) => element.getAttribute(attr), + }, + }), + {}, + ); + }, + + parseHTML() { + return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }]; + }, + + renderHTML({ HTMLAttributes }) { + return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addInputRules() { + return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)]; + }, + }), +); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index c9e8dfa4ad9..837fab0585f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,7 @@ import { Image } from '@tiptap/extension-image'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import ImageWrapper from '../components/wrappers/image.vue'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); @@ -27,27 +28,27 @@ export default Image.extend({ parseHTML: (element) => { const img = resolveImageEl(element); - return { - src: img.dataset.src || img.getAttribute('src'), - }; + return img.dataset.src || img.getAttribute('src'); }, }, canonicalSrc: { default: null, + parseHTML: (element) => element.dataset.canonicalSrc, + }, + alt: { + default: null, parseHTML: (element) => { - return { - canonicalSrc: element.dataset.canonicalSrc, - }; + const img = resolveImageEl(element); + + return img.getAttribute('alt'); }, }, - alt: { + title: { default: null, parseHTML: (element) => { const img = resolveImageEl(element); - return { - alt: img.getAttribute('alt'), - }; + return img.getAttribute('title'); }, }, }; @@ -55,7 +56,7 @@ export default Image.extend({ parseHTML() { return [ { - priority: 100, + priority: PARSE_HTML_PRIORITY_HIGHEST, tag: 'a.no-attachment-icon', }, { diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index 9471d324764..3bd328958df 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -14,11 +14,7 @@ export default Mark.create({ return { type: { default: 'addition', - parseHTML: (element) => { - return { - type: element.classList.contains('deletion') ? 'deletion' : 'addition', - }; - }, + parseHTML: (element) => (element.classList.contains('deletion') ? 'deletion' : 'addition'), }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 53104fe07a3..fc0f38e6935 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -36,19 +36,15 @@ export default Link.extend({ ...this.parent?.(), href: { default: null, - parseHTML: (element) => { - return { - href: element.getAttribute('href'), - }; - }, + parseHTML: (element) => element.getAttribute('href'), + }, + title: { + title: null, + parseHTML: (element) => element.getAttribute('title'), }, canonicalSrc: { default: null, - parseHTML: (element) => { - return { - canonicalSrc: element.dataset.canonicalSrc, - }; - }, + parseHTML: (element) => element.dataset.canonicalSrc, }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js index 9a79187d9c1..57d5bd6ebf8 100644 --- a/app/assets/javascripts/content_editor/extensions/ordered_list.js +++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js @@ -1 +1,15 @@ -export { OrderedList as default } from '@tiptap/extension-ordered-list'; +import { OrderedList } from '@tiptap/extension-ordered-list'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export default OrderedList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), + }, + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js new file mode 100644 index 00000000000..0062bc563db --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -0,0 +1,66 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { Node } from '@tiptap/core'; + +const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); + +export default Node.create({ + group: 'inline', + inline: true, + draggable: true, + + addAttributes() { + return { + src: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.src; + }, + }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.dataset.canonicalSrc; + }, + }, + alt: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.dataset.title; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `.${this.options.mediaType}-container`, + }, + ]; + }, + + renderHTML({ node }) { + return [ + 'span', + { class: `media-container ${this.options.mediaType}-container` }, + [ + this.options.mediaType, + { + src: node.attrs.src, + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + ...this.extraElementAttrs, + }, + ], + ['a', { href: node.attrs.src }, node.attrs.alt], + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5f4484af9c8..5e459e65de2 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,10 @@ import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +const getAnchor = (element) => { + if (element.nodeName === 'A') return element; + return element.querySelector('a'); +}; export default Node.create({ name: 'reference', @@ -13,43 +19,23 @@ export default Node.create({ return { className: { default: null, - parseHTML: (element) => { - return { - className: element.className, - }; - }, + parseHTML: (element) => getAnchor(element).className, }, referenceType: { default: null, - parseHTML: (element) => { - return { - referenceType: element.dataset.referenceType, - }; - }, + parseHTML: (element) => getAnchor(element).dataset.referenceType, }, originalText: { default: null, - parseHTML: (element) => { - return { - originalText: element.dataset.original, - }; - }, + parseHTML: (element) => getAnchor(element).dataset.original, }, href: { default: null, - parseHTML: (element) => { - return { - href: element.getAttribute('href'), - }; - }, + parseHTML: (element) => getAnchor(element).getAttribute('href'), }, text: { default: null, - parseHTML: (element) => { - return { - text: element.textContent, - }; - }, + parseHTML: (element) => getAnchor(element).textContent, }, }; }, @@ -58,7 +44,10 @@ export default Node.create({ return [ { tag: 'a.gfm:not([data-link=true])', - priority: 51, + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + { + tag: 'span.gl-label', }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js index 4bf89796efe..d0766f42308 100644 --- a/app/assets/javascripts/content_editor/extensions/subscript.js +++ b/app/assets/javascripts/content_editor/extensions/subscript.js @@ -1 +1,9 @@ -export { Subscript as default } from '@tiptap/extension-subscript'; +import { markInputRule } from '@tiptap/core'; +import { Subscript } from '@tiptap/extension-subscript'; +import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils'; + +export default Subscript.extend({ + addInputRules() { + return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js index 3eb7d86d90d..6cd814977ea 100644 --- a/app/assets/javascripts/content_editor/extensions/superscript.js +++ b/app/assets/javascripts/content_editor/extensions/superscript.js @@ -1 +1,9 @@ -export { Superscript as default } from '@tiptap/extension-superscript'; +import { markInputRule } from '@tiptap/core'; +import { Superscript } from '@tiptap/extension-superscript'; +import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils'; + +export default Superscript.extend({ + addInputRules() { + return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index 5bdc39231a1..befc33e669f 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -1,5 +1,12 @@ import { TableCell } from '@tiptap/extension-table-cell'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue'; +import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableCell.extend({ - content: 'inline*', + content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + + addNodeView() { + return VueNodeViewRenderer(TableCellBodyWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 23509706e4b..829b06fc14b 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,5 +1,11 @@ import { TableHeader } from '@tiptap/extension-table-header'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue'; +import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableHeader.extend({ - content: 'inline*', + content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + addNodeView() { + return VueNodeViewRenderer(TableCellHeaderWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 6163c0e043b..9b050edcb28 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -1,4 +1,5 @@ import { TaskItem } from '@tiptap/extension-task-item'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; export default TaskItem.extend({ defaultOptions: { @@ -12,7 +13,8 @@ export default TaskItem.extend({ default: false, parseHTML: (element) => { const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); - return { checked: checkbox?.checked }; + + return checkbox?.checked; }, renderHTML: (attributes) => ({ 'data-checked': attributes.checked, @@ -26,7 +28,7 @@ export default TaskItem.extend({ return [ { tag: 'li.task-list-item', - priority: 100, + priority: PARSE_HTML_PRIORITY_HIGHEST, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js index b7f6c857bc7..72c6e020102 100644 --- a/app/assets/javascripts/content_editor/extensions/task_list.js +++ b/app/assets/javascripts/content_editor/extensions/task_list.js @@ -1,16 +1,24 @@ import { mergeAttributes } from '@tiptap/core'; import { TaskList } from '@tiptap/extension-task-list'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; export default TaskList.extend({ addAttributes() { return { - type: { - default: 'ul', - parseHTML: (element) => { - return { - type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul', - }; - }, + numeric: { + default: false, + parseHTML: (element) => element.tagName.toLowerCase() === 'ol', + }, + start: { + default: 1, + parseHTML: (element) => + element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1, + }, + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), }, }; }, @@ -19,12 +27,12 @@ export default TaskList.extend({ return [ { tag: '.task-list', - priority: 100, + priority: PARSE_HTML_PRIORITY_HIGHEST, }, ]; }, - renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) { - return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; + renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) { + return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js new file mode 100644 index 00000000000..9923b7c04cd --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/video.js @@ -0,0 +1,10 @@ +import Playable from './playable'; + +export default Playable.extend({ + name: 'video', + defaultOptions: { + ...Playable.options, + mediaType: 'video', + extraElementAttrs: { width: '400' }, + }, +}); |