Commit 78d6243d authored by Яков's avatar Яков
Browse files

add button-url

parent 47ffb026
...@@ -12,7 +12,7 @@ import TableCell from '@tiptap/extension-table-cell' ...@@ -12,7 +12,7 @@ import TableCell from '@tiptap/extension-table-cell'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header' import TableHeader from '@tiptap/extension-table-header'
import Focus from '@tiptap/extension-focus' import Focus from '@tiptap/extension-focus'
import { Input, Modal, Form, Button, message } from "antd"; import { Input, Modal, Form, Button, message, Select as AntdSelect } from "antd";
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
// import Image from '@tiptap/extension-image' // import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align' import TextAlign from '@tiptap/extension-text-align'
...@@ -38,6 +38,7 @@ import TableExtension from './extensions/TableExtension' ...@@ -38,6 +38,7 @@ import TableExtension from './extensions/TableExtension'
import ToggleBlock from './extensions/ToggleBlock' import ToggleBlock from './extensions/ToggleBlock'
import InteractiveImage from './extensions/InteractiveImage' import InteractiveImage from './extensions/InteractiveImage'
import FontSize from './extensions/FontSize' import FontSize from './extensions/FontSize'
import ButtonLinkExtension from './extensions/ButtonLink'
// import Image from '@tiptap/extension-image' // import Image from '@tiptap/extension-image'
// import ImageResize from 'tiptap-extension-resize-image'; // import ImageResize from 'tiptap-extension-resize-image';
...@@ -52,6 +53,7 @@ import { isMobile } from 'react-device-detect' ...@@ -52,6 +53,7 @@ import { isMobile } from 'react-device-detect'
import { ExportPdf } from './extensions/ExportPdf' import { ExportPdf } from './extensions/ExportPdf'
import { mergeAttributes } from "@tiptap/core"; import { mergeAttributes } from "@tiptap/core";
import Upload from "rc-upload"; import Upload from "rc-upload";
import { NodeSelection } from 'prosemirror-state'
// const CustomImage = Image.extend({ // const CustomImage = Image.extend({
// options: {inline: true}, // options: {inline: true},
...@@ -92,6 +94,34 @@ const QEditor = ({ ...@@ -92,6 +94,34 @@ const QEditor = ({
}) => { }) => {
global.uploadUrl = uploadOptions.url global.uploadUrl = uploadOptions.url
const defaultButtonLinkData = {
text: 'Перейти',
href: '',
fontSize: '16px',
textColor: '#ffffff',
backgroundColor: '#1790FF'
}
const getSelectedButtonLinkData = () => {
if (!editor || !(editor.state.selection instanceof NodeSelection)) {
return null
}
const selectedNode = editor.state.selection.node
if (!selectedNode || selectedNode.type.name !== 'buttonLink') {
return null
}
return {
text: selectedNode.attrs.text || defaultButtonLinkData.text,
href: selectedNode.attrs.href || '',
fontSize: selectedNode.attrs.fontSize || defaultButtonLinkData.fontSize,
textColor: selectedNode.attrs.textColor || defaultButtonLinkData.textColor,
backgroundColor: selectedNode.attrs.backgroundColor || defaultButtonLinkData.backgroundColor
}
}
const [innerModalType, setInnerModalType] = useState(null) const [innerModalType, setInnerModalType] = useState(null)
const [embedContent, setEmbedContent] = useState('') const [embedContent, setEmbedContent] = useState('')
const [uploaderUid, setUploaderUid] = useState('uid' + new Date()) const [uploaderUid, setUploaderUid] = useState('uid' + new Date())
...@@ -106,6 +136,7 @@ const QEditor = ({ ...@@ -106,6 +136,7 @@ const QEditor = ({
const [oldFocusFromTo, setOldFocusFromTo] = useState(null) const [oldFocusFromTo, setOldFocusFromTo] = useState(null)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [recordType, setRecordType] = useState({video: true}) const [recordType, setRecordType] = useState({video: true})
const [buttonLinkData, setButtonLinkData] = useState(defaultButtonLinkData)
let formRef = useRef(null); let formRef = useRef(null);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
...@@ -167,6 +198,11 @@ const QEditor = ({ ...@@ -167,6 +198,11 @@ const QEditor = ({
} }
const modalOpener = (type, title) => { const modalOpener = (type, title) => {
if (type === 'buttonLink') {
const selectedButtonLinkData = getSelectedButtonLinkData()
setButtonLinkData(selectedButtonLinkData || defaultButtonLinkData)
}
setModalTitle(title) setModalTitle(title)
setInnerModalType(type) setInnerModalType(type)
setModalIsOpen(true) setModalIsOpen(true)
...@@ -203,12 +239,42 @@ const QEditor = ({ ...@@ -203,12 +239,42 @@ const QEditor = ({
'#ffe672' '#ffe672'
] ]
} }
const fontSizes = ['default', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px'] const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px']
const validateLinkHref = (href) => {
if (!href || typeof href !== 'string') {
return false
}
try {
const normalizedHref = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)
? href
: `https://${href}`
const parsedUrl = new URL(normalizedHref)
return ['http:', 'https:'].includes(parsedUrl.protocol)
} catch (error) {
return false
}
}
const normalizeLinkHref = (href) => {
if (!href || typeof href !== 'string') {
return ''
}
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ? href : `https://${href}`
}
const toolsLib = { const toolsLib = {
link: { link: {
title: 'Вставить ссылку', title: 'Вставить ссылку',
onClick: () => { onClick: () => {
const selection = {
from: editor.state.selection.from,
to: editor.state.selection.to,
empty: editor.state.selection.empty
}
const previousUrl = editor.getAttributes('link').href const previousUrl = editor.getAttributes('link').href
const url = window.prompt('Введите URL', previousUrl) const url = window.prompt('Введите URL', previousUrl)
...@@ -219,14 +285,41 @@ const QEditor = ({ ...@@ -219,14 +285,41 @@ const QEditor = ({
// empty // empty
if (url === '') { if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run() editor.chain().focus().setTextSelection({ from: selection.from, to: selection.to }).extendMarkRange('link').unsetLink().run()
return
}
const normalizedUrl = normalizeLinkHref(url)
if (!validateLinkHref(normalizedUrl)) {
window.alert('Некорректный URL')
return
}
if (selection.empty) {
editor.chain().focus().setTextSelection(selection.from).insertContent({
type: 'text',
text: url,
marks: [
{
type: 'link',
attrs: {
href: normalizedUrl,
target: '_blank'
}
}
]
}).run()
return return
} }
// update link editor.chain().focus().setTextSelection({ from: selection.from, to: selection.to }).extendMarkRange('link').setLink({href: normalizedUrl, target: '_blank'}).run()
editor.chain().focus().extendMarkRange('link').setLink({href: url, target: '_blank'}).run()
} }
}, },
buttonLink: {
title: 'Кнопка-ссылка',
onClick: () => modalOpener('buttonLink', 'Добавить кнопку')
},
file: { file: {
title: 'Прикрепить файл', title: 'Прикрепить файл',
onClick: () => modalOpener('file', 'Прикрепить файл') onClick: () => modalOpener('file', 'Прикрепить файл')
...@@ -539,7 +632,7 @@ const QEditor = ({ ...@@ -539,7 +632,7 @@ const QEditor = ({
linkOnPaste: true, linkOnPaste: true,
defaultProtocol: 'https', defaultProtocol: 'https',
protocols: ['http', 'https'], protocols: ['http', 'https'],
validate: (href)=> console.log(href), validate: validateLinkHref,
}), }),
Video, Video,
Iframe, Iframe,
...@@ -565,6 +658,7 @@ const QEditor = ({ ...@@ -565,6 +658,7 @@ const QEditor = ({
}), }),
TextStyle, TextStyle,
FontSize, FontSize,
ButtonLinkExtension,
Color.configure({ Color.configure({
types: ['textStyle'] types: ['textStyle']
}), }),
...@@ -769,6 +863,100 @@ const QEditor = ({ ...@@ -769,6 +863,100 @@ const QEditor = ({
{getUploader({accept: '*', afterParams: ['no_convert=1']})} {getUploader({accept: '*', afterParams: ['no_convert=1']})}
</Fragment> </Fragment>
) )
case 'buttonLink':
return (
<div className='atma-editor-button-link-form'>
<label className='atma-editor-field'>
<span>Текст кнопки</span>
<Input
value={buttonLinkData.text}
placeholder='Перейти'
onChange={(event) => {
setButtonLinkData({
...buttonLinkData,
text: event.target.value
})
}}
/>
</label>
<label className='atma-editor-field'>
<span>Ссылка</span>
<Input
value={buttonLinkData.href}
placeholder='https://example.com'
onChange={(event) => {
setButtonLinkData({
...buttonLinkData,
href: event.target.value
})
}}
/>
</label>
<div className='atma-editor-button-link-grid'>
<label className='atma-editor-field atma-editor-field-select'>
<span>Размер шрифта</span>
<AntdSelect
value={buttonLinkData.fontSize}
options={fontSizes.map((fontSize) => ({
value: fontSize,
label: fontSize
}))}
getPopupContainer={(triggerNode) => triggerNode.parentNode}
dropdownStyle={{ zIndex: 100003 }}
onChange={(value) => {
setButtonLinkData({
...buttonLinkData,
fontSize: value
})
}}
/>
</label>
<label className='atma-editor-field atma-editor-field-color'>
<span>Цвет текста</span>
<input
type='color'
value={buttonLinkData.textColor}
className='atma-editor-color-input'
onChange={(event) => {
setButtonLinkData({
...buttonLinkData,
textColor: event.target.value
})
}}
/>
</label>
<label className='atma-editor-field atma-editor-field-color'>
<span>Цвет кнопки</span>
<input
type='color'
value={buttonLinkData.backgroundColor}
className='atma-editor-color-input'
onChange={(event) => {
setButtonLinkData({
...buttonLinkData,
backgroundColor: event.target.value
})
}}
/>
</label>
</div>
<div className='atma-editor-button-link-preview-wrap'>
<span>Предпросмотр</span>
<a
href='/'
onClick={(event) => event.preventDefault()}
className='atma-editor-button-link-preview'
style={{
color: buttonLinkData.textColor,
backgroundColor: buttonLinkData.backgroundColor,
fontSize: buttonLinkData.fontSize || '16px'
}}
>
{buttonLinkData.text || 'Кнопка'}
</a>
</div>
</div>
)
case 'voicemessage': case 'voicemessage':
return ( return (
<Fragment> <Fragment>
...@@ -1063,6 +1251,9 @@ const QEditor = ({ ...@@ -1063,6 +1251,9 @@ const QEditor = ({
) )
isDisabled = ! regex.test(embedContent) isDisabled = ! regex.test(embedContent)
break break
case 'buttonLink':
isDisabled = !buttonLinkData.text.trim() || !validateLinkHref(buttonLinkData.href)
break
} }
return isDisabled return isDisabled
...@@ -1084,6 +1275,7 @@ const QEditor = ({ ...@@ -1084,6 +1275,7 @@ const QEditor = ({
clearBlobUrl() clearBlobUrl()
setUploaderUid(`uid${new Date()}`) setUploaderUid(`uid${new Date()}`)
setUploadedPaths([]) setUploadedPaths([])
setButtonLinkData(defaultButtonLinkData)
setModalIsOpen(false) setModalIsOpen(false)
} }
}, },
...@@ -1096,6 +1288,7 @@ const QEditor = ({ ...@@ -1096,6 +1288,7 @@ const QEditor = ({
clearBlobUrl() clearBlobUrl()
setUploaderUid(`uid${new Date()}`) setUploaderUid(`uid${new Date()}`)
setUploadedPaths([]) setUploadedPaths([])
setButtonLinkData(defaultButtonLinkData)
setModalIsOpen(false) setModalIsOpen(false)
} }
} }
...@@ -1317,6 +1510,26 @@ const QEditor = ({ ...@@ -1317,6 +1510,26 @@ const QEditor = ({
).run() ).run()
}) })
break break
case 'buttonLink':
{
const buttonLinkAttrs = {
text: buttonLinkData.text.trim(),
href: normalizeLinkHref(buttonLinkData.href),
fontSize: buttonLinkData.fontSize.trim() || '16px',
textColor: buttonLinkData.textColor,
backgroundColor: buttonLinkData.backgroundColor
}
if (editor.state.selection instanceof NodeSelection
&& editor.state.selection.node
&& editor.state.selection.node.type.name === 'buttonLink'
) {
editor.chain().focus().updateAttributes('buttonLink', buttonLinkAttrs).run()
} else {
editor.chain().focus().setButtonLink(buttonLinkAttrs).insertContent(' ').run()
}
}
break
} }
setModalIsOpen(false) setModalIsOpen(false)
clearBlobUrl() clearBlobUrl()
...@@ -1324,6 +1537,7 @@ const QEditor = ({ ...@@ -1324,6 +1537,7 @@ const QEditor = ({
setEmbedContent('') setEmbedContent('')
setUploadedPaths([]) setUploadedPaths([])
setModalTitle('') setModalTitle('')
setButtonLinkData(defaultButtonLinkData)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
setModalIsOpen(false) setModalIsOpen(false)
...@@ -1332,6 +1546,7 @@ const QEditor = ({ ...@@ -1332,6 +1546,7 @@ const QEditor = ({
setEmbedContent('') setEmbedContent('')
setUploadedPaths([]) setUploadedPaths([])
setModalTitle('') setModalTitle('')
setButtonLinkData(defaultButtonLinkData)
} }
} }
}, },
...@@ -1348,6 +1563,14 @@ const QEditor = ({ ...@@ -1348,6 +1563,14 @@ const QEditor = ({
typpyOptions={{followCursor: true}} typpyOptions={{followCursor: true}}
editor={editor} editor={editor}
shouldShow={({...o}) => { shouldShow={({...o}) => {
if (o.state.selection instanceof NodeSelection) {
const selectedNode = o.state.selection.node
if (selectedNode && selectedNode.type && selectedNode.type.name === 'buttonLink') {
return false
}
}
let items = [] let items = []
if ( if (
o.from !== o.to && o.from !== o.to &&
......
...@@ -44,6 +44,7 @@ const toolsInit = { ...@@ -44,6 +44,7 @@ const toolsInit = {
items: [ items: [
'emoji', 'emoji',
'link', 'link',
'buttonLink',
'file', 'file',
'image', 'image',
'interactiveImage', 'interactiveImage',
......
import { Node } from '@tiptap/core'
import { Plugin } from 'prosemirror-state'
import { NodeSelection } from 'prosemirror-state'
const ButtonLink = Node.create({
name: 'buttonLink',
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
text: {
default: 'Кнопка'
},
href: {
default: ''
},
target: {
default: '_blank'
},
textColor: {
default: '#ffffff'
},
backgroundColor: {
default: '#1790FF'
},
fontSize: {
default: '16px'
}
}
},
parseHTML() {
return [
{
tag: 'a[data-button-link="true"]',
getAttrs: (element) => ({
text: element.textContent || 'Кнопка',
href: element.getAttribute('href') || '',
target: element.getAttribute('target') || '_blank',
textColor: element.getAttribute('data-text-color') || '#ffffff',
backgroundColor: element.getAttribute('data-background-color') || '#1790FF',
fontSize: element.getAttribute('data-font-size') || '16px'
})
}
]
},
renderHTML({ HTMLAttributes }) {
const {
text,
href,
target,
textColor,
backgroundColor,
fontSize
} = HTMLAttributes
return [
'a',
{
href,
target,
rel: 'noopener noreferrer nofollow',
'data-button-link': 'true',
'data-text-color': textColor,
'data-background-color': backgroundColor,
'data-font-size': fontSize,
style: [
'display: inline-block',
'padding: 10px 18px',
'border-radius: 10px',
'text-decoration: none',
`font-size: ${fontSize}`,
`color: ${textColor}`,
`background-color: ${backgroundColor}`
].join('; ')
},
text
]
},
addCommands() {
return {
setButtonLink: (attrs) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs
})
}
}
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleDOMEvents: {
click: (view, event) => {
const target = event.target instanceof HTMLElement
? event.target.closest('a[data-button-link="true"]')
: null
if (!target || !view.dom.contains(target)) {
return false
}
event.preventDefault()
return true
},
touchstart: (view, event) => {
const target = event.target instanceof HTMLElement
? event.target.closest('a[data-button-link="true"]')
: null
if (!target || !view.dom.contains(target)) {
return false
}
event.preventDefault()
return true
},
mousedown: (view, event) => {
const target = event.target instanceof HTMLElement
? event.target.closest('a[data-button-link="true"]')
: null
if (!target || !view.dom.contains(target)) {
return false
}
const position = view.posAtDOM(target, 0)
const node = view.state.doc.nodeAt(position)
if (!node || node.type.name !== this.name) {
return false
}
event.preventDefault()
view.dispatch(
view.state.tr.setSelection(
NodeSelection.create(view.state.doc, position)
)
)
view.focus()
return true
}
},
handleClickOn: (view, pos, node, nodePos, event, direct) => {
if (!direct || node.type.name !== this.name) {
return false
}
event.preventDefault()
const transaction = view.state.tr.setSelection(
NodeSelection.create(view.state.doc, nodePos)
)
view.dispatch(transaction)
view.focus()
return true
}
}
})
]
}
})
export default ButtonLink
...@@ -529,6 +529,15 @@ body{ ...@@ -529,6 +529,15 @@ body{
} }
} }
a[data-button-link="true"]{
cursor: pointer;
}
.ProseMirror-selectednode[data-button-link="true"]{
outline: 2px solid #1790FF;
outline-offset: 2px;
}
} }
&-bubble{ &-bubble{
...@@ -637,6 +646,68 @@ body{ ...@@ -637,6 +646,68 @@ body{
} }
} }
&-field{
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
span{
font-size: 13px;
color: #4f4f4f;
}
&-color{
align-items: flex-start;
}
}
&-color-input{
width: 72px;
min-width: 72px;
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
padding: 3px;
}
&-button-link{
&-form{
min-width: 420px;
}
&-grid{
display: grid;
grid-template-columns: minmax(0, 1.3fr) repeat(2, auto);
gap: 16px;
align-items: start;
}
&-preview-wrap{
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
span{
font-size: 13px;
color: #4f4f4f;
}
}
&-preview{
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-start;
min-height: 42px;
border-radius: 10px;
padding: 10px 18px;
text-decoration: none;
}
}
&-uploader{ &-uploader{
&-drop{ &-drop{
display: flex; display: flex;
...@@ -913,6 +984,9 @@ body{ ...@@ -913,6 +984,9 @@ body{
.qicon{ .qicon{
&.qbuttonLink{
background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='2.25' y='4' width='13.5' height='10' rx='2.6' stroke='%231D1D1F' stroke-width='1.4'/%3E%3Cpath d='M5.2 9H8.8' stroke='%231D1D1F' stroke-width='1.4' stroke-linecap='round'/%3E%3Cpath d='M10.3 7.4H11.7C12.42 7.4 13 7.98 13 8.7C13 9.42 12.42 10 11.7 10H10.3' stroke='%231D1D1F' stroke-width='1.2' stroke-linecap='round'/%3E%3Cpath d='M9.8 10.9L11.1 9.6' stroke='%231D1D1F' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
}
&.qfontSize{ &.qfontSize{
background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.2 13.8L6.84 5.4H7.98L10.62 13.8H9.51L8.88 11.76H5.91L5.28 13.8H4.2ZM6.15 10.86H8.64L7.41 6.84L6.15 10.86ZM11.58 13.8V8.43H12.6V9.12C13.02 8.58 13.59 8.31 14.34 8.31C14.73 8.31 15.09 8.4 15.39 8.58C15.69 8.76 15.93 9.03 16.08 9.39C16.23 9.75 16.32 10.17 16.32 10.65C16.32 11.16 16.23 11.61 16.05 12C15.87 12.39 15.6 12.69 15.24 12.9C14.88 13.11 14.49 13.2 14.07 13.2C13.47 13.2 12.99 12.99 12.66 12.6V13.8H11.58ZM12.6 10.74C12.6 11.37 12.72 11.82 12.96 12.12C13.2 12.42 13.5 12.57 13.89 12.57C14.28 12.57 14.61 12.42 14.88 12.09C15.15 11.76 15.27 11.28 15.27 10.62C15.27 10.02 15.15 9.57 14.91 9.27C14.67 8.97 14.37 8.82 14.01 8.82C13.65 8.82 13.32 8.97 13.05 9.3C12.75 9.6 12.6 10.08 12.6 10.74Z' fill='%231D1D1F'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.2 13.8L6.84 5.4H7.98L10.62 13.8H9.51L8.88 11.76H5.91L5.28 13.8H4.2ZM6.15 10.86H8.64L7.41 6.84L6.15 10.86ZM11.58 13.8V8.43H12.6V9.12C13.02 8.58 13.59 8.31 14.34 8.31C14.73 8.31 15.09 8.4 15.39 8.58C15.69 8.76 15.93 9.03 16.08 9.39C16.23 9.75 16.32 10.17 16.32 10.65C16.32 11.16 16.23 11.61 16.05 12C15.87 12.39 15.6 12.69 15.24 12.9C14.88 13.11 14.49 13.2 14.07 13.2C13.47 13.2 12.99 12.99 12.66 12.6V13.8H11.58ZM12.6 10.74C12.6 11.37 12.72 11.82 12.96 12.12C13.2 12.42 13.5 12.57 13.89 12.57C14.28 12.57 14.61 12.42 14.88 12.09C15.15 11.76 15.27 11.28 15.27 10.62C15.27 10.02 15.15 9.57 14.91 9.27C14.67 8.97 14.37 8.82 14.01 8.82C13.65 8.82 13.32 8.97 13.05 9.3C12.75 9.6 12.6 10.08 12.6 10.74Z' fill='%231D1D1F'/%3E%3C/svg%3E");
} }
...@@ -1409,8 +1483,21 @@ body{ ...@@ -1409,8 +1483,21 @@ body{
color: #8c8c8c; color: #8c8c8c;
font-size: 13px; font-size: 13px;
} }
.atma-editor-field-select {
width: 110px
}
@media (max-width: 768px) { @media (max-width: 768px) {
.atma-editor-button-link-form{
min-width: 0;
}
.atma-editor-button-link-grid{
grid-template-columns: 1fr 1fr;
}
.atma-editor-field-color{
align-items: flex-start;
}
.atma-editor-toolbar{ .atma-editor-toolbar{
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment