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

add button-url

parent 47ffb026
......@@ -12,7 +12,7 @@ import TableCell from '@tiptap/extension-table-cell'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
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 Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
......@@ -38,6 +38,7 @@ import TableExtension from './extensions/TableExtension'
import ToggleBlock from './extensions/ToggleBlock'
import InteractiveImage from './extensions/InteractiveImage'
import FontSize from './extensions/FontSize'
import ButtonLinkExtension from './extensions/ButtonLink'
// import Image from '@tiptap/extension-image'
// import ImageResize from 'tiptap-extension-resize-image';
......@@ -52,6 +53,7 @@ import { isMobile } from 'react-device-detect'
import { ExportPdf } from './extensions/ExportPdf'
import { mergeAttributes } from "@tiptap/core";
import Upload from "rc-upload";
import { NodeSelection } from 'prosemirror-state'
// const CustomImage = Image.extend({
// options: {inline: true},
......@@ -92,6 +94,34 @@ const QEditor = ({
}) => {
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 [embedContent, setEmbedContent] = useState('')
const [uploaderUid, setUploaderUid] = useState('uid' + new Date())
......@@ -106,6 +136,7 @@ const QEditor = ({
const [oldFocusFromTo, setOldFocusFromTo] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [recordType, setRecordType] = useState({video: true})
const [buttonLinkData, setButtonLinkData] = useState(defaultButtonLinkData)
let formRef = useRef(null);
// eslint-disable-next-line no-unused-vars
......@@ -167,6 +198,11 @@ const QEditor = ({
}
const modalOpener = (type, title) => {
if (type === 'buttonLink') {
const selectedButtonLinkData = getSelectedButtonLinkData()
setButtonLinkData(selectedButtonLinkData || defaultButtonLinkData)
}
setModalTitle(title)
setInnerModalType(type)
setModalIsOpen(true)
......@@ -203,12 +239,42 @@ const QEditor = ({
'#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 = {
link: {
title: 'Вставить ссылку',
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 url = window.prompt('Введите URL', previousUrl)
......@@ -219,14 +285,41 @@ const QEditor = ({
// empty
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
}
// update link
editor.chain().focus().extendMarkRange('link').setLink({href: url, target: '_blank'}).run()
editor.chain().focus().setTextSelection({ from: selection.from, to: selection.to }).extendMarkRange('link').setLink({href: normalizedUrl, target: '_blank'}).run()
}
},
buttonLink: {
title: 'Кнопка-ссылка',
onClick: () => modalOpener('buttonLink', 'Добавить кнопку')
},
file: {
title: 'Прикрепить файл',
onClick: () => modalOpener('file', 'Прикрепить файл')
......@@ -539,7 +632,7 @@ const QEditor = ({
linkOnPaste: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
validate: (href)=> console.log(href),
validate: validateLinkHref,
}),
Video,
Iframe,
......@@ -565,6 +658,7 @@ const QEditor = ({
}),
TextStyle,
FontSize,
ButtonLinkExtension,
Color.configure({
types: ['textStyle']
}),
......@@ -769,6 +863,100 @@ const QEditor = ({
{getUploader({accept: '*', afterParams: ['no_convert=1']})}
</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':
return (
<Fragment>
......@@ -1063,6 +1251,9 @@ const QEditor = ({
)
isDisabled = ! regex.test(embedContent)
break
case 'buttonLink':
isDisabled = !buttonLinkData.text.trim() || !validateLinkHref(buttonLinkData.href)
break
}
return isDisabled
......@@ -1084,6 +1275,7 @@ const QEditor = ({
clearBlobUrl()
setUploaderUid(`uid${new Date()}`)
setUploadedPaths([])
setButtonLinkData(defaultButtonLinkData)
setModalIsOpen(false)
}
},
......@@ -1096,6 +1288,7 @@ const QEditor = ({
clearBlobUrl()
setUploaderUid(`uid${new Date()}`)
setUploadedPaths([])
setButtonLinkData(defaultButtonLinkData)
setModalIsOpen(false)
}
}
......@@ -1317,6 +1510,26 @@ const QEditor = ({
).run()
})
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)
clearBlobUrl()
......@@ -1324,6 +1537,7 @@ const QEditor = ({
setEmbedContent('')
setUploadedPaths([])
setModalTitle('')
setButtonLinkData(defaultButtonLinkData)
} catch (err) {
console.log(err)
setModalIsOpen(false)
......@@ -1332,6 +1546,7 @@ const QEditor = ({
setEmbedContent('')
setUploadedPaths([])
setModalTitle('')
setButtonLinkData(defaultButtonLinkData)
}
}
},
......@@ -1348,6 +1563,14 @@ const QEditor = ({
typpyOptions={{followCursor: true}}
editor={editor}
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 = []
if (
o.from !== o.to &&
......
......@@ -44,6 +44,7 @@ const toolsInit = {
items: [
'emoji',
'link',
'buttonLink',
'file',
'image',
'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{
}
}
a[data-button-link="true"]{
cursor: pointer;
}
.ProseMirror-selectednode[data-button-link="true"]{
outline: 2px solid #1790FF;
outline-offset: 2px;
}
}
&-bubble{
......@@ -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{
&-drop{
display: flex;
......@@ -913,6 +984,9 @@ body{
.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{
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{
color: #8c8c8c;
font-size: 13px;
}
.atma-editor-field-select {
width: 110px
}
@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{
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