Hiện tại, dự án đã hỗ trợ chèn hình ảnh dạng base64 vào editor và lưu trữ vào file DOCX. Tuy nhiên, hình ảnh khi chèn chỉ hiển thị với kích thước mặc định và luôn có dạng inline block không thể thay đổi kích thước (Resize) hay sắp xếp chữ bao quanh (Text Wrapping).
Giải pháp yêu cầu nâng cấp toàn diện cơ chế xử lý hình ảnh qua 3 thành phần chính:
<img>, cung cấp các nút handle kéo thả thay đổi kích thước và thanh Toolbar mini để chọn chế độ Wrap/Alignment.w:drawing (cả wp:inline và wp:anchor) để chuyển đổi thành các thuộc tính HTML tùy chỉnh (custom attributes) trên thẻ <img>.<img> để cấu hình đối tượng ImageRun trong thư viện docx bao gồm kích thước thực tế (pixels) và cấu hình floating cho việc bọc chữ (Text Wrapping).
Trong ProseMirror / TipTap, để hỗ trợ Text Wrapping (Wrap Text) (bản chất là CSS float: left hoặc float: right), node hình ảnh phải cấu hình là Inline Node (inline: true). Nếu cấu hình là Block Node, nó sẽ luôn được bao bởi thẻ bọc ngoài độc lập và chữ sẽ không bao quanh được hình ảnh theo cách trực quan.
Thẻ <img> trong editor sẽ được quản lý bởi các thuộc tính mở rộng sau để lưu trữ trạng thái hiển thị:
| Thuộc tính HTML | Mô tả | Giá trị hỗ trợ | Ánh xạ sang CSS Webview | Ánh xạ sang XML DOCX (Office OpenXML) |
|---|---|---|---|---|
width / style.width |
Chiều rộng hình ảnh (pixel) | Số nguyên dương (px) | width: {width}px |
wp:extent/@cx (quy đổi EMU) |
height / style.height |
Chiều cao hình ảnh (pixel) | Số nguyên dương (px) | height: {height}px |
wp:extent/@cy (quy đổi EMU) |
data-wrap |
Kiểu bọc chữ xung quanh hình | inline, square, top-bottom |
inline: inline-blocksquare: float left/righttop-bottom: block + clear
|
inline: wp:inlinesquare: wp:anchor/wp:wrapSquaretop-bottom: wp:anchor/wp:wrapTopAndBottom
|
data-align |
Căn lề của hình ảnh | left, center, right |
Căn lề hoặc hướng float tương ứng |
wp:positionH/wp:align hoặc căn lề của paragraph cha (nếu inline).
|
Thay thế import Image mặc định từ @tiptap/extension-image bằng CustomImage mà chúng ta tự định nghĩa.
Khai báo extension kế thừa từ Image, bổ sung các thuộc tính schema (width, height, data-wrap, data-align) và đăng ký NodeView liên kết với component React xử lý hiển thị/resize.
Component React chịu trách nhiệm render giao diện hình ảnh trong editor. Bao gồm các thẻ div resize-handles nằm ở 4 góc và 1 thanh Toolbar popover hiển thị khi hình ảnh được focus.
Sửa hàm convertDrawing để trích xuất kích thước và wrap style từ XML, ghi vào thẻ <img>. Đồng thời cập nhật biểu thức Regex tìm kiếm <img> để không bị lỗi khi thẻ <img> chứa thêm nhiều thuộc tính.
Sửa hàm createImageRun để đọc kích thước, alignment, wrap từ thẻ HTML, sau đó cấu hình đối tượng ImageRun với các tham số transformation và floating tương ứng.
Thêm CSS styling cho các handle resize (.resize-handle), selection border cho hình đang chọn và các CSS class phục vụ hiển thị float wrapping (chữ bao quanh) trong editor.
Tạo file src/webview/extensions/CustomImage.ts:
import Image from '@tiptap/extension-image'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { ImageNodeView } from '../components/ImageNodeView'
export const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: 400,
parseHTML: element => parseInt(element.getAttribute('width') || element.style.width, 10) || 400,
renderHTML: attributes => ({
width: attributes.width,
style: `width: ${attributes.width}px;`
})
},
height: {
default: 300,
parseHTML: element => parseInt(element.getAttribute('height') || element.style.height, 10) || 300,
renderHTML: attributes => ({
height: attributes.height,
style: `height: ${attributes.height}px;`
})
},
align: {
default: 'center',
parseHTML: element => element.getAttribute('data-align') || 'center',
renderHTML: attributes => ({ 'data-align': attributes.align })
},
wrap: {
default: 'inline',
parseHTML: element => element.getAttribute('data-wrap') || 'inline',
renderHTML: attributes => ({ 'data-wrap': attributes.wrap })
}
}
},
inline: true, // Cho phép inline để float hoạt động
group: 'inline',
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView)
}
})
Tạo file src/webview/components/ImageNodeView.tsx:
import React, { useRef } from 'react'
import { NodeViewWrapper, NodeViewProps } from '@tiptap/react'
export const ImageNodeView = ({ node, updateAttributes, selected }: NodeViewProps) => {
const { src, width, height, align, wrap } = node.attrs
const imgRef = useRef<HTMLImageElement>(null)
const handleResize = (direction: string, event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const startX = event.clientX
const startY = event.clientY
const startWidth = width
const startHeight = height
const aspectRatio = startWidth / startHeight
const onMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX
let newWidth = startWidth
if (direction.includes('Right')) {
newWidth = startWidth + deltaX
} else {
newWidth = startWidth - deltaX
}
newWidth = Math.max(50, newWidth) // Kích thước tối thiểu
const newHeight = Math.round(newWidth / aspectRatio)
updateAttributes({
width: newWidth,
height: newHeight
})
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// Tạo các class CSS động dựa trên align và wrap
const wrapperStyle: React.CSSProperties = {
display: wrap === 'inline' ? 'inline-block' : 'block',
float: wrap === 'square' ? (align === 'center' ? 'none' : align) : 'none',
textAlign: wrap === 'top-bottom' ? align : undefined,
margin: '10px',
position: 'relative',
}
return (
<NodeViewWrapper className="image-node-view-wrapper" style={wrapperStyle}>
<div className={`image-container ${selected ? 'is-selected' : ''}`} style={{ display: 'inline-block', position: 'relative' }}>
<img ref={imgRef} src={src} style={{ width: `${width}px`, height: `${height}px`, display: 'block' }} alt="" />
{selected && (
<>
<div className="resize-handle top-left" onMouseDown={e => handleResize('topLeft', e)} />
<div className="resize-handle top-right" onMouseDown={e => handleResize('topRight', e)} />
<div className="resize-handle bottom-left" onMouseDown={e => handleResize('bottomLeft', e)} />
<div className="resize-handle bottom-right" onMouseDown={e => handleResize('bottomRight', e)} />
<!-- Nút bấm nổi (Bubble Menu) thay đổi align / wrap -->
<div className="image-toolbar" contentEditable={false}>
<button onClick={() => updateAttributes({ wrap: 'inline' })} className={wrap === 'inline' ? 'active' : ''}>Inline</button>
<button onClick={() => updateAttributes({ wrap: 'square' })} className={wrap === 'square' ? 'active' : ''}>Wrap Text</button>
<button onClick={() => updateAttributes({ wrap: 'top-bottom' })} className={wrap === 'top-bottom' ? 'active' : ''}>Break Text</button>
|
<button onClick={() => updateAttributes({ align: 'left' })} className={align === 'left' ? 'active' : ''}>Left</button>
<button onClick={() => updateAttributes({ align: 'center' })} className={align === 'center' ? 'active' : ''}>Center</button>
<button onClick={() => updateAttributes({ align: 'right' })} className={align === 'right' ? 'active' : ''}>Right</button>
</div>
</>
)}
</div>
</NodeViewWrapper>
)
}
Thêm các đoạn mã CSS sau vào src/webview/styles/editor.css:
/* Khung viền và handle resize cho hình ảnh khi được chọn */
.image-node-view-wrapper .image-container.is-selected {
outline: 2px solid var(--leandix-accent);
}
.image-node-view-wrapper .resize-handle {
position: absolute;
width: 8px;
height: 8px;
background-color: var(--leandix-accent);
border: 1px solid #fff;
border-radius: 50%;
z-index: 10;
}
.image-node-view-wrapper .resize-handle.top-left { top: -4px; left: -4px; cursor: nwse-resize; }
.image-node-view-wrapper .resize-handle.top-right { top: -4px; right: -4px; cursor: nesw-resize; }
.image-node-view-wrapper .resize-handle.bottom-left { bottom: -4px; left: -4px; cursor: nesw-resize; }
.image-node-view-wrapper .resize-handle.bottom-right { bottom: -4px; right: -4px; cursor: nwse-resize; }
/* Toolbar điều hướng nổi phía trên hình ảnh */
.image-node-view-wrapper .image-toolbar {
position: absolute;
top: -45px;
left: 50%;
transform: translateX(-50%);
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
padding: 4px 8px;
border-radius: 6px;
display: flex;
gap: 6px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.image-node-view-wrapper .image-toolbar button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 11px;
padding: 2px 6px;
cursor: pointer;
border-radius: 4px;
}
.image-node-view-wrapper .image-toolbar button:hover {
background-color: var(--border-color);
color: var(--text-primary);
}
.image-node-view-wrapper .image-toolbar button.active {
background-color: var(--accent);
color: #fff;
}
Trong file src/extension/DocxParser.ts, ta cần sửa hàm convertDrawing. Office OpenXML quản lý kích thước hình ảnh dưới dạng EMU (English Metric Units) trong phần tử <wp:extent cx="..." cy="..."/>.
Tỉ lệ quy đổi tiêu chuẩn là: 1 pixel = 9525 EMUs (ở độ phân giải chuẩn 96 DPI).
function convertDrawing(drawing: XmlElement, ctx: ConversionContext): string {
const blip = findDeep(drawing, 'a:blip');
if (!blip) return '';
const embedId = blip.attribs['r:embed'];
if (!embedId) return '';
return `<img data-rel-id="${embedId}" />`;
}
function convertDrawing(drawing: XmlElement, ctx: ConversionContext): string {
const blip = findDeep(drawing, 'a:blip');
if (!blip) return '';
const embedId = blip.attribs['r:embed'];
if (!embedId) return '';
// 1. Trích xuất Kích thước từ wp:extent
const extent = findDeep(drawing, 'wp:extent');
let width = 400;
let height = 300;
if (extent) {
const cx = parseInt(extent.attribs['cx'], 10);
const cy = parseInt(extent.attribs['cy'], 10);
if (!isNaN(cx)) width = Math.round(cx / 9525);
if (!isNaN(cy)) height = Math.round(cy / 9525);
}
// 2. Trích xuất Text Wrapping & Alignment
let wrap = 'inline';
let align = 'center';
const anchor = findDeep(drawing, 'wp:anchor');
if (anchor) {
if (findDeep(anchor, 'wp:wrapSquare')) wrap = 'square';
else if (findDeep(anchor, 'wp:wrapTight')) wrap = 'tight';
else if (findDeep(anchor, 'wp:wrapTopAndBottom')) wrap = 'top-bottom';
else if (findDeep(anchor, 'wp:wrapNone')) wrap = 'none';
// Đọc căn lề từ vị trí ngang
const posH = findDeep(anchor, 'wp:positionH');
const alignH = posH ? findDeep(posH, 'wp:align') : null;
if (alignH && alignH.children[0]) {
align = alignH.children[0].toString().trim();
}
}
return `<img data-rel-id="${embedId}" data-width="${width}" data-height="${height}" data-align="${align}" data-wrap="${wrap}" />`;
}
Trong DocxParser.ts dòng ~2737 có biểu thức regex khớp cứng:
const imgRegex = /<img data-rel-id="([^"]+)" \/>/g;
Khi ta thêm các thuộc tính kích thước/alignment vào thẻ img, regex này sẽ không bao giờ khớp, dẫn đến mất hình ảnh hoàn toàn sau khi parse.
Cách khắc phục: Sửa regex thành const imgRegex = /<img data-rel-id="([^"]+)"([^>]*)\/>/g;, đồng thời trích xuất các thuộc tính bổ sung từ nhóm capture số 2 và đưa vào thẻ img sau cùng chứa base64 src.
Trong file src/extension/DocxSerializer.ts, ta cập nhật hàm createImageRun để biến đổi thẻ HTML <img> thành một ImageRun có cấu hình kích thước và thuộc tính floating cho việc bao chữ.
function createImageRun(el: Element): ImageRun | null {
const src = getAttributeValue(el, 'src') || ''
const match = src.match(/^data:image\/(.*?);base64,(.*)$/)
if (!match) return null
const imageData = Buffer.from(match[2], 'base64')
return new ImageRun({
data: imageData,
transformation: {
width: 400,
height: 300,
},
type: 'png',
})
}
import {
ImageRun,
TextWrappingType,
TextWrappingSide,
HorizontalPositionRelativeFrom,
HorizontalPositionAlign,
VerticalPositionRelativeFrom,
VerticalPositionAlign
} from 'docx'
function createImageRun(el: Element): ImageRun | null {
const src = getAttributeValue(el, 'src') || ''
const match = src.match(/^data:image\/(.*?);base64,(.*)$/)
if (!match) return null
const imageData = Buffer.from(match[2], 'base64')
// 1. Phân tích Kích thước từ HTML attributes
let width = 400
let height = 300
const wAttr = getAttributeValue(el, 'width')
const hAttr = getAttributeValue(el, 'height')
if (wAttr) width = parseInt(wAttr, 10) || width
if (hAttr) height = parseInt(hAttr, 10) || height
const wrap = getAttributeValue(el, 'data-wrap') || 'inline'
const align = getAttributeValue(el, 'data-align') || 'center'
const imgOptions: any = {
data: imageData,
transformation: { width, height },
type: 'png'
}
// 2. Xử lý Floating (Text Wrapping) trong DOCX
if (wrap !== 'inline') {
let wrapType = TextWrappingType.SQUARE
if (wrap === 'tight') wrapType = TextWrappingType.TIGHT
else if (wrap === 'top-bottom') wrapType = TextWrappingType.TOP_AND_BOTTOM
let floatAlign = HorizontalPositionAlign.CENTER
if (align === 'left') floatAlign = HorizontalPositionAlign.LEFT
else if (align === 'right') floatAlign = HorizontalPositionAlign.RIGHT
imgOptions.floating = {
horizontalPosition: {
relative: HorizontalPositionRelativeFrom.COLUMN,
align: floatAlign,
},
verticalPosition: {
relative: VerticalPositionRelativeFrom.PARAGRAPH,
align: VerticalPositionAlign.TOP,
offset: 0,
},
wrap: {
type: wrapType,
side: TextWrappingSide.BOTH_SIDES,
},
margins: {
top: 144, // 144 dxa = ~10px
bottom: 144,
left: 144,
right: 144,
}
}
}
return new ImageRun(imgOptions)
}
Tính năng cần được xác minh chéo hai chiều để đảm bảo chất lượng hiển thị không bị sai lệch: