1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 05:09:43 +02:00
This commit is contained in:
Roman Zavarnitsyn 2025-05-29 20:55:39 +02:00
parent 42817c5199
commit 3f67d9e8bb
No known key found for this signature in database
GPG key ID: C00677B27F355C04
7 changed files with 275 additions and 36 deletions

View file

@ -69,6 +69,7 @@
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^2.2.7", "react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
@ -12296,6 +12297,31 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
}, },
"node_modules/react-mentions": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/react-mentions/-/react-mentions-4.4.10.tgz",
"integrity": "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "7.4.5",
"invariant": "^2.2.4",
"prop-types": "^15.5.8",
"substyle": "^9.1.0"
},
"peerDependencies": {
"react": ">=16.8.3",
"react-dom": ">=16.8.3"
}
},
"node_modules/react-mentions/node_modules/@babel/runtime": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
"integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.13.2"
}
},
"node_modules/react-onclickoutside": { "node_modules/react-onclickoutside": {
"version": "6.13.2", "version": "6.13.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz",
@ -12677,6 +12703,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp-match-indices": { "node_modules/regexp-match-indices": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz",
@ -14183,6 +14215,19 @@
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="
}, },
"node_modules/substyle": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/substyle/-/substyle-9.4.1.tgz",
"integrity": "sha512-VOngeq/W1/UkxiGzeqVvDbGDPM8XgUyJVWjrqeh+GgKqspEPiLYndK+XRcsKUHM5Muz/++1ctJ1QCF/OqRiKWA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.4",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": ">=16.8.3"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",

View file

@ -140,6 +140,7 @@
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^2.2.7", "react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",

View file

@ -4,15 +4,17 @@
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Button, Form } from 'semantic-ui-react';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { MentionsInput, Mention } from 'react-mentions';
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks'; import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks'; import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
import { isModifierKeyPressed } from '../../../utils/event-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar, { Sizes } from '../../users/UserAvatar/UserAvatar';
import styles from './Add.module.scss'; import styles from './Add.module.scss';
@ -27,9 +29,25 @@ const Add = React.memo(() => {
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle(); const [selectTextFieldState, selectTextField] = useToggle();
const [textFieldRef, handleTextFieldRef] = useNestedRef(); const textFieldRef = React.createRef();
const [buttonRef, handleButtonRef] = useNestedRef(); const [buttonRef, handleButtonRef] = useNestedRef();
const mentionsInputStyle = {
control: {
minHeight: isOpened ? '60px' : '36px',
},
};
const renderSuggestion = useCallback(
(suggestion, search, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={suggestion.id} size={Sizes.TINY} />
<span className={styles.suggestionText}>{highlightedDisplay}</span>
</div>
),
[],
);
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
@ -37,7 +55,7 @@ const Add = React.memo(() => {
}; };
if (!cleanData.text) { if (!cleanData.text) {
textFieldRef.current.select(); textFieldRef.current?.focus();
return; return;
} }
@ -71,12 +89,15 @@ const Add = React.memo(() => {
[submit], [submit],
); );
const handleAwayClick = useCallback(() => { const handleAwayClick = useCallback((event) => {
if (event?.target?.closest?.('.mentions-input')) {
return;
}
setIsOpened(false); setIsOpened(false);
}, []); }, []);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus(); textFieldRef.current?.focus();
}, [textFieldRef]); }, [textFieldRef]);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
@ -85,6 +106,18 @@ const Add = React.memo(() => {
handleClickAwayCancel, handleClickAwayCancel,
); );
const users = useSelector(selectors.selectMembershipsForCurrentBoard);
const handleFormFieldChange = useCallback(
(event, newValue) => {
handleFieldChange(null, {
name: 'text',
value: newValue,
});
},
[handleFieldChange],
);
useDidUpdate(() => { useDidUpdate(() => {
if (isOpened) { if (isOpened) {
activateEscapeInterceptor(); activateEscapeInterceptor();
@ -94,26 +127,41 @@ const Add = React.memo(() => {
}, [isOpened]); }, [isOpened]);
useDidUpdate(() => { useDidUpdate(() => {
textFieldRef.current.focus(); textFieldRef.current?.focus();
}, [selectTextFieldState]); }, [selectTextFieldState]);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <div className={styles.field}>
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <MentionsInput
ref={handleTextFieldRef} inputRef={textFieldRef}
as={TextareaAutosize}
name="text"
value={data.text} value={data.text}
placeholder={t('common.writeComment')} placeholder={t('common.writeComment')}
maxLength={1048576} className="mentions-input"
minRows={isOpened ? 3 : 1} style={mentionsInputStyle}
spellCheck={false}
className={styles.field}
onFocus={handleFieldFocus} onFocus={handleFieldFocus}
onChange={handleFormFieldChange}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onMouseDown={clickAwayProps.onMouseDown}
onTouchStart={clickAwayProps.onTouchStart}
allowSpaceInQuery
singleLine={false}
rows={isOpened ? 3 : 1}
maxLength={1048576}
>
<Mention
trigger="@"
data={users.map((membership) => ({
id: membership.user.id,
display: membership.user.username || membership.user.name,
}))}
markup="@[__display__](__id__)"
appendSpaceOnAdd
displayTransform={(id, display) => `@${display}`}
renderSuggestion={renderSuggestion}
/> />
</MentionsInput>
</div>
{isOpened && ( {isOpened && (
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button

View file

@ -16,7 +16,17 @@
} }
.field { .field {
position: relative;
margin-bottom: 8px !important;
background: #fff; background: #fff;
border-radius: 4px;
:global(.mentions-input) {
width: 100%;
textarea {
background: #fff;
box-shadow: none;
border: 0; border: 0;
box-sizing: border-box; box-sizing: border-box;
color: #333; color: #333;
@ -33,3 +43,67 @@
} }
} }
} }
}
}
:global {
.mentions-input {
width: 100%;
&__suggestions {
position: absolute;
z-index: 1000;
margin-top: 4px;
background: white;
border: 1px solid rgba(34, 36, 38, 0.15);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
&__list {
margin: 0;
padding: 0;
list-style: none;
}
&__item {
display: flex;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
&--focused {
background-color: #f1f8ff;
}
}
}
&__highlighter {
padding: 8px 12px;
font-size: 14px;
font-family: 'Open Sans', sans-serif;
line-height: 1.4;
}
&__control {
position: relative;
}
&__input {
overflow: hidden;
}
}
}
.suggestion {
display: flex;
align-items: center;
gap: 8px;
}
.suggestionText {
flex: 1;
font-size: 14px;
line-height: 1.4;
}

View file

@ -17,7 +17,7 @@ import { StaticUserIds } from '../../../constants/StaticUsers';
import styles from './UserAvatar.module.scss'; import styles from './UserAvatar.module.scss';
const Sizes = { export const Sizes = {
TINY: 'tiny', TINY: 'tiny',
SMALL: 'small', SMALL: 'small',
MEDIUM: 'medium', MEDIUM: 'medium',

View file

@ -23,6 +23,7 @@ import { emojiDefs } from '@gravity-ui/markdown-editor/_/bundle/emoji';
/* eslint-enable import/no-unresolved */ /* eslint-enable import/no-unresolved */
import link from './link'; import link from './link';
import mentions from './mentions';
export default [ export default [
ins, ins,
@ -41,4 +42,5 @@ export default [
meta, meta,
deflist, deflist,
link, link,
mentions,
]; ];

View file

@ -0,0 +1,69 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const mentionsPlugin = (md) => {
const mentionRegex = /@\[(.*?)\]\((.*?)\)/g;
const renderMention = (tokens, idx) => {
const token = tokens[idx];
const { display, userId } = token.meta;
return `<span class="mention" data-user-id="${userId}" style="color: #0366d6; background-color: #f1f8ff; border-radius: 3px; padding: 0 2px;">@${display}</span>`;
};
md.core.ruler.push('mentions', (state) => {
const { tokens } = state;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token.type === 'inline' && token.content) {
const matches = [...token.content.matchAll(mentionRegex)];
if (matches.length > 0) {
const newChildren = [];
let lastIndex = 0;
matches.forEach((match) => {
// Add text before the mention
if (match.index > lastIndex) {
newChildren.push({
type: 'text',
content: token.content.slice(lastIndex, match.index),
level: token.level,
});
}
// Add mention token
newChildren.push({
type: 'mention',
meta: {
display: match[1],
userId: match[2],
},
level: token.level,
});
lastIndex = match.index + match[0].length;
});
// Add remaining text after last mention
if (lastIndex < token.content.length) {
newChildren.push({
type: 'text',
content: token.content.slice(lastIndex),
level: token.level,
});
}
token.children = newChildren;
}
}
}
});
// eslint-disable-next-line no-param-reassign
md.renderer.rules.mention = renderMention;
};
export default mentionsPlugin;