From 71fa95e4628487dfe8ef29da071ae96ff18b2668 Mon Sep 17 00:00:00 2001 From: SeinopSys Date: Mon, 13 Sep 2021 01:19:00 +0200 Subject: [PATCH] initial implementation of markdown-based text editor --- assets/css/common/_base.scss | 4 +- assets/css/common/_blocks.scss | 47 ++++ assets/css/common/_forms.scss | 4 + assets/css/views/_communications.scss | 4 +- assets/js/markdowntoolbar.js | 257 ++++++++++++++++++ assets/js/preview.js | 100 +++++-- assets/js/textiletoolbar.js | 172 ------------ assets/js/utils/events.js | 19 ++ assets/js/utils/requests.js | 3 +- assets/js/when-ready.js | 2 +- .../comment/_comment_with_image.html.slime | 2 +- .../conversation/message/_form.html.slime | 23 +- .../templates/conversation/new.html.slime | 21 +- .../templates/image/comment/_form.html.slime | 25 +- .../templates/image/comment/edit.html.slime | 27 +- .../image/comment/history/index.html.slime | 2 +- .../image/description/_form.html.slime | 4 +- .../templates/image/new.html.slime | 22 +- .../markdown/_anon_checkbox.html.slime | 5 + .../{textile => markdown}/_help.html.slime | 24 +- .../templates/markdown/_input.html.slime | 27 ++ .../{textile => markdown}/_toolbar.html.slime | 2 +- .../templates/post/preview/create.html.slime | 4 +- .../profile/description/edit.html.slime | 21 +- .../profile/scratchpad/edit.html.slime | 21 +- .../templates/report/new.html.slime | 19 +- .../templates/topic/new.html.slime | 41 +-- .../templates/topic/post/_form.html.slime | 25 +- .../templates/topic/post/edit.html.slime | 22 +- .../topic/post/history/index.html.slime | 2 +- .../templates/topic/show.html.slime | 2 +- lib/philomena_web/views/image/comment_view.ex | 4 - lib/philomena_web/views/image_view.ex | 4 - lib/philomena_web/views/markdown_view.ex | 7 + lib/philomena_web/views/textile_view.ex | 3 - lib/philomena_web/views/topic/post_view.ex | 4 - lib/philomena_web/views/topic_view.ex | 4 - 37 files changed, 511 insertions(+), 468 deletions(-) create mode 100644 assets/js/markdowntoolbar.js delete mode 100644 assets/js/textiletoolbar.js create mode 100644 lib/philomena_web/templates/markdown/_anon_checkbox.html.slime rename lib/philomena_web/templates/{textile => markdown}/_help.html.slime (54%) create mode 100644 lib/philomena_web/templates/markdown/_input.html.slime rename lib/philomena_web/templates/{textile => markdown}/_toolbar.html.slime (99%) create mode 100644 lib/philomena_web/views/markdown_view.ex delete mode 100644 lib/philomena_web/views/textile_view.ex diff --git a/assets/css/common/_base.scss b/assets/css/common/_base.scss index 9870edc5..115c501c 100644 --- a/assets/css/common/_base.scss +++ b/assets/css/common/_base.scss @@ -215,7 +215,7 @@ hr { } } -//textile +// Text Editor blockquote { margin: 1em 2em; border: 1px dotted $foreground_color; @@ -298,7 +298,7 @@ blockquote blockquote blockquote blockquote blockquote blockquote { white-space: pre-wrap; } -.textile-syntax-reference { +.editor-syntax-reference { margin-bottom: 6px; } diff --git a/assets/css/common/_blocks.scss b/assets/css/common/_blocks.scss index fe57f608..d00ccd92 100644 --- a/assets/css/common/_blocks.scss +++ b/assets/css/common/_blocks.scss @@ -222,3 +222,50 @@ a.block__header--single-item, .block__header a { display: none; } } + +.block__content--top-border { + border-top: $border; +} + +.block__column__header { + font-size: 14px; + line-height: $block_header_sub_height; + margin-bottom: $header_field_spacing; + border-bottom: 1px solid; + font-weight: bold; +} + +@media (min-width: $min_px_width_for_desktop_layout) { + .block--split { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + } + + .block__column--full, .block__column--half { + flex-grow: 0; + flex-shrink: 0; + box-sizing: border-box; + } + + .block__column--full { + flex-basis: 100%; + } + + .block__column--half { + flex-basis: 50%; + max-width: 50%; + + &:first-child { + padding-right: $block-spacing; + } + &:last-child { + padding-left: $block-spacing; + } + + .flex__grow { + flex: 1; + min-width: 0; + } + } +} diff --git a/assets/css/common/_forms.scss b/assets/css/common/_forms.scss index 2d17ac9e..2efb0b0e 100644 --- a/assets/css/common/_forms.scss +++ b/assets/css/common/_forms.scss @@ -74,6 +74,10 @@ form p { box-sizing: border-box; } +.input--resize-vertical { + resize: vertical; +} + .checkbox { margin: 0.2em 0 0 0.4em; padding: 0; diff --git a/assets/css/views/_communications.scss b/assets/css/views/_communications.scss index 5809534f..7e410306 100644 --- a/assets/css/views/_communications.scss +++ b/assets/css/views/_communications.scss @@ -51,7 +51,7 @@ span.communication__sender__stats, margin-left: 6px; } -.communication-edit__tab { +.communication-edit__wrap { padding-bottom: 12px; } @@ -89,7 +89,7 @@ span.communication__sender__stats, } } -.hyphenate-breaks{ +.hyphenate-breaks { hyphens: auto; } diff --git a/assets/js/markdowntoolbar.js b/assets/js/markdowntoolbar.js new file mode 100644 index 00000000..7511fd50 --- /dev/null +++ b/assets/js/markdowntoolbar.js @@ -0,0 +1,257 @@ +/** + * Markdown toolbar + */ + +import { $, $$, showEl } from './utils/dom'; + +const markdownSyntax = { + bold: { + action: wrapSelection, + options: { prefix: '**', shortcutKey: 'b' } + }, + italics: { + action: wrapSelection, + options: { prefix: '*', shortcutKey: 'i' } + }, + under: { + action: wrapSelection, + options: { prefix: '__', shortcutKey: 'u' } + }, + spoiler: { + action: wrapSelection, + options: { prefix: '||', shortcutKey: 's' } + }, + code: { + action: wrapSelection, + options: { prefix: '`', shortcutKey: 'e' } + }, + strike: { + action: wrapSelection, + options: { prefix: '~~' } + }, + superscript: { + action: wrapSelection, + options: { prefix: '^' } + }, + subscript: { + action: wrapSelection, + options: { prefix: '%' } + }, + quote: { + action: wrapLines, + options: { prefix: '> ' } + }, + link: { + action: insertLink, + options: { shortcutKey: 'l' } + }, + image: { + action: insertLink, + options: { image: true, shortcutKey: 'k' } + }, + noParse: { + action: escapeSelection, + options: { escapeChar: '\\' } + }, +}; + +function getSelections(textarea, linesOnly = false) { + let { selectionStart, selectionEnd } = textarea, + selection = textarea.value.substring(selectionStart, selectionEnd), + leadingSpace = '', + trailingSpace = '', + caret; + + if (linesOnly) { + let startNewlineIndex = 0, + endNewlineIndex = textarea.value.length, + explorer = /\n/g; + while (explorer.exec(textarea.value)) { + const { lastIndex } = explorer; + if (lastIndex < selectionStart) { + startNewlineIndex = lastIndex + 1; + } else if (lastIndex > selectionEnd) { + endNewlineIndex = lastIndex; + break; + } + } + + selectionStart = startNewlineIndex; + selectionEnd = endNewlineIndex; + selection = textarea.value.substring(selectionStart, selectionEnd); + } else { + // Deselect trailing space and line break + for (caret = selection.length - 1; caret > 0; caret--) { + if (selection[caret] !== ' ' && selection[caret] !== '\n') break; + trailingSpace = selection[caret] + trailingSpace; + } + selection = selection.substring(0, caret + 1); + + // Deselect leading space and line break + for (caret = 0; caret < selection.length; caret++) { + if (selection[caret] !== ' ' && selection[caret] !== '\n') break; + leadingSpace += selection[caret]; + } + selection = selection.substring(caret); + } + + return { + selectedText: selection, + beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace, + afterSelection: trailingSpace + textarea.value.substring(selectionEnd), + }; +} + +function getSurroundingTwoLines(beforeText, afterText) { + // Selection typically includes the new line right before it + // therefore you need to include two lines before and after + return { + twoLinesBefore: beforeText.split('\n').slice(-2).join('\n'), + twoLinesAfter: afterText.split('\n').slice(0, 2).join('\n'), + } +} + +function transformSelection(textarea, transformer, eachLine) { + const { selectedText, beforeSelection, afterSelection } = getSelections(textarea, eachLine), + // For long comments, record scrollbar position to restore it later + { scrollTop } = textarea; + + const { newText, caretOffset } = transformer(selectedText, beforeSelection, afterSelection); + + textarea.value = beforeSelection + newText + afterSelection; + + const newSelectionStart = caretOffset >= 1 + ? beforeSelection.length + caretOffset + : textarea.value.length - afterSelection.length - caretOffset; + + textarea.selectionStart = newSelectionStart; + textarea.selectionEnd = newSelectionStart; + textarea.scrollTop = scrollTop; + textarea.dispatchEvent(new Event('keydown')); +} + +function insertLink(textarea, options) { + let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:'); + if (!hyperlink || hyperlink === '') return; + + // Change on-site link to use relative url + if (!options.image && hyperlink.startsWith(window.location.origin)) { + hyperlink = hyperlink.substring(window.location.origin.length); + } + + const prefix = options.image ? '![' : '[', + suffix = '](' + escapeHyperlink(hyperlink) + ')'; + + wrapSelection(textarea, { prefix, suffix }); +} + +function wrapSelection(textarea, options) { + transformSelection(textarea, selectedText => { + const { text = selectedText, prefix = '', suffix = options.prefix } = options, + emptyText = text === ''; + let newText = text; + + if (!emptyText) { + newText = text.replace(/(\n{2,})/g, match => { + return suffix + match + prefix; + }); + } + + return { + newText: prefix + newText + suffix, + caretOffset: emptyText ? prefix.length : -suffix.length, + }; + }) +} + +function wrapLines(textarea, options) { + transformSelection(textarea, (selectedText, before, after) => { + const { text = selectedText, prefix = '', suffix = '' } = options, + { twoLinesBefore, twoLinesAfter } = getSurroundingTwoLines(before, after), + emptyText = text === ''; + let newText = prefix; + + if (!emptyText) { + newText = text.split(/\n/g).map(line => prefix + (line.trim()) + suffix).join('\n'); + } else { + newText += suffix; + } + + // Add blank lines before/after if surrounding line are not empty + if (isNotBlank(twoLinesBefore)) newText = '\n' + newText; + if (isNotBlank(twoLinesAfter)) newText += '\n'; + + return { newText, caretOffset: newText.length - suffix.length }; + }) +} + +function escapeSelection(textarea, options) { + transformSelection(textarea, selectedText => { + const { text = selectedText } = options, + emptyText = text === ''; + + if (emptyText) return; + + let newText = text.replace(/([\[\]()*_`\\~<>^])/g, '\\$1').replace(/\|\|/g, '\\|\\|'); + + return { + newText: newText, + caretOffset: newText.length, + }; + }) +} + +function escapeHyperlink(url) { + return typeof url === 'string' ? url.replace(/([()])/g, '\\$1') : url; +} + +function isNotBlank(string) { + return /\S/.test(string); +} + +function clickHandler(event) { + const button = event.target.closest('.communication__toolbar__button'); + if (!button) return; + const toolbar = button.closest('.communication__toolbar'), + // There may be multiple toolbars present on the page, + // in the case of image pages with description edit active + // we target the textarea that shares the same parent as the toolabr + textarea = $('.js-toolbar-input', toolbar.parentNode), + id = button.dataset.syntaxId; + + markdownSyntax[id].action(textarea, markdownSyntax[id].options); + textarea.focus(); +} + +function shortcutHandler(event) { + if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return; + const textarea = event.target, + key = event.key.toLowerCase(); + + for (const id in markdownSyntax) { + if (key === markdownSyntax[id].options.shortcutKey) { + markdownSyntax[id].action(textarea, markdownSyntax[id].options); + event.preventDefault(); + } + } +} + +function setupToolbar() { + $$('.communication__toolbar').forEach(toolbar => { + toolbar.addEventListener('click', clickHandler); + }); + $$('.js-toolbar-input').forEach(textarea => { + textarea.addEventListener('keydown', shortcutHandler); + }); + + // Transform non-JS basic editor to two-column layout with preview + $$('.js-preview-input-wrapper').forEach(wrapper => { + wrapper.classList.remove('block__column--full'); + wrapper.classList.add('block__column--half'); + }); + $$('.js-preview-output-wrapper').forEach(wrapper => { + showEl(wrapper) + }); +} + +export { setupToolbar }; diff --git a/assets/js/preview.js b/assets/js/preview.js index 068120ba..78035698 100644 --- a/assets/js/preview.js +++ b/assets/js/preview.js @@ -1,14 +1,16 @@ /** - * Textile previews (posts, comments, messages) + * Markdown previews (posts, comments, messages) */ import { fetchJson } from './utils/requests'; import { filterNode } from './imagesclientside'; +import { debounce } from './utils/events.js'; +import { hideEl, showEl } from './utils/dom.js'; function handleError(response) { const errorMessage = '
Preview failed to load!
'; - if (!response.ok) { + if (!response.ok){ return errorMessage; } @@ -22,7 +24,7 @@ function commentReply(user, url, textarea, quote) { if (newval && /\n$/.test(newval)) newval += '\n'; newval += `${text}\n`; - if (quote) { + if (quote){ newval += `[bq="${user.replace('"', '\'')}"] ${quote} [/bq]\n`; } @@ -35,47 +37,99 @@ function commentReply(user, url, textarea, quote) { textarea.focus(); } -function getPreview(body, anonymous, previewTab, isImage = false) { +/** + * Stores the abort controller for the current preview request + * @type {null|AbortController} + */ +let previewAbortController = null; + +function getPreview(body, anonymous, previewLoading, previewContent) { let path = '/posts/preview'; - fetchJson('POST', path, { body, anonymous }) + if (typeof body !== 'string') return; + + const trimmedBody = body.trim(); + if (trimmedBody.length < 1){ + previewContent.innerHTML = ''; + return; + } + + showEl(previewLoading); + + // Abort previous requests if it exists + if (previewAbortController) previewAbortController.abort(); + previewAbortController = new AbortController(); + + fetchJson('POST', path, { body, anonymous }, previewAbortController.signal) .then(handleError) .then(data => { - previewTab.innerHTML = data; - filterNode(previewTab); + previewContent.innerHTML = data; + filterNode(previewContent); + showEl(previewContent); + hideEl(previewLoading); + }) + .finally(() => { + previewAbortController = null; }); } +/** + * Resizes the event target