Add markdown toolbar to comments (#18062)
* show the toolbar (WIP - includes some hacks and todos) * allow calling component to pass additional toolbar elements * add templates behaviour * get templates working * tweaks following mention autocomplete refactor * remove image upload handler no longer needed * rename method * some small refactors * only comment text area controls visibility of templates area * fix error in role name * fix mobile view * update specs
This commit is contained in:
parent
04dba9d05c
commit
2f436731a7
14 changed files with 201 additions and 261 deletions
|
|
@ -329,7 +329,7 @@ function handleFocus(event) {
|
|||
form.classList.add("comment-form--initiated");
|
||||
}
|
||||
handleSizeChange(event);
|
||||
window.Forem.initializeMentionAutocompleteTextArea(area);
|
||||
window.Forem.initializeEnhancedCommentTextArea(area);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -487,71 +487,6 @@ function generateUploadFormdata(image) {
|
|||
return formData;
|
||||
}
|
||||
|
||||
function handleImageUpload(event, randomIdNumber) {
|
||||
var commentableId = document.getElementById('comments-container').dataset.commentableId;
|
||||
event.preventDefault();
|
||||
document.getElementById('image-upload-' + randomIdNumber).click();
|
||||
document.getElementById('image-upload-' + randomIdNumber).onchange = function (e) {
|
||||
var image = document.getElementById('image-upload-' + randomIdNumber).files;
|
||||
if (image.length > 0) {
|
||||
document.getElementById("image-upload-file-label-" + randomIdNumber).style.color = '#888888';
|
||||
document.getElementById("image-upload-file-label-" + randomIdNumber).innerHTML = "Uploading...";
|
||||
document.getElementById("image-upload-submit-" + randomIdNumber).value = "uploading";
|
||||
setTimeout(function () {
|
||||
document.getElementById("image-upload-submit-" + randomIdNumber).click(function () { });
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("image-upload-submit-" + randomIdNumber).onclick = function (e) {
|
||||
e.preventDefault();
|
||||
var image = document.getElementById('image-upload-' + randomIdNumber).files;
|
||||
if (image.length > 0) {
|
||||
getCsrfToken()
|
||||
.then(sendFetch("image-upload", generateUploadFormdata(image)))
|
||||
.then(function (response) {
|
||||
if (response.status === 200) {
|
||||
response.json().then(
|
||||
function uploadImageCb(json) {
|
||||
var address = document.getElementById("uploaded-image-" + randomIdNumber);
|
||||
var button = document.getElementById("image-upload-button-" + randomIdNumber);
|
||||
var messageContainer = document.getElementById("image-upload-file-label-" + randomIdNumber)
|
||||
// button.style.display = "none";
|
||||
messageContainer.style.display = "none";
|
||||
address.value = json.links[0];
|
||||
address.classList.remove("hidden");;
|
||||
address.select();
|
||||
|
||||
var uploadedMessage = 'Uploaded! Paste into editor';
|
||||
messageContainer.innerHTML = uploadedMessage;
|
||||
messageContainer.style.color = '#00c673';
|
||||
messageContainer.style.position = "relative";
|
||||
messageContainer.style.top = "5px";
|
||||
}
|
||||
);
|
||||
} else if (response.status === 429) {
|
||||
showRateLimitModal({
|
||||
response,
|
||||
element: 'image',
|
||||
action_ing: 'uploading',
|
||||
action_past: 'uploaded',
|
||||
});
|
||||
} else {
|
||||
response.json().then(function(responseBody) {
|
||||
var errorMessage = responseBody.error || 'Invalid file!';
|
||||
document.getElementById("image-upload-file-label-" + randomIdNumber).innerHTML = errorMessage;
|
||||
document.getElementById("image-upload-file-label-" + randomIdNumber).style.color = '#e05252';
|
||||
document.getElementById("image-upload-submit-" + randomIdNumber).style.display = 'none';
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
// there's currently no error handling
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateItemSummaryHtml(item) {
|
||||
var itemSummaryContent = item.getElementsByClassName("js-collapse-comment-content")[0];
|
||||
var usernames = item.getElementsByClassName("js-comment-username");
|
||||
|
|
|
|||
|
|
@ -19,25 +19,6 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
|
|||
<div class="comment-form__inner">
|
||||
<div class="comment-form__field">
|
||||
<textarea id="textarea-for-${parentId}" class="crayons-textfield crayons-textfield--ghost comment-textarea" name="comment[body_markdown]" data-tracking-name="comment_form_textfield" placeholder="Reply..." aria-label="Reply to a comment..." required="required" onkeydown="handleKeyDown(event)" onfocus="handleFocus(event)" oninput="handleChange(event)" onkeyup="handleKeyUp(event)"></textarea>
|
||||
<div class="comment-form__toolbar">
|
||||
<div class="editor-image-upload">
|
||||
<input type="file" id="image-upload-${randomIdNumber}" name="file" accept="image/*" style="display:none">
|
||||
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed" onclick="handleImageUpload(event, ${randomIdNumber})" id="image-upload-button-${randomIdNumber}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="crayons-icon"><path d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"/></svg>
|
||||
<span class="hidden s:inline-block">Upload image</span>
|
||||
</button>
|
||||
<label class="image-upload-file-label" id="image-upload-file-label-${randomIdNumber}"></label>
|
||||
<input type="submit" id="image-upload-submit-${randomIdNumber}" value="Upload" style="display:none">
|
||||
<input class="crayons-textfield fs-s w-auto uploaded-image hidden" type="text" id="uploaded-image-${randomIdNumber}" />
|
||||
</div>
|
||||
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed response-templates-button" title="Use a response template" data-has-listener="false">
|
||||
<svg width="24" height="24" class="crayons-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 18.5V5a3 3 0 013-3h14a1 1 0 011 1v18a1 1 0 01-1 1H6.5A3.5 3.5 0 013 18.5zM19 20v-3H6.5a1.5 1.5 0 100 3H19zM10 4H6a1 1 0 00-1 1v10.337A3.485 3.485 0 016.5 15H19V4h-2v8l-3.5-2-3.5 2V4z"/></svg>
|
||||
<span class="hidden s:inline-block">Templates</span>
|
||||
</button>
|
||||
<a href="/p/editor_guide" class="crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon crayons-btn--s ml-auto" target="_blank" rel="noopener" title="Markdown Guide">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="crayons-icon"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 100-16 8 8 0 000 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response-templates-container crayons-card crayons-card--secondary p-4 mb-4 fs-base comment-form__templates hidden">
|
||||
<header>
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@
|
|||
calc(var(--content-padding-x) * -1) var(--su-6)
|
||||
calc(var(--content-padding-x) * -1);
|
||||
|
||||
.editor-toolbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
@media (min-width: $breakpoint-m) {
|
||||
margin-left: calc(var(--su-2) * -1);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@
|
|||
border-color: var(--form-border-focus);
|
||||
box-shadow: 0 0 0 1px var(--form-border-focus);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
border-top: 1px solid var(--form-border);
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ImageUploader } from './ImageUploader';
|
||||
import { MarkdownToolbar } from '@crayons/MarkdownToolbar';
|
||||
import { MarkdownToolbar, Link } from '@crayons';
|
||||
import HelpIcon from '@images/help.svg';
|
||||
|
||||
export const Toolbar = ({ version, textAreaId }) => {
|
||||
return (
|
||||
|
|
@ -13,7 +14,20 @@ export const Toolbar = ({ version, textAreaId }) => {
|
|||
{version === 'v1' ? (
|
||||
<ImageUploader editorVersion={version} />
|
||||
) : (
|
||||
<MarkdownToolbar textAreaId={textAreaId} />
|
||||
<MarkdownToolbar
|
||||
textAreaId={textAreaId}
|
||||
additionalSecondaryToolbarElements={[
|
||||
<Link
|
||||
key="help-link"
|
||||
block
|
||||
href="/p/editor_guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={HelpIcon}
|
||||
aria-label="Help"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { h } from 'preact';
|
||||
import { h, cloneElement } from 'preact';
|
||||
import { useState, useLayoutEffect, useRef } from 'preact/hooks';
|
||||
import { ImageUploader } from '../../article-form/components/ImageUploader';
|
||||
import {
|
||||
|
|
@ -7,8 +7,7 @@ import {
|
|||
getNewTextAreaValueWithEdits,
|
||||
} from './markdownSyntaxFormatters';
|
||||
import OverflowIcon from '@images/overflow-vertical.svg';
|
||||
import HelpIcon from '@images/help.svg';
|
||||
import { ButtonNew as Button, Link } from '@crayons';
|
||||
import { ButtonNew as Button } from '@crayons';
|
||||
import { KeyboardShortcuts } from '@components/useKeyboardShortcuts';
|
||||
import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery';
|
||||
import { getSelectionData } from '@utilities/textAreaUtils';
|
||||
|
|
@ -58,13 +57,27 @@ const getPreviousMatchingSibling = (element, selector) => {
|
|||
* @param {object} props
|
||||
* @param {string} props.textAreaId The ID of the textarea the markdown formatting should be added to
|
||||
*/
|
||||
export const MarkdownToolbar = ({ textAreaId }) => {
|
||||
export const MarkdownToolbar = ({
|
||||
textAreaId,
|
||||
additionalSecondaryToolbarElements = [],
|
||||
}) => {
|
||||
const textAreaRef = useRef(null);
|
||||
|
||||
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
|
||||
const [storedCursorPosition, setStoredCursorPosition] = useState({});
|
||||
const smallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Medium - 1}px)`);
|
||||
|
||||
// Enhance any additional toolbar elements with the appropriate roles & listeners
|
||||
const additionalSecondaryItems = additionalSecondaryToolbarElements.map(
|
||||
(SecondaryItem) =>
|
||||
cloneElement(SecondaryItem, {
|
||||
role: 'menuitem',
|
||||
className: 'overflow-menu-btn',
|
||||
tabindex: '-1',
|
||||
onKeyUp: (e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn'),
|
||||
}),
|
||||
);
|
||||
|
||||
const markdownSyntaxFormatters = {
|
||||
...coreSyntaxFormatters,
|
||||
...secondarySyntaxFormatters,
|
||||
|
|
@ -325,7 +338,7 @@ export const MarkdownToolbar = ({ textAreaId }) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="editor-toolbar relative"
|
||||
className="editor-toolbar"
|
||||
aria-label="Markdown formatting toolbar"
|
||||
role="toolbar"
|
||||
aria-controls={textAreaId}
|
||||
|
|
@ -382,7 +395,9 @@ export const MarkdownToolbar = ({ textAreaId }) => {
|
|||
|
||||
{smallScreen ? getSecondaryFormatterButtons(false) : null}
|
||||
|
||||
{smallScreen ? null : (
|
||||
{smallScreen ? (
|
||||
additionalSecondaryItems
|
||||
) : (
|
||||
<Button
|
||||
id="overflow-menu-button"
|
||||
onClick={() => setOverflowMenuOpen(!overflowMenuOpen)}
|
||||
|
|
@ -403,18 +418,7 @@ export const MarkdownToolbar = ({ textAreaId }) => {
|
|||
className="crayons-dropdown flex p-2 min-w-unset right-0 top-100"
|
||||
>
|
||||
{getSecondaryFormatterButtons(true)}
|
||||
<Link
|
||||
block
|
||||
role="menuitem"
|
||||
href="/p/editor_guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={HelpIcon}
|
||||
className="overflow-menu-btn"
|
||||
tabindex="-1"
|
||||
aria-label="Help"
|
||||
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn')}
|
||||
/>
|
||||
{additionalSecondaryItems}
|
||||
</div>
|
||||
)}
|
||||
{textAreaRef.current && (
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe('<MarkdownToolbar />', () => {
|
|||
expect(getByLabelText('Embed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an overflow menu with secondary formatters and help link', async () => {
|
||||
it('should render an overflow menu with secondary formatters', async () => {
|
||||
const { getByLabelText } = render(<MarkdownToolbar />);
|
||||
|
||||
getByLabelText('More options').click();
|
||||
|
|
@ -54,6 +54,25 @@ describe('<MarkdownToolbar />', () => {
|
|||
);
|
||||
expect(getByLabelText('Strikethrough')).toBeInTheDocument();
|
||||
expect(getByLabelText('Line divider')).toBeInTheDocument();
|
||||
expect(getByLabelText('Help')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render any custom secondary toolbar elements', async () => {
|
||||
const exampleLink = <a href="/something">Some link</a>;
|
||||
const exampleButton = <button>Some button</button>;
|
||||
|
||||
const { getByLabelText, getByRole } = render(
|
||||
<MarkdownToolbar
|
||||
additionalSecondaryToolbarElements={[exampleButton, exampleLink]}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByLabelText('More options').click();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByLabelText('Underline')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(getByRole('menuitem', { name: 'Some link' })).toBeInTheDocument();
|
||||
expect(getByRole('menuitem', { name: 'Some button' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
app/javascript/packs/CommentTextArea/CommentTextArea.jsx
Normal file
74
app/javascript/packs/CommentTextArea/CommentTextArea.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { populateTemplates } from '../../responseTemplates/responseTemplates';
|
||||
|
||||
import {
|
||||
AutocompleteTriggerTextArea,
|
||||
MarkdownToolbar,
|
||||
Link,
|
||||
ButtonNew as Button,
|
||||
} from '@crayons';
|
||||
import { fetchSearch } from '@utilities/search';
|
||||
import HelpIcon from '@images/help.svg';
|
||||
import Templates from '@images/templates.svg';
|
||||
|
||||
const getClosestTemplatesContainer = (element) =>
|
||||
element
|
||||
.closest('.comment-form__inner')
|
||||
?.querySelector('.response-templates-container');
|
||||
|
||||
export const CommentTextArea = ({ vanillaTextArea }) => {
|
||||
const [templatesVisible, setTemplatesVisible] = useState(false);
|
||||
|
||||
// Templates appear outside of the comment textarea, but we only want to load this data if it's requested by the user
|
||||
const handleTemplatesClick = ({ target }) => {
|
||||
const templatesContainer = getClosestTemplatesContainer(target);
|
||||
const relatedForm = target.closest('form');
|
||||
|
||||
if (templatesContainer && relatedForm) {
|
||||
populateTemplates(relatedForm, () => {
|
||||
setTemplatesVisible(false);
|
||||
templatesContainer.classList.add('hidden');
|
||||
});
|
||||
templatesContainer.classList.toggle('hidden');
|
||||
setTemplatesVisible(!templatesVisible);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-100 relative">
|
||||
<AutocompleteTriggerTextArea
|
||||
triggerCharacter="@"
|
||||
maxSuggestions={6}
|
||||
searchInstructionsMessage="Type to search for a user"
|
||||
replaceElement={vanillaTextArea}
|
||||
fetchSuggestions={(username) =>
|
||||
fetchSearch('usernames', { username }).then(({ result }) =>
|
||||
result.map((user) => ({ ...user, value: user.username })),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MarkdownToolbar
|
||||
textAreaId={vanillaTextArea.id}
|
||||
additionalSecondaryToolbarElements={[
|
||||
<Button
|
||||
key="templates-btn"
|
||||
onClick={handleTemplatesClick}
|
||||
icon={Templates}
|
||||
aria-label="Show templates"
|
||||
aria-pressed={templatesVisible}
|
||||
/>,
|
||||
<Link
|
||||
key="help-link"
|
||||
block
|
||||
href="/p/editor_guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={HelpIcon}
|
||||
aria-label="Help"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,21 +17,19 @@ window.Forem = {
|
|||
}
|
||||
return this.preactImport;
|
||||
},
|
||||
mentionAutoCompleteImports: undefined,
|
||||
getMentionAutoCompleteImports() {
|
||||
if (!this.mentionAutoCompleteImports) {
|
||||
this.mentionAutoCompleteImports = [
|
||||
import('@crayons/AutocompleteTriggerTextArea'),
|
||||
import('@utilities/search'),
|
||||
this.getPreactImport(),
|
||||
];
|
||||
enhancedCommentTextAreaImport: undefined,
|
||||
getEnhancedCommentTextAreaImports() {
|
||||
if (!this.enhancedCommentTextAreaImport) {
|
||||
this.enhancedCommentTextAreaImport = import(
|
||||
'./CommentTextArea/CommentTextArea'
|
||||
);
|
||||
}
|
||||
|
||||
// We're still returning Promises, but if the they have already been imported
|
||||
// they will now be fulfilled instead of pending, i.e. a network request is no longer made.
|
||||
return Promise.all(this.mentionAutoCompleteImports);
|
||||
return Promise.all([
|
||||
this.enhancedCommentTextAreaImport,
|
||||
this.getPreactImport(),
|
||||
]);
|
||||
},
|
||||
initializeMentionAutocompleteTextArea: async (originalTextArea) => {
|
||||
initializeEnhancedCommentTextArea: async (originalTextArea) => {
|
||||
const parentContainer = originalTextArea.parentElement;
|
||||
|
||||
const alreadyInitialized =
|
||||
|
|
@ -41,21 +39,11 @@ window.Forem = {
|
|||
return;
|
||||
}
|
||||
|
||||
const [{ AutocompleteTriggerTextArea }, { fetchSearch }, { render, h }] =
|
||||
await window.Forem.getMentionAutoCompleteImports();
|
||||
const [{ CommentTextArea }, { render, h }] =
|
||||
await window.Forem.getEnhancedCommentTextAreaImports();
|
||||
|
||||
render(
|
||||
<AutocompleteTriggerTextArea
|
||||
replaceElement={originalTextArea}
|
||||
maxSuggestions={6}
|
||||
fetchSuggestions={(username) =>
|
||||
fetchSearch('usernames', { username }).then(({ result }) =>
|
||||
result.map((user) => ({ ...user, value: user.username })),
|
||||
)
|
||||
}
|
||||
searchInstructionsMessage="Type to search for a user"
|
||||
triggerCharacter="@"
|
||||
/>,
|
||||
<CommentTextArea vanillaTextArea={originalTextArea} />,
|
||||
parentContainer,
|
||||
originalTextArea,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { loadResponseTemplates } from '../responseTemplates/responseTemplates';
|
||||
|
||||
window.InstantClick.on('change', () => {
|
||||
loadResponseTemplates();
|
||||
});
|
||||
|
||||
loadResponseTemplates();
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
/* eslint-disable no-alert */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* global showLoginModal */
|
||||
|
||||
function toggleTemplateTypeButton(form, e) {
|
||||
const { targetType } = e.target.dataset;
|
||||
const activeType = targetType === 'personal' ? 'moderator' : 'personal';
|
||||
|
|
@ -110,7 +106,7 @@ Make sure this is the appropriate comment for the situation.
|
|||
|
||||
This action is not reversible.`;
|
||||
|
||||
function addClickListeners(form) {
|
||||
function addClickListeners(form, onTemplateSelected) {
|
||||
const responsesContainer = form.getElementsByClassName(
|
||||
'response-templates-container',
|
||||
)[0];
|
||||
|
|
@ -126,10 +122,8 @@ function addClickListeners(form) {
|
|||
insertButtons.forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
const { content } = event.target.dataset;
|
||||
// We need to grab the textarea that is not the comment mention auto-complete component
|
||||
const textArea = event.target.form.querySelector(
|
||||
'.comment-textarea:not([role=combobox])',
|
||||
);
|
||||
|
||||
const textArea = event.target.form.querySelector('.comment-textarea');
|
||||
const textAreaReplaceable =
|
||||
textArea.value === null ||
|
||||
textArea.value === '' ||
|
||||
|
|
@ -139,7 +133,7 @@ function addClickListeners(form) {
|
|||
textArea.value = content;
|
||||
textArea.dispatchEvent(new Event('input', { target: textArea }));
|
||||
textArea.focus();
|
||||
responsesContainer.classList.toggle('hidden');
|
||||
onTemplateSelected();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -155,7 +149,7 @@ function addClickListeners(form) {
|
|||
});
|
||||
}
|
||||
|
||||
function fetchResponseTemplates(typeOf, formId) {
|
||||
function fetchResponseTemplates(formId, onTemplateSelected) {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
const typesOf = [
|
||||
|
|
@ -163,8 +157,6 @@ function fetchResponseTemplates(typeOf, formId) {
|
|||
['mod_comment', 'moderator-responses-container'],
|
||||
];
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
|
||||
fetch(`/response_templates`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -201,7 +193,7 @@ function fetchResponseTemplates(typeOf, formId) {
|
|||
}
|
||||
}
|
||||
|
||||
addClickListeners(form);
|
||||
addClickListeners(form, onTemplateSelected);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -230,27 +222,29 @@ function copyData(responsesContainer) {
|
|||
).innerHTML;
|
||||
}
|
||||
|
||||
function loadData(form) {
|
||||
function loadData(form, onTemplateSelected) {
|
||||
form.querySelector('img.loading-img').classList.toggle('hidden');
|
||||
fetchResponseTemplates('personal_comment', form.id);
|
||||
fetchResponseTemplates(form.id, onTemplateSelected);
|
||||
}
|
||||
|
||||
function openButtonCallback(form) {
|
||||
/**
|
||||
* This helper function makes sure the correct templates are inserted into the UI next to the given comment form.
|
||||
*
|
||||
* @param {HTMLElement} form The relevant comment form
|
||||
* @param {Function} onTemplateSelected Callback for when a template is inserted
|
||||
*/
|
||||
export function populateTemplates(form, onTemplateSelected) {
|
||||
const responsesContainer = form.getElementsByClassName(
|
||||
'response-templates-container',
|
||||
)[0];
|
||||
const topLevelData = document.getElementById('response-templates-data');
|
||||
const dataFetched = topLevelData.innerHTML !== '';
|
||||
|
||||
responsesContainer.classList.toggle('hidden');
|
||||
|
||||
const containerHidden = responsesContainer.classList.contains('hidden');
|
||||
|
||||
if (dataFetched && !containerHidden) {
|
||||
if (dataFetched) {
|
||||
copyData(responsesContainer);
|
||||
addClickListeners(form);
|
||||
} else if (!dataFetched && !containerHidden) {
|
||||
loadData(form);
|
||||
addClickListeners(form, onTemplateSelected);
|
||||
} else if (!dataFetched) {
|
||||
loadData(form, onTemplateSelected);
|
||||
}
|
||||
|
||||
const hasBothTemplates =
|
||||
|
|
@ -275,76 +269,3 @@ function openButtonCallback(form) {
|
|||
.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function prepareOpenButton(form) {
|
||||
const button = form.getElementsByClassName('response-templates-button')[0];
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
openButtonCallback(form);
|
||||
});
|
||||
|
||||
button.dataset.hasListener = 'true';
|
||||
}
|
||||
|
||||
function observeForReplyClick() {
|
||||
const config = { childList: true, subtree: true };
|
||||
|
||||
const callback = (mutations) => {
|
||||
const form = Array.from(mutations[0].addedNodes).filter(
|
||||
(node) => node.nodeName === 'FORM',
|
||||
);
|
||||
if (form.length > 0) {
|
||||
prepareOpenButton(form[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
|
||||
const commentTree = document.getElementById('comment-trees-container');
|
||||
if (commentTree) {
|
||||
observer.observe(commentTree, config);
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
window.InstantClick.on('change', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function handleLoggedOut() {
|
||||
document
|
||||
.getElementsByClassName('response-templates-button')[0]
|
||||
?.addEventListener(
|
||||
'click',
|
||||
// eslint-disable-next-line no-undef
|
||||
showLoginModal,
|
||||
);
|
||||
}
|
||||
/* eslint-enable no-alert */
|
||||
/* eslint-enable no-restricted-globals */
|
||||
|
||||
export function loadResponseTemplates() {
|
||||
const { userStatus } = document.body.dataset;
|
||||
|
||||
const form = document.getElementsByClassName('comment-form')[0];
|
||||
|
||||
if (document.getElementById('response-templates-data')) {
|
||||
if (userStatus === 'logged-out') {
|
||||
handleLoggedOut();
|
||||
}
|
||||
if (
|
||||
form &&
|
||||
form.getElementsByClassName('response-templates-button')[0].dataset
|
||||
.hasListener === 'false'
|
||||
) {
|
||||
prepareOpenButton(form);
|
||||
}
|
||||
observeForReplyClick();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div id="response-templates-data" class="hidden"></div>
|
||||
<%= javascript_packs_with_chunks_tag "validateFileInputs", "responseTemplates", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "validateFileInputs", defer: true %>
|
||||
|
||||
<% if @comment.errors.any? %>
|
||||
<div class="crayons-notice crayons-notice--danger" role="alert">
|
||||
|
|
@ -43,27 +43,7 @@
|
|||
required: true,
|
||||
class: "crayons-textfield comment-textarea crayons-textfield--ghost",
|
||||
"aria-label": t("views.comments.write.field.aria_label") %>
|
||||
<div class="comment-form__toolbar">
|
||||
<div class="editor-image-upload">
|
||||
<input type="file" id="image-upload-main" name="file" accept="image/*" style="display:none">
|
||||
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed" onclick="handleImageUpload(event,'main')">
|
||||
<%= crayons_icon_tag(:image) %>
|
||||
<span class="hidden s:inline-block" aria-hidden="false"><%= t("views.comments.write.field.upload") %></span>
|
||||
</button>
|
||||
<label class="image-upload-file-label" id="image-upload-file-label-main"></label>
|
||||
<input type="submit" id='image-upload-submit-main' value="<%= t("views.comments.write.field.submit") %>" style="display:none">
|
||||
<input class="hidden" id="uploaded-image-main" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed response-templates-button" title="<%= t("views.comments.write.field.use") %>" data-has-listener="false" data-form-id="new_comment">
|
||||
<%= crayons_icon_tag(:templates) %>
|
||||
<span class="hidden s:inline-block" aria-hidden="false"><%= t("views.comments.write.field.templates") %></span>
|
||||
</button>
|
||||
|
||||
<a href="/p/editor_guide" class="crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon crayons-btn--s ml-auto" target="_blank" rel="noopener" title="<%= t("views.comments.write.field.guide.title") %>">
|
||||
<%= crayons_icon_tag(:info, title: t("views.comments.write.field.guide.icon")) %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="response-templates-container crayons-card crayons-card--secondary p-4 mb-4 comment-form__templates fs-base hidden">
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
</article>
|
||||
<% else %>
|
||||
<div id="response-templates-data" class="hidden"></div>
|
||||
<%= javascript_packs_with_chunks_tag "responseTemplates", "commentsDisplay", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "commentsDisplay", defer: true %>
|
||||
<header class="p-2 pb-4 m:p-6 m:pb-6 crayons-card crayons-card--secondary crayons-layout__content s:mx-2 m:mx-4 -mb-1 z-0">
|
||||
<% if @commentable %>
|
||||
<h1 class="crayons-subtitle-1 mb-4">
|
||||
|
|
|
|||
|
|
@ -381,7 +381,8 @@ describe('Comment on articles', () => {
|
|||
name: /^Add a comment to the discussion$/i,
|
||||
}).focus(); // Focus activates the Submit button and mini toolbar below a comment textbox
|
||||
|
||||
cy.findByRole('button', { name: /^Use a response template$/i }).click();
|
||||
cy.findByRole('button', { name: 'More options' }).click();
|
||||
cy.findByRole('menuitem', { name: 'Show templates' }).click();
|
||||
|
||||
cy.findByRole('button', { name: /^Insert$/i }).click();
|
||||
|
||||
|
|
@ -617,4 +618,20 @@ describe('Comment on articles', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should enhance the textarea with a markdown toolbar', () => {
|
||||
cy.findByRole('main').within(() => {
|
||||
cy.findByRole('heading', { name: 'Discussion (0)' });
|
||||
|
||||
cy.findByRole('textbox', {
|
||||
name: /^Add a comment to the discussion$/i,
|
||||
})
|
||||
.as('textArea')
|
||||
.focus();
|
||||
cy.findByRole('toolbar').as('toolbar');
|
||||
|
||||
cy.get('@toolbar').findByRole('button', { name: 'Bold' }).click();
|
||||
cy.get('@textArea').should('have.value', '****').should('have.focus');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue