Commit 0b426860 authored by Яков's avatar Яков
Browse files

update image

parent a123742c
{ {
"name": "react-ag-qeditor", "name": "react-ag-qeditor",
"version": "1.1.35", "version": "1.1.36",
"description": "WYSIWYG html editor", "description": "WYSIWYG html editor",
"author": "atma", "author": "atma",
"license": "MIT", "license": "MIT",
......
...@@ -8,17 +8,18 @@ const {Text} = Typography; ...@@ -8,17 +8,18 @@ const {Text} = Typography;
const MIN_WIDTH = 60; const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd'; const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right', 'text']; const ALIGN_OPTIONS = ['left', 'center', 'right'];
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => { const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => {
const imgRef = useRef(null); const imgRef = useRef(null);
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const [showAlignMenu, setShowAlignMenu] = useState(false);
const isInitialized = useRef(false); const isInitialized = useRef(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [altModalVisible, setAltModalVisible] = useState(false); const [altModalVisible, setAltModalVisible] = useState(false);
const [tempAlt, setTempAlt] = useState(node.attrs.alt || ''); const [tempAlt, setTempAlt] = useState(node.attrs.alt || '');
const [tempFrontAlt, setTempFrontAlt] = useState(node.attrs.frontAlt || ''); const [tempFrontAlt, setTempFrontAlt] = useState(node.attrs.frontAlt || '');
// wrap=false + left/right: outer wrapper is full-width block, inner div holds image+handles
const isNoWrap = !node.attrs.wrap && (node.attrs.align === 'left' || node.attrs.align === 'right');
// Добавляем прозрачный нулевой пробел после изображения // Добавляем прозрачный нулевой пробел после изображения
...@@ -370,57 +371,70 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -370,57 +371,70 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
console.warn('getPos() failed:', e) console.warn('getPos() failed:', e)
} }
}, 50); }, 50);
setShowAlignMenu(false);
}; };
// Стили для обертки изображения // Внешняя обёртка (NodeViewWrapper): управляет float/block-layout и отступами
const getWrapperStyle = () => { const getOuterStyle = () => {
const baseStyle = { const { align, wrap, width } = node.attrs;
display: 'inline-block', const w = width ? `${width}px` : 'auto';
lineHeight: 0, const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
position: 'relative',
outline: (selected || isResizing) ? `1px dashed ${BORDER_COLOR}` : 'none',
verticalAlign: 'top',
margin: '0.5rem 0',
};
if (node.attrs.align === 'center') { if (align === 'center') {
return { ...sharedMargin, display: 'block', lineHeight: 0 };
}
if (!wrap) {
// no-wrap: float:left + width:100% — занимает всю строку, текст не может встать рядом.
// Используем float (а не display:block) чтобы layout-алгоритм был одинаковым
// с wrap=true и не было прыжка при переключении обтекания.
return { return {
...baseStyle, ...sharedMargin, lineHeight: 0,
display: 'block', display: 'inline-block',
marginLeft: 'auto', float: 'left',
marginRight: 'auto', clear: 'both',
width: node.attrs.width ? `${node.attrs.width}px` : 'fit-content', width: '100%',
maxWidth: '100%', ...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
textAlign: 'center'
}; };
} }
// wrap: true — узкий float с шириной картинки, текст обтекает
return { return {
...baseStyle, ...sharedMargin, lineHeight: 0,
...(node.attrs.align === 'left' && { display: 'inline-block',
float: 'left', float: align === 'left' ? 'left' : 'right',
marginRight: '1rem', ...(align === 'left' ? { marginRight: '1rem' } : { marginLeft: '1rem' }),
width: node.attrs.width ? `${node.attrs.width}px` : 'auto', width: w, maxWidth: '100%',
maxWidth: '100%',
}),
...(node.attrs.align === 'right' && {
float: 'right',
marginLeft: '1rem',
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
maxWidth: '100%',
}),
...(node.attrs.align === 'text' && {
display: 'inline-block',
float: 'none',
margin: '0 0.2rem',
verticalAlign: 'middle',
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
maxWidth: '100%',
}),
}; };
}; };
// Внутренний контейнер: всегда inline-block — надёжно получает высоту от дочернего img
const getInnerStyle = () => {
const { align, width } = node.attrs;
const w = width ? `${width}px` : 'auto';
const base = {
position: 'relative',
display: 'inline-block',
verticalAlign: 'top',
lineHeight: 0,
outline: (selected || isResizing) ? `1px dashed ${BORDER_COLOR}` : 'none',
width: w,
maxWidth: '100%',
};
if (align === 'center') {
return { ...base, display: 'block', marginLeft: 'auto', marginRight: 'auto',
width: width ? `${width}px` : 'fit-content' };
}
return base;
};
const handleNodeClick = (e) => {
e.stopPropagation();
try {
const pos = getPos?.();
if (typeof pos === 'number') editor.commands.setNodeSelection(pos);
} catch (err) {
console.warn('getPos() failed:', err);
}
};
// Стили для самого изображения // Стили для самого изображения
const getImageStyle = () => ({ const getImageStyle = () => ({
width: node.attrs.width ? `${node.attrs.width}px` : 'auto', width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
...@@ -434,49 +448,16 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -434,49 +448,16 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
objectFit: 'contain' objectFit: 'contain'
}); });
console.log(node.attrs.frontAlt); // Inner content shared between both rendering paths
return ( const imageContent = (
<NodeViewWrapper <>
as="div"
style={getWrapperStyle()}
ref={wrapperRef}
onClick={(e) => {
e.stopPropagation();
try {
const pos = getPos?.()
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos)
}
} catch (e) {
console.warn('getPos() failed:', e)
}
// editor.commands.setNodeSelection(getPos());
}}
contentEditable={false}
data-image-wrapper
>
<img <img
{...node.attrs} {...node.attrs}
ref={imgRef} ref={imgRef}
draggable={true} draggable={true}
style={getImageStyle()} style={getImageStyle()}
// onLoad={() => {
// if (imgRef.current && !isInitialized.current && !node.attrs.width && !node.attrs.height) {
// const { width: editorWidth } = getEditorDimensions();
// const naturalWidth = imgRef.current.naturalWidth;
// const naturalHeight = imgRef.current.naturalHeight;
//
// safeUpdateAttributes({
// width: naturalWidth,
// height: naturalHeight,
// 'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
// });
// isInitialized.current = true;
// }
// }}
/> />
{ {node.attrs.frontAlt?.length > 0 && (
node.attrs.frontAlt?.length > 0 &&
<div <div
style={{ style={{
backgroundColor: '#FDE674', backgroundColor: '#FDE674',
...@@ -495,7 +476,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -495,7 +476,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
whiteSpace: 'pre-line' whiteSpace: 'pre-line'
}} }}
>{node.attrs.frontAlt}</div> >{node.attrs.frontAlt}</div>
} )}
<Button <Button
size="default" size="default"
shape={'circle'} shape={'circle'}
...@@ -506,12 +487,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -506,12 +487,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
setTempFrontAlt(node.attrs.frontAlt || ''); setTempFrontAlt(node.attrs.frontAlt || '');
setAltModalVisible(true); setAltModalVisible(true);
}} }}
style={{ style={{ position: 'absolute', top: 4, right: '30px', zIndex: 15 }}
position: 'absolute',
top: 4,
right: '30px',
zIndex: 15,
}}
> >
<FontSizeOutlined /> <FontSizeOutlined />
</Button> </Button>
...@@ -521,33 +497,21 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -521,33 +497,21 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
danger danger
size="small" size="small"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
const pos = getPos?.();
const pos = getPos?.()
if (typeof pos === 'number') { if (typeof pos === 'number') {
editor.view.dispatch( editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize) editor.view.state.tr.delete(pos, pos + node.nodeSize)
) );
} }
}} }}
style={{ style={{
position: 'absolute', position: 'absolute', top: 4, right: 4, zIndex: 30,
top: 4, backgroundColor: 'white', border: '1px solid #d9d9d9',
right: 4, borderRadius: '50%', width: 20, height: 20,
zIndex: 30, fontSize: 12, lineHeight: 1, padding: '0px 0px 2px 0px', cursor: 'pointer'
backgroundColor: 'white',
border: '1px solid #d9d9d9',
borderRadius: '50%',
width: 20,
height: 20,
fontSize: 12,
lineHeight: 1,
padding: '0px 0px 2px 0px',
cursor: 'pointer'
}} }}
> >×</Button>
×
</Button>
)} )}
{(selected || isResizing) && ( {(selected || isResizing) && (
<Fragment> <Fragment>
...@@ -558,103 +522,126 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -558,103 +522,126 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
onTouchStart={handleResizeStart(dir)} onTouchStart={handleResizeStart(dir)}
style={{ style={{
position: 'absolute', position: 'absolute',
width: 12, width: 12, height: 12,
height: 12,
backgroundColor: BORDER_COLOR, backgroundColor: BORDER_COLOR,
border: '1px solid white', border: '1px solid white',
[dir[0] === 'n' ? 'top' : 'bottom']: -6, [dir[0] === 'n' ? 'top' : 'bottom']: -6,
[dir[1] === 'w' ? 'left' : 'right']: node.attrs.align === 'center' ? '50%' : -6, [dir[1] === 'w' ? 'left' : 'right']: node.attrs.align === 'center' ? '50%' : -6,
transform: node.attrs.align === 'center' ? transform: node.attrs.align === 'center'
`translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none', ? `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
cursor: `${dir}-resize`, cursor: `${dir}-resize`,
zIndex: 10 zIndex: 10
}} }}
/> />
))} ))}
<div style={{
{showAlignMenu && ( position: 'absolute', top: -36, left: '50%',
<div style={{ transform: 'translateX(-50%)',
position: 'absolute', backgroundColor: 'white',
top: -40, boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
left: '50%', borderRadius: 4, padding: 4, zIndex: 20,
transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 2, whiteSpace: 'nowrap',
backgroundColor: 'white', }}>
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', {ALIGN_OPTIONS.map(a => (
borderRadius: 4, <button
padding: 4, type="button"
zIndex: 20, key={a}
display: 'flex' title={a === 'left' ? 'По левому краю' : a === 'center' ? 'По центру' : 'По правому краю'}
}}> onClick={() => handleAlign(a)}
{ALIGN_OPTIONS.map(align => ( style={{
padding: '4px 6px',
background: node.attrs.align === a ? '#e6f7ff' : 'transparent',
border: `1px solid ${node.attrs.align === a ? BORDER_COLOR : '#d9d9d9'}`,
borderRadius: 2, cursor: 'pointer', display: 'flex', alignItems: 'center',
}}
>
{a === 'left' && (
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="4" width="10" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="8" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="12" width="10" height="2" rx="1" fill="currentColor"/>
</svg>
)}
{a === 'center' && (
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="3" y="4" width="10" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="8" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="3" y="12" width="10" height="2" rx="1" fill="currentColor"/>
</svg>
)}
{a === 'right' && (
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="6" y="4" width="10" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="8" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="6" y="12" width="10" height="2" rx="1" fill="currentColor"/>
</svg>
)}
</button>
))}
{node.attrs.align !== 'center' && (
<>
<div style={{ width: 1, background: '#d9d9d9', alignSelf: 'stretch', margin: '0 2px' }} />
<button <button
type="button" type="button"
key={align} title={node.attrs.wrap ? 'Обтекание включено' : 'Обтекание выключено'}
onClick={() => handleAlign(align)} onClick={(e) => {
e.stopPropagation();
safeUpdateAttributes({ wrap: !node.attrs.wrap });
requestAnimationFrame(() => {
try {
const pos = getPos?.();
if (typeof pos === 'number') editor.commands.setNodeSelection(pos);
} catch {}
});
}}
style={{ style={{
margin: '0 2px', padding: '4px 6px',
padding: '10px 8px', background: node.attrs.wrap ? '#e6f7ff' : 'transparent',
background: node.attrs.align === align ? '#e6f7ff' : 'transparent', border: `1px solid ${node.attrs.wrap ? BORDER_COLOR : '#d9d9d9'}`,
border: '1px solid #d9d9d9', borderRadius: 2, cursor: 'pointer', fontSize: 11,
borderRadius: 2, display: 'flex', alignItems: 'center', gap: 3,
cursor: 'pointer'
}} }}
> >
{align} <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
<rect x="9" y="0" width="7" height="2" rx="1" fill="currentColor"/>
<rect x="9" y="4" width="5" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="9" width="16" height="2" rx="1" fill="currentColor"/>
<rect x="0" y="12" width="12" height="2" rx="1" fill="currentColor"/>
</svg>
Обтекание
</button> </button>
))} </>
</div> )}
)} </div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowAlignMenu(!showAlignMenu);
}}
style={{
position: 'absolute',
top: -30,
left: 'calc(50% - 6px)',
transform: 'translateX(-50%)',
backgroundColor: 'white',
border: `1px solid ${BORDER_COLOR}`,
borderRadius: 4,
padding: '8px 8px',
cursor: 'pointer',
fontSize: 12,
zIndex: 10
}}
>
Align
</button>
</Fragment> </Fragment>
)} )}
<Modal <Modal
title="Текст на картинке" title="Текст на картинке"
open={altModalVisible} open={altModalVisible}
onOk={() => { onOk={() => { updateAttributes({ alt: tempAlt, frontAlt: tempFrontAlt }); setAltModalVisible(false); }}
updateAttributes({ alt: tempAlt, frontAlt: tempFrontAlt });
setAltModalVisible(false);
}}
onCancel={() => setAltModalVisible(false)} onCancel={() => setAltModalVisible(false)}
okText="Применить" okText="Применить"
cancelText="Отмена" cancelText="Отмена"
> >
<div style={{marginBottom: '5px'}}><Text>Лицевая сторона</Text></div> <div style={{marginBottom: '5px'}}><Text>Лицевая сторона</Text></div>
<TextArea <TextArea value={tempFrontAlt} onChange={(e) => setTempFrontAlt(e.target.value)} rows={4} placeholder="Введите текст" />
value={tempFrontAlt}
onChange={(e) => setTempFrontAlt(e.target.value)}
rows={4}
placeholder="Введите текст"
/>
<div style={{marginTop: '15px', marginBottom: '5px'}}><Text>Обратная сторона</Text></div> <div style={{marginTop: '15px', marginBottom: '5px'}}><Text>Обратная сторона</Text></div>
<TextArea <TextArea value={tempAlt} onChange={(e) => setTempAlt(e.target.value)} rows={4} placeholder="Введите текст" />
value={tempAlt}
onChange={(e) => setTempAlt(e.target.value)}
rows={4}
placeholder="Введите текст"
/>
</Modal> </Modal>
</>
);
// Единая структура NodeViewWrapper > div > content — img всегда на одной глубине,
// поэтому при смене выравнивания React не размонтирует img и не перезагружает его.
return (
<NodeViewWrapper as="div" style={getOuterStyle()} contentEditable={false} data-image-wrapper>
<div ref={wrapperRef} style={getInnerStyle()} onClick={handleNodeClick}>
{imageContent}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
...@@ -704,6 +691,11 @@ const ResizableImageExtension = TipTapImage.extend({ ...@@ -704,6 +691,11 @@ const ResizableImageExtension = TipTapImage.extend({
parseHTML: element => element.getAttribute('data-align') || 'left', parseHTML: element => element.getAttribute('data-align') || 'left',
renderHTML: attributes => ({ 'data-align': attributes.align }) renderHTML: attributes => ({ 'data-align': attributes.align })
}, },
wrap: {
default: false,
parseHTML: element => element.getAttribute('data-wrap') === 'true',
renderHTML: attributes => attributes.wrap ? { 'data-wrap': 'true' } : {}
},
'data-node-id': { 'data-node-id': {
default: null, default: null,
parseHTML: element => element.getAttribute('data-node-id'), parseHTML: element => element.getAttribute('data-node-id'),
...@@ -722,18 +714,20 @@ const ResizableImageExtension = TipTapImage.extend({ ...@@ -722,18 +714,20 @@ const ResizableImageExtension = TipTapImage.extend({
} = HTMLAttributes; } = HTMLAttributes;
const align = node.attrs.align || 'left'; const align = node.attrs.align || 'left';
const wrap = node.attrs.wrap || false;
const style = []; const style = [];
if (align === 'center') { if (align === 'center') {
style.push('display: block', 'margin-left: auto', 'margin-right: auto'); style.push('display: block', 'margin-left: auto', 'margin-right: auto');
} else if (align === 'left') { } else if (align === 'left') {
style.push('float: left', 'margin-right: 1rem'); wrap
? style.push('float: left', 'margin-right: 1rem')
: style.push('display: block', 'margin-right: auto');
} else if (align === 'right') { } else if (align === 'right') {
style.push('float: right', 'margin-left: 1rem'); wrap
} else if (align === 'text') { ? style.push('float: right', 'margin-left: 1rem')
style.push('display: inline-block', 'vertical-align: middle', 'margin: 0 0.2rem'); : style.push('display: block', 'margin-left: auto');
} }
if (width) style.push(`width: ${width}px`); if (width) style.push(`width: ${width}px`);
......
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