diff --git a/assets/css/common/_blocks.scss b/assets/css/common/_blocks.scss index d00ccd92..774ae23e 100644 --- a/assets/css/common/_blocks.scss +++ b/assets/css/common/_blocks.scss @@ -226,46 +226,3 @@ a.block__header--single-item, .block__header a { .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/views/_communications.scss b/assets/css/views/_communications.scss index 7e410306..5809534f 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__wrap { +.communication-edit__tab { 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 index 3992bdfa..0056f807 100644 --- a/assets/js/markdowntoolbar.js +++ b/assets/js/markdowntoolbar.js @@ -2,7 +2,7 @@ * Markdown toolbar */ -import { $, $$, showEl } from './utils/dom'; +import { $, $$ } from './utils/dom'; const markdownSyntax = { bold: { @@ -22,8 +22,15 @@ const markdownSyntax = { options: { prefix: '||', shortcutKey: 's' } }, code: { - action: wrapSelection, - options: { prefix: '`', shortcutKey: 'e' } + action: wrapSelectionOrLines, + options: { + prefix: '`', + suffix: '`', + prefixMultiline: '```\n', + suffixMultiline: '\n```', + singleWrap: true, + shortcutKey: 'e' + } }, strike: { action: wrapSelection, @@ -62,22 +69,31 @@ function getSelections(textarea, linesOnly = false) { trailingSpace = '', caret; - if (linesOnly) { + const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly; + if (processLinesOnly) { + const explorer = /\n/g; let startNewlineIndex = 0, endNewlineIndex = textarea.value.length; - const explorer = /\n/g; while (explorer.exec(textarea.value)) { const { lastIndex } = explorer; - if (lastIndex < selectionStart) { - startNewlineIndex = lastIndex + 1; + if (lastIndex <= selectionStart) { + startNewlineIndex = lastIndex; } else if (lastIndex > selectionEnd) { - endNewlineIndex = lastIndex; + endNewlineIndex = lastIndex - 1; break; } } selectionStart = startNewlineIndex; + const startRemovedValue = textarea.value.substring(selectionStart); + const startsWithBlankString = startRemovedValue.match(/^[\s\n]+/); + if (startsWithBlankString) { + // Offset the selection start to the first non-blank line's first non-blank character, since + // Some browsers treat selection up to the start of the line as including the end of the + // previous line + selectionStart += startsWithBlankString[0].length; + } selectionEnd = endNewlineIndex; selection = textarea.value.substring(selectionStart, selectionEnd); } @@ -98,6 +114,7 @@ function getSelections(textarea, linesOnly = false) { } return { + processLinesOnly, selectedText: selection, beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace, afterSelection: trailingSpace + textarea.value.substring(selectionEnd) @@ -105,11 +122,11 @@ function getSelections(textarea, linesOnly = false) { } function transformSelection(textarea, transformer, eachLine) { - const { selectedText, beforeSelection, afterSelection } = getSelections(textarea, eachLine), + const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine), // For long comments, record scrollbar position to restore it later { scrollTop } = textarea; - const { newText, caretOffset } = transformer(selectedText); + const { newText, caretOffset } = transformer(selectedText, processLinesOnly); textarea.value = beforeSelection + newText + afterSelection; @@ -120,7 +137,8 @@ function transformSelection(textarea, transformer, eachLine) { textarea.selectionStart = newSelectionStart; textarea.selectionEnd = newSelectionStart; textarea.scrollTop = scrollTop; - textarea.dispatchEvent(new Event('keydown')); + // Needed for automatic textarea resizing + textarea.dispatchEvent(new Event('change')); } function insertLink(textarea, options) { @@ -150,28 +168,31 @@ function wrapSelection(textarea, options) { }); } + newText = prefix + newText + suffix + return { - newText: prefix + newText + suffix, - caretOffset: emptyText ? prefix.length : 0 + newText, + caretOffset: emptyText ? prefix.length : newText.length }; }); } -function wrapLines(textarea, options) { - transformSelection(textarea, (selectedText) => { - const { text = selectedText, prefix = '', suffix = '' } = options, +function wrapLines(textarea, options, eachLine = true) { + transformSelection(textarea, (selectedText, processLinesOnly) => { + const { text = selectedText, singleWrap = false } = options, + prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', + suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', emptyText = text === ''; - let newText = prefix; + let newText = singleWrap + ? prefix + text.trim() + suffix + : text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n'); - if (!emptyText) { - newText = text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n'); - } - else { - newText += suffix; - } + return { newText, caretOffset: emptyText ? prefix.length : newText.length }; + }, eachLine); +} - return { newText, caretOffset: newText.length }; - }); +function wrapSelectionOrLines(textarea, options) { + wrapLines(textarea, options, /\n/); } function escapeSelection(textarea, options) { @@ -224,15 +245,6 @@ function setupToolbar() { $$('.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 93104b5a..757ef52b 100644 --- a/assets/js/preview.js +++ b/assets/js/preview.js @@ -4,7 +4,6 @@ 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) { @@ -37,39 +36,21 @@ function commentReply(user, url, textarea, quote) { textarea.focus(); } -/** - * Stores the abort controller for the current preview request - * @type {null|AbortController} - */ -let previewAbortController = null; - -function getPreview(body, anonymous, previewLoading, previewContent) { +function getPreview(body, anonymous, previewLoading, previewIdle, previewContent) { const path = '/posts/preview'; if (typeof body !== 'string') return; - const trimmedBody = body.trim(); - if (trimmedBody.length < 1) { - previewContent.innerHTML = ''; - return; - } - showEl(previewLoading); + hideEl(previewIdle); - // Abort previous requests if it exists - if (previewAbortController) previewAbortController.abort(); - previewAbortController = new AbortController(); - - fetchJson('POST', path, { body, anonymous }, previewAbortController.signal) + fetchJson('POST', path, { body, anonymous }) .then(handleError) .then(data => { previewContent.innerHTML = data; filterNode(previewContent); - showEl(previewContent); + showEl(previewIdle); hideEl(previewLoading); - }) - .finally(() => { - previewAbortController = null; }); } @@ -99,34 +80,41 @@ function setupPreviews() { textarea = document.querySelector('.js-preview-description'); } - const previewLoading = document.querySelector('.communication-preview__loading'); - const previewContent = document.querySelector('.communication-preview__content'); + const previewButton = document.querySelector('a[data-click-tab="preview"]'); + const previewLoading = document.querySelector('.js-preview-loading'); + const previewIdle = document.querySelector('.js-preview-idle'); + const previewContent = document.querySelector('.js-preview-content'); const previewAnon = document.querySelector('.js-preview-anonymous') || false; if (!textarea || !previewContent) { return; } + const getCacheKey = () => { + return (previewAnon && previewAnon.checked ? 'anon;' : '') + textarea.value; + } + + const previewedTextAttribute = 'data-previewed-text'; const updatePreview = () => { - getPreview(textarea.value, previewAnon && previewAnon.checked, previewLoading, previewContent); + const cachedValue = getCacheKey() + if (previewContent.getAttribute(previewedTextAttribute) === cachedValue) return; + previewContent.setAttribute(previewedTextAttribute, cachedValue); + + getPreview(textarea.value, previewAnon && previewAnon.checked, previewLoading, previewIdle, previewContent); }; - const debouncedUpdater = debounce(500, () => { - if (previewContent.previewedText === textarea.value) return; - previewContent.previewedText = textarea.value; - - updatePreview(); - }); - - textarea.addEventListener('keydown', debouncedUpdater); - textarea.addEventListener('focus', debouncedUpdater); + previewButton.addEventListener('click', updatePreview); textarea.addEventListener('change', resizeTextarea); textarea.addEventListener('keyup', resizeTextarea); - // Fire handler if textarea contains text on page load (e.g. editing) - if (textarea.value) textarea.dispatchEvent(new Event('keydown')); + // Fire handler for automatic resizing if textarea contains text on page load (e.g. editing) + if (textarea.value) textarea.dispatchEvent(new Event('change')); - previewAnon && previewAnon.addEventListener('click', updatePreview); + previewAnon && previewAnon.addEventListener('click', () => { + if (previewContent.classList.contains('hidden')) return; + + updatePreview(); + }); document.addEventListener('click', event => { if (event.target && event.target.closest('.post-reply')) { diff --git a/assets/js/utils/events.js b/assets/js/utils/events.js index 606702c3..51c46a13 100644 --- a/assets/js/utils/events.js +++ b/assets/js/utils/events.js @@ -22,22 +22,3 @@ export function delegate(node, event, selectors) { } }); } - -/** - * Runs the provided `func` if it hasn't been called for at least `time` ms - * @template {(...any[]) => any} T - * @param {number} time - * @param {T} func - * @return {T} - */ -export function debounce(time, func) { - let timerId = null; - - return function(...args) { - // Cancels the setTimeout method execution - timerId && clearTimeout(timerId); - - // Executes the func after delay time. - timerId = setTimeout(() => func(...args), time); - }; -} diff --git a/assets/js/utils/requests.js b/assets/js/utils/requests.js index ec232903..da0a1524 100644 --- a/assets/js/utils/requests.js +++ b/assets/js/utils/requests.js @@ -2,7 +2,7 @@ * Request Utils */ -function fetchJson(verb, endpoint, body, signal) { +function fetchJson(verb, endpoint, body) { const data = { method: verb, credentials: 'same-origin', @@ -11,7 +11,6 @@ function fetchJson(verb, endpoint, body, signal) { 'x-csrf-token': window.booru.csrfToken, 'x-requested-with': 'xmlhttprequest' }, - signal, }; if (body) { diff --git a/lib/philomena_web/templates/markdown/_input.html.slime b/lib/philomena_web/templates/markdown/_input.html.slime index ad806cdb..96f10bb7 100644 --- a/lib/philomena_web/templates/markdown/_input.html.slime +++ b/lib/philomena_web/templates/markdown/_input.html.slime @@ -3,25 +3,22 @@ - action_icon = assigns[:action_icon] || 'edit' - field_name = assigns[:name] || :body - field_placeholder = assigns[:placeholder] || "Your message" -.block__content.block--split - .block__column--full.js-preview-input-wrapper - .block__column__header - i.fa> class="fa-#{action_icon}" - = action_text +.block__header.block__header--js-tabbed + a.selected href="#" data-click-tab="write" + i.fa> class="fa-#{action_icon}" + = action_text - = render PhilomenaWeb.MarkdownView, "_help.html", conn: @conn - = render PhilomenaWeb.MarkdownView, "_toolbar.html", conn: @conn + a href="#" data-click-tab="preview" + i.fa.fa-cog.fa-fw.fa-spin.js-preview-loading.hidden> title=raw('Loading preview…') + i.fa.fa-eye.fa-fw.js-preview-idle> + | Preview - .field - = textarea form, field_name, class: "input input--wide input--text input--resize-vertical js-toolbar-input js-preview-input", placeholder: field_placeholder, required: true - = error_tag form, field_name +.block__tab.communication-edit__tab.selected.js-preview-input-wrapper data-tab="write" + = render PhilomenaWeb.MarkdownView, "_help.html", conn: @conn + = render PhilomenaWeb.MarkdownView, "_toolbar.html", conn: @conn - .block__column--half.hidden.communication-preview.js-preview-output-wrapper - .block__column__header.flex.flex--spaced-out - span - i.fa.fa-eye> - ' Preview - span.communication-preview__loading.hidden - i.fa.fa-spin.fa-fw.fa-cog> title=raw('Loading preview…') + .field + = textarea form, field_name, class: "input input--wide input--text input--resize-vertical js-toolbar-input js-preview-input", placeholder: field_placeholder, required: true + = error_tag form, field_name - .communication-preview__content +.block__tab.communication-edit__tab.hidden.js-preview-content data-tab="preview"