mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
WIP
This commit is contained in:
parent
42817c5199
commit
3f67d9e8bb
7 changed files with 275 additions and 36 deletions
45
client/package-lock.json
generated
45
client/package-lock.json
generated
|
@ -69,6 +69,7 @@
|
|||
"react-i18next": "^15.5.1",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-mentions": "^4.4.10",
|
||||
"react-photoswipe-gallery": "^2.2.7",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.30.0",
|
||||
|
@ -12296,6 +12297,31 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"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": {
|
||||
"version": "6.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz",
|
||||
|
@ -12677,6 +12703,12 @@
|
|||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
|
|
|
@ -140,6 +140,7 @@
|
|||
"react-i18next": "^15.5.1",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-mentions": "^4.4.10",
|
||||
"react-photoswipe-gallery": "^2.2.7",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.30.0",
|
||||
|
|
|
@ -4,15 +4,17 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { MentionsInput, Mention } from 'react-mentions';
|
||||
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import UserAvatar, { Sizes } from '../../users/UserAvatar/UserAvatar';
|
||||
|
||||
import styles from './Add.module.scss';
|
||||
|
||||
|
@ -27,9 +29,25 @@ const Add = React.memo(() => {
|
|||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
const [selectTextFieldState, selectTextField] = useToggle();
|
||||
|
||||
const [textFieldRef, handleTextFieldRef] = useNestedRef();
|
||||
const textFieldRef = React.createRef();
|
||||
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 cleanData = {
|
||||
...data,
|
||||
|
@ -37,7 +55,7 @@ const Add = React.memo(() => {
|
|||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textFieldRef.current.select();
|
||||
textFieldRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -71,12 +89,15 @@ const Add = React.memo(() => {
|
|||
[submit],
|
||||
);
|
||||
|
||||
const handleAwayClick = useCallback(() => {
|
||||
const handleAwayClick = useCallback((event) => {
|
||||
if (event?.target?.closest?.('.mentions-input')) {
|
||||
return;
|
||||
}
|
||||
setIsOpened(false);
|
||||
}, []);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
textFieldRef.current.focus();
|
||||
textFieldRef.current?.focus();
|
||||
}, [textFieldRef]);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
|
@ -85,6 +106,18 @@ const Add = React.memo(() => {
|
|||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
const users = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
|
||||
const handleFormFieldChange = useCallback(
|
||||
(event, newValue) => {
|
||||
handleFieldChange(null, {
|
||||
name: 'text',
|
||||
value: newValue,
|
||||
});
|
||||
},
|
||||
[handleFieldChange],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (isOpened) {
|
||||
activateEscapeInterceptor();
|
||||
|
@ -94,26 +127,41 @@ const Add = React.memo(() => {
|
|||
}, [isOpened]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
textFieldRef.current.focus();
|
||||
textFieldRef.current?.focus();
|
||||
}, [selectTextFieldState]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleTextFieldRef}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
<div className={styles.field}>
|
||||
<MentionsInput
|
||||
inputRef={textFieldRef}
|
||||
value={data.text}
|
||||
placeholder={t('common.writeComment')}
|
||||
maxLength={1048576}
|
||||
minRows={isOpened ? 3 : 1}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
className="mentions-input"
|
||||
style={mentionsInputStyle}
|
||||
onFocus={handleFieldFocus}
|
||||
onChange={handleFormFieldChange}
|
||||
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 && (
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
|
|
|
@ -16,7 +16,17 @@
|
|||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
margin-bottom: 8px !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
|
||||
:global(.mentions-input) {
|
||||
width: 100%;
|
||||
|
||||
textarea {
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
|
@ -32,4 +42,68 @@
|
|||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ import { StaticUserIds } from '../../../constants/StaticUsers';
|
|||
|
||||
import styles from './UserAvatar.module.scss';
|
||||
|
||||
const Sizes = {
|
||||
export const Sizes = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
|
|
|
@ -23,6 +23,7 @@ import { emojiDefs } from '@gravity-ui/markdown-editor/_/bundle/emoji';
|
|||
/* eslint-enable import/no-unresolved */
|
||||
|
||||
import link from './link';
|
||||
import mentions from './mentions';
|
||||
|
||||
export default [
|
||||
ins,
|
||||
|
@ -41,4 +42,5 @@ export default [
|
|||
meta,
|
||||
deflist,
|
||||
link,
|
||||
mentions,
|
||||
];
|
||||
|
|
69
client/src/configs/markdown-plugins/mentions.js
Normal file
69
client/src/configs/markdown-plugins/mentions.js
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue