diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /app/assets/javascripts/static_site_editor | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/static_site_editor')
23 files changed, 1064 insertions, 4 deletions
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index a51a4f9f604..ea775eff358 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -1,7 +1,7 @@ <script> +import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; -import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import imageRepository from '../image_repository'; import formatter from '../services/formatter'; import renderImage from '../services/renderers/render_image'; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 49a2ca03ace..beec1b515ad 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,5 +1,5 @@ <script> -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Tracking from '~/tracking'; import EditArea from '../components/edit_area.vue'; @@ -45,7 +45,9 @@ export default { return !this.appData.isSupportedContent; }, error() { - createFlash(LOAD_CONTENT_ERROR); + createFlash({ + message: LOAD_CONTENT_ERROR, + }); }, }, }, diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js new file mode 100644 index 00000000000..cbb30baa488 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js @@ -0,0 +1,57 @@ +import { __ } from '~/locale'; + +export const CUSTOM_EVENTS = { + openAddImageModal: 'gl_openAddImageModal', + openInsertVideoModal: 'gl_openInsertVideoModal', +}; + +export const YOUTUBE_URL = 'https://www.youtube.com'; + +export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`; + +export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL]; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const TOOLBAR_ITEM_CONFIGS = [ + { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, + { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') }, + { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') }, + { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') }, + { isDivider: true }, + { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') }, + { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') }, + { isDivider: true }, + { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') }, + { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') }, + { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') }, + { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') }, + { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') }, + { isDivider: true }, + { icon: 'dash', command: 'HR', tooltip: __('Add a line') }, + { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, + { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') }, + { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') }, + { isDivider: true }, + { icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, + { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, +]; + +export const EDITOR_TYPES = { + markdown: 'markdown', + wysiwyg: 'wysiwyg', +}; + +export const EDITOR_HEIGHT = '100%'; + +export const EDITOR_PREVIEW_STYLE = 'horizontal'; + +export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 }; + +export const MAX_FILE_SIZE = 2097152; // 2Mb + +export const VIDEO_ATTRIBUTES = { + width: '560', + height: '315', + frameBorder: '0', + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', +}; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue new file mode 100644 index 00000000000..82060d2e4ad --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue @@ -0,0 +1,134 @@ +<script> +import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; +import { isSafeURL, joinPaths } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { IMAGE_TABS } from '../../constants'; +import UploadImageTab from './upload_image_tab.vue'; + +export default { + components: { + UploadImageTab, + GlModal, + GlFormGroup, + GlFormInput, + GlTabs, + GlTab, + }, + props: { + imageRoot: { + type: String, + required: true, + }, + }, + data() { + return { + file: null, + urlError: null, + imageUrl: null, + description: null, + tabIndex: IMAGE_TABS.UPLOAD_TAB, + uploadImageTab: null, + }; + }, + modalTitle: __('Image details'), + okTitle: __('Insert image'), + urlTabTitle: __('Link to an image'), + urlLabel: __('Image URL'), + descriptionLabel: __('Description'), + uploadTabTitle: __('Upload an image'), + computed: { + altText() { + return this.description; + }, + }, + methods: { + show() { + this.file = null; + this.urlError = null; + this.imageUrl = null; + this.description = null; + this.tabIndex = IMAGE_TABS.UPLOAD_TAB; + + this.$refs.modal.show(); + }, + onOk(event) { + if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { + this.submitFile(event); + return; + } + this.submitURL(event); + }, + setFile(file) { + this.file = file; + }, + submitFile(event) { + const { file, altText } = this; + const { uploadImageTab } = this.$refs; + + uploadImageTab.validateFile(); + + if (uploadImageTab.fileError) { + event.preventDefault(); + return; + } + + const imageUrl = joinPaths(this.imageRoot, file.name); + + this.$emit('addImage', { imageUrl, file, altText: altText || file.name }); + }, + submitURL(event) { + if (!this.validateUrl()) { + event.preventDefault(); + return; + } + + const { imageUrl, altText } = this; + + this.$emit('addImage', { imageUrl, altText: altText || imageUrl }); + }, + validateUrl() { + if (!isSafeURL(this.imageUrl)) { + this.urlError = __('Please provide a valid URL'); + this.$refs.urlInput.$el.focus(); + return false; + } + + return true; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="add-image-modal" + :title="$options.modalTitle" + :ok-title="$options.okTitle" + @ok="onOk" + > + <gl-tabs v-model="tabIndex"> + <!-- Upload file Tab --> + <gl-tab :title="$options.uploadTabTitle"> + <upload-image-tab ref="uploadImageTab" @input="setFile" /> + </gl-tab> + + <!-- By URL Tab --> + <gl-tab :title="$options.urlTabTitle"> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.urlLabel" + label-for="url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + </gl-tab> + </gl-tabs> + + <!-- Description Input --> + <gl-form-group :label="$options.descriptionLabel" label-for="description-input"> + <gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue new file mode 100644 index 00000000000..9baa7f286d7 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue @@ -0,0 +1,56 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { MAX_FILE_SIZE } from '../../constants'; + +export default { + components: { + GlFormGroup, + }, + data() { + return { + file: null, + fileError: null, + }; + }, + fileLabel: __('Select file'), + methods: { + onInput(event) { + [this.file] = event.target.files; + + this.validateFile(); + + if (!this.fileError) { + this.$emit('input', this.file); + } + }, + validateFile() { + this.fileError = null; + + if (!this.file) { + this.fileError = __('Please choose a file'); + } else if (this.file.size > MAX_FILE_SIZE) { + this.fileError = __('Maximum file size is 2MB. Please select a smaller file.'); + } + }, + }, +}; +</script> +<template> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.fileLabel" + label-for="file-input" + :state="!Boolean(fileError)" + :invalid-feedback="fileError" + > + <input + id="file-input" + ref="fileInput" + class="gl-mt-3 gl-mb-2" + type="file" + accept="image/*" + @input="onInput" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue new file mode 100644 index 00000000000..99bb2080610 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue @@ -0,0 +1,91 @@ +<script> +import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { isSafeURL } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants'; + +export default { + components: { + GlModal, + GlFormGroup, + GlFormInput, + GlSprintf, + }, + data() { + return { + url: null, + urlError: null, + description: __( + 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}', + ), + }; + }, + modalTitle: __('Insert a video'), + okTitle: __('Insert video'), + label: __('YouTube URL or ID'), + methods: { + show() { + this.urlError = null; + this.url = null; + + this.$refs.modal.show(); + }, + onPrimary(event) { + this.submitURL(event); + }, + submitURL(event) { + const url = this.generateUrl(); + + if (!url) { + event.preventDefault(); + return; + } + + this.$emit('insertVideo', url); + }, + generateUrl() { + let { url } = this; + const reYouTubeId = /^[A-z0-9]*$/; + const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`); + + if (reYouTubeId.test(url)) { + url = `${YOUTUBE_EMBED_URL}/${url}`; + } else if (reYouTubeUrl.test(url)) { + url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`; + } + + if (!isSafeURL(url) || !reYouTubeUrl.test(url)) { + this.urlError = __('Please provide a valid YouTube URL or ID'); + this.$refs.urlInput.$el.focus(); + return null; + } + + return url; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + size="sm" + modal-id="insert-video-modal" + :title="$options.modalTitle" + :ok-title="$options.okTitle" + @primary="onPrimary" + > + <gl-form-group + :label="$options.label" + label-for="video-modal-url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" /> + <gl-sprintf slot="description" :message="description" class="text-gl-muted"> + <template #id> + <strong>{{ __('0t1DgySidms') }}</strong> + </template> + </gl-sprintf> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue new file mode 100644 index 00000000000..8988dab85d2 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue @@ -0,0 +1,150 @@ +<script> +import 'codemirror/lib/codemirror.css'; +import '@toast-ui/editor/dist/toastui-editor.css'; + +import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants'; +import AddImageModal from './modals/add_image/add_image_modal.vue'; +import InsertVideoModal from './modals/insert_video_modal.vue'; + +import { + registerHTMLToMarkdownRenderer, + getEditorOptions, + addCustomEventListener, + removeCustomEventListener, + addImage, + getMarkdown, + insertVideo, +} from './services/editor_service'; + +export default { + components: { + ToastEditor: () => + import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then( + (toast) => toast.Editor, + ), + AddImageModal, + InsertVideoModal, + }, + props: { + content: { + type: String, + required: true, + }, + options: { + type: Object, + required: false, + default: () => null, + }, + initialEditType: { + type: String, + required: false, + default: EDITOR_TYPES.wysiwyg, + }, + height: { + type: String, + required: false, + default: EDITOR_HEIGHT, + }, + previewStyle: { + type: String, + required: false, + default: EDITOR_PREVIEW_STYLE, + }, + imageRoot: { + type: String, + required: true, + }, + }, + data() { + return { + editorApi: null, + previousMode: null, + }; + }, + computed: { + editorInstance() { + return this.$refs.editor; + }, + customEventListeners() { + return [ + { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal }, + { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal }, + ]; + }, + }, + created() { + this.editorOptions = getEditorOptions(this.options); + }, + beforeDestroy() { + this.removeListeners(); + }, + methods: { + addListeners(editorApi) { + this.customEventListeners.forEach(({ event, listener }) => { + addCustomEventListener(editorApi, event, listener); + }); + + editorApi.eventManager.listen('changeMode', this.onChangeMode); + }, + removeListeners() { + this.customEventListeners.forEach(({ event, listener }) => { + removeCustomEventListener(this.editorApi, event, listener); + }); + + this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); + }, + resetInitialValue(newVal) { + this.editorInstance.invoke('setMarkdown', newVal); + }, + onContentChanged() { + this.$emit('input', getMarkdown(this.editorInstance)); + }, + onLoad(editorApi) { + this.editorApi = editorApi; + + registerHTMLToMarkdownRenderer(editorApi); + + this.addListeners(editorApi); + + this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() }); + }, + onOpenAddImageModal() { + this.$refs.addImageModal.show(); + }, + onAddImage({ imageUrl, altText, file }) { + const image = { imageUrl, altText }; + + if (file) { + this.$emit('uploadImage', { file, imageUrl }); + } + + addImage(this.editorInstance, image, file); + }, + onOpenInsertVideoModal() { + this.$refs.insertVideoModal.show(); + }, + onInsertVideo(url) { + insertVideo(this.editorInstance, url); + }, + onChangeMode(newMode) { + this.$emit('modeChange', newMode); + }, + }, +}; +</script> +<template> + <div> + <toast-editor + ref="editor" + :initial-value="content" + :options="editorOptions" + :preview-style="previewStyle" + :initial-edit-type="initialEditType" + :height="height" + @change="onContentChanged" + @load="onLoad" + /> + <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" /> + <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" /> + </div> +</template> diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js new file mode 100644 index 00000000000..6ffd280e005 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js @@ -0,0 +1,42 @@ +import { union, mapValues } from 'lodash'; +import renderAttributeDefinition from './renderers/render_attribute_definition'; +import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; +import renderHeading from './renderers/render_heading'; +import renderBlockHtml from './renderers/render_html_block'; +import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; +import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; +import renderListItem from './renderers/render_list_item'; +import renderSoftbreak from './renderers/render_softbreak'; + +const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; +const htmlBlockRenderers = [renderBlockHtml]; +const headingRenderers = [renderHeading]; +const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml]; +const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition]; +const listItemRenderers = [renderListItem]; +const softbreakRenderers = [renderSoftbreak]; + +const executeRenderer = (renderers, node, context) => { + const availableRenderer = renderers.find((renderer) => renderer.canRender(node, context)); + + return availableRenderer ? availableRenderer.render(node, context) : context.origin(); +}; + +const buildCustomHTMLRenderer = (customRenderers) => { + const renderersByType = { + ...customRenderers, + htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock), + htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline), + heading: union(headingRenderers, customRenderers?.heading), + item: union(listItemRenderers, customRenderers?.listItem), + paragraph: union(paragraphRenderers, customRenderers?.paragraph), + text: union(textRenderers, customRenderers?.text), + softbreak: union(softbreakRenderers, customRenderers?.softbreak), + }; + + return mapValues(renderersByType, (renderers) => { + return (node, context) => executeRenderer(renderers, node, context); + }); +}; + +export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js new file mode 100644 index 00000000000..273e0a59963 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -0,0 +1,109 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import { defaults, repeat } from 'lodash'; + +const DEFAULTS = { + subListIndentSpaces: 4, + unorderedListBulletChar: '-', + incrementListMarker: false, + strong: '*', + emphasis: '_', +}; + +const countIndentSpaces = (text) => { + const matches = text.match(/^\s+/m); + + return matches ? matches[0].length : 0; +}; + +const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => { + const { + subListIndentSpaces, + unorderedListBulletChar, + incrementListMarker, + strong, + emphasis, + } = defaults(formattingPreferences, DEFAULTS); + const sublistNode = 'LI OL, LI UL'; + const unorderedListItemNode = 'UL LI'; + const orderedListItemNode = 'OL LI'; + const emphasisNode = 'EM, I'; + const strongNode = 'STRONG, B'; + const headingNode = 'H1, H2, H3, H4, H5, H6'; + const preCodeNode = 'PRE CODE'; + + return { + TEXT_NODE(node) { + return baseRenderer.getSpaceControlled( + baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)), + node, + ); + }, + /* + * This converter overwrites the default indented list converter + * to allow us to parameterize the number of indent spaces for + * sublists. + * + * See the original implementation in + * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161 + */ + [sublistNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + // Default to 1 to prevent possible divide by 0 + const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1; + const reindentedList = baseResult + .split('\n') + .map((line) => { + const itemIndentSpacesCount = countIndentSpaces(line); + const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount); + const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel); + + return line.replace(/^ +/, indentSpaces); + }) + .join('\n'); + + return reindentedList; + }, + [unorderedListItemNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`); + const { attributeDefinition } = node.dataset; + + return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted; + }, + [orderedListItemNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + + return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.'); + }, + [emphasisNode](node, subContent) { + const result = baseRenderer.convert(node, subContent); + + return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis); + }, + [strongNode](node, subContent) { + const result = baseRenderer.convert(node, subContent); + const strongSyntax = repeat(strong, 2); + + return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax); + }, + [headingNode](node, subContent) { + const result = baseRenderer.convert(node, subContent); + const { attributeDefinition } = node.dataset; + + return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result; + }, + [preCodeNode](node, subContent) { + const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition); + + return isReferenceDefinition + ? `\n\n${node.innerText}\n\n` + : baseRenderer.convert(node, subContent); + }, + IMG(node) { + const { originalSrc } = node.dataset; + return `![${node.alt}](${originalSrc || node.src})`; + }, + }; +}; + +export default buildHTMLToMarkdownRender; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js new file mode 100644 index 00000000000..026a4069d9b --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js @@ -0,0 +1,116 @@ +import { defaults } from 'lodash'; +import Vue from 'vue'; +import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants'; +import ToolbarItem from '../toolbar_item.vue'; +import buildCustomHTMLRenderer from './build_custom_renderer'; +import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; +import sanitizeHTML from './sanitize_html'; + +const buildWrapper = (propsData) => { + const instance = new Vue({ + render(createElement) { + return createElement(ToolbarItem, propsData); + }, + }); + + instance.$mount(); + return instance.$el; +}; + +const buildVideoIframe = (src) => { + const wrapper = document.createElement('figure'); + const iframe = document.createElement('iframe'); + const videoAttributes = { ...VIDEO_ATTRIBUTES, src }; + const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container']; + const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full']; + + wrapper.setAttribute('contenteditable', 'false'); + wrapper.classList.add(...wrapperClasses); + iframe.classList.add(...iframeClasses); + Object.assign(iframe, videoAttributes); + + wrapper.appendChild(iframe); + + return wrapper; +}; + +const buildImg = (alt, originalSrc, file) => { + const img = document.createElement('img'); + const src = file ? URL.createObjectURL(file) : originalSrc; + const attributes = { alt, src }; + + if (file) { + img.dataset.originalSrc = originalSrc; + } + + Object.assign(img, attributes); + + return img; +}; + +export const generateToolbarItem = (config) => { + const { icon, classes, event, command, tooltip, isDivider } = config; + + if (isDivider) { + return 'divider'; + } + + return { + type: 'button', + options: { + el: buildWrapper({ props: { icon, tooltip }, class: classes }), + event, + command, + }, + }; +}; + +export const addCustomEventListener = (editorApi, event, handler) => { + editorApi.eventManager.addEventType(event); + editorApi.eventManager.listen(event, handler); +}; + +export const removeCustomEventListener = (editorApi, event, handler) => + editorApi.eventManager.removeEventHandler(event, handler); + +export const addImage = ({ editor }, { altText, imageUrl }, file) => { + if (editor.isWysiwygMode()) { + const img = buildImg(altText, imageUrl, file); + editor.getSquire().insertElement(img); + } else { + editor.insertText(`![${altText}](${imageUrl})`); + } +}; + +export const insertVideo = ({ editor }, url) => { + const videoIframe = buildVideoIframe(url); + + if (editor.isWysiwygMode()) { + editor.getSquire().insertElement(videoIframe); + } else { + editor.insertText(videoIframe.outerHTML); + } +}; + +export const getMarkdown = (editorInstance) => editorInstance.invoke('getMarkdown'); + +/** + * This function allow us to extend Toast UI HTML to Markdown renderer. It is + * a temporary measure because Toast UI does not provide an API + * to achieve this goal. + */ +export const registerHTMLToMarkdownRenderer = (editorApi) => { + const { renderer } = editorApi.toMarkOptions; + + Object.assign(editorApi.toMarkOptions, { + renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), + }); +}; + +export const getEditorOptions = (externalOptions) => { + return defaults({ + customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), + toolbarItems: TOOLBAR_ITEM_CONFIGS.map((toolbarItem) => generateToolbarItem(toolbarItem)), + customHTMLSanitizer: (html) => sanitizeHTML(html), + }); +}; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js new file mode 100644 index 00000000000..638e5fd6f60 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js @@ -0,0 +1,63 @@ +const buildToken = (type, tagName, props) => { + return { type, tagName, ...props }; +}; + +const TAG_TYPES = { + block: 'div', + inline: 'a', +}; + +// Open helpers (singular and multiple) + +const buildUneditableOpenToken = (tagType = TAG_TYPES.block) => + buildToken('openTag', tagType, { + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }); + +export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => { + return [buildUneditableOpenToken(tagType), token]; +}; + +// Close helpers (singular and multiple) + +export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) => + buildToken('closeTag', tagType); + +export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => { + return [token, buildUneditableCloseToken(tagType)]; +}; + +// Complete helpers (open plus close) + +export const buildTextToken = (content) => buildToken('text', null, { content }); + +export const buildUneditableBlockTokens = (token) => { + return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; +}; + +export const buildUneditableInlineTokens = (token) => { + return [ + ...buildUneditableOpenTokens(token, TAG_TYPES.inline), + buildUneditableCloseToken(TAG_TYPES.inline), + ]; +}; + +export const buildUneditableHtmlAsTextTokens = (node) => { + /* + Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain + nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want + to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html` + type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass ` + to prevent their persistence within the `text` content as the user did not intend these as edits. + + https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72 + */ + const regex = / data-tomark-pass /gm; + const content = node.literal.replace(regex, ''); + const htmlAsTextToken = buildToken('text', null, { content }); + + return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()]; +}; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js new file mode 100644 index 00000000000..bd419447a48 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js @@ -0,0 +1,7 @@ +import { isAttributeDefinition } from './render_utils'; + +const canRender = ({ literal }) => isAttributeDefinition(literal); + +const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' }); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js new file mode 100644 index 00000000000..0e122f598e5 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -0,0 +1,9 @@ +import { renderUneditableLeaf as render } from './render_utils'; + +const embeddedRubyRegex = /(^<%.+%>$)/; + +const canRender = ({ literal }) => { + return embeddedRubyRegex.test(literal); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js new file mode 100644 index 00000000000..572f6e3cf9d --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js @@ -0,0 +1,11 @@ +import { buildUneditableInlineTokens } from './build_uneditable_token'; + +const fontAwesomeRegexOpen = /<i class="fa.+>/; + +const canRender = ({ literal }) => { + return fontAwesomeRegexOpen.test(literal); +}; + +const render = (_, { origin }) => buildUneditableInlineTokens(origin()); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js new file mode 100644 index 00000000000..71026fd0d65 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js @@ -0,0 +1,6 @@ +import { + renderWithAttributeDefinitions as render, + willAlwaysRender as canRender, +} from './render_utils'; + +export default { render, canRender }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js new file mode 100644 index 00000000000..710b807275b --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js @@ -0,0 +1,23 @@ +import { getURLOrigin } from '~/lib/utils/url_utility'; +import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; + +const isVideoFrame = (html) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const { + children: { length }, + } = doc; + const iframe = doc.querySelector('iframe'); + const origin = iframe && getURLOrigin(iframe.getAttribute('src')); + + return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin); +}; + +const canRender = ({ type, literal }) => { + return type === 'htmlBlock' && !isVideoFrame(literal); +}; + +const render = (node) => buildUneditableHtmlAsTextTokens(node); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..d770dd18d7f --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"main:broken"`][broken-main-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = (literal) => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = (text) => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map((match) => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js new file mode 100644 index 00000000000..4829f0f2243 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -0,0 +1,40 @@ +const identifierRegex = /(^\[.+\]: .+)/; + +const isIdentifier = (text) => { + return identifierRegex.test(text); +}; + +const canRender = (node, context) => { + return isIdentifier(context.getChildrenText(node)); +}; + +const getReferenceDefinitions = (node, definitions = '') => { + if (!node) { + return definitions; + } + + const definition = node.type === 'text' ? node.literal : '\n'; + + return getReferenceDefinitions(node.next, `${definitions}${definition}`); +}; + +const render = (node, { skipChildren }) => { + const content = getReferenceDefinitions(node.firstChild); + + skipChildren(); + + return [ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { 'data-sse-reference-definition': true }, + }, + { type: 'openTag', tagName: 'code' }, + { type: 'text', content }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]; +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js new file mode 100644 index 00000000000..71026fd0d65 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js @@ -0,0 +1,6 @@ +import { + renderWithAttributeDefinitions as render, + willAlwaysRender as canRender, +} from './render_utils'; + +export default { render, canRender }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js new file mode 100644 index 00000000000..c004e839821 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js @@ -0,0 +1,7 @@ +const canRender = (node) => ['emph', 'strong'].includes(node.parent?.type); +const render = () => ({ + type: 'text', + content: ' ', +}); + +export default { canRender, render }; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js new file mode 100644 index 00000000000..eff5dbf59f2 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js @@ -0,0 +1,38 @@ +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from './build_uneditable_token'; + +export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin()); + +export const renderUneditableBranch = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +const attributeDefinitionRegexp = /(^{:.+}$)/; + +export const isAttributeDefinition = (text) => attributeDefinitionRegexp.test(text); + +const findAttributeDefinition = (node) => { + const literal = + node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items; + + return isAttributeDefinition(literal) ? literal : null; +}; + +export const renderWithAttributeDefinitions = (node, { origin }) => { + const attributes = findAttributeDefinition(node); + const token = origin(); + + if (token.type === 'openTag' && attributes) { + Object.assign(token, { + attributes: { + 'data-attribute-definition': attributes, + }, + }); + } + + return token; +}; + +export const willAlwaysRender = () => true; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js new file mode 100644 index 00000000000..486d88466b7 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js @@ -0,0 +1,22 @@ +import createSanitizer from 'dompurify'; +import { getURLOrigin } from '~/lib/utils/url_utility'; +import { ALLOWED_VIDEO_ORIGINS } from '../constants'; + +const sanitizer = createSanitizer(window); +const ADD_TAGS = ['iframe']; + +sanitizer.addHook('uponSanitizeElement', (node) => { + if (node.tagName !== 'IFRAME') { + return; + } + + const origin = getURLOrigin(node.getAttribute('src')); + + if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) { + node.remove(); + } +}); + +const sanitize = (content) => sanitizer.sanitize(content, { ADD_TAGS }); + +export default sanitize; diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue new file mode 100644 index 00000000000..85a67c087bb --- /dev/null +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue @@ -0,0 +1,31 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + icon: { + type: String, + required: true, + }, + tooltip: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <button + v-gl-tooltip="{ title: tooltip }" + :aria-label="tooltip" + class="p-0 gl-display-flex toolbar-button" + > + <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" /> + </button> +</template> |