Đặc tả Kỹ thuật

Tính năng Resize & Text Wrapping cho Hình ảnh

Đồng bộ hai chiều kích thước và kiểu bao quanh chữ của hình ảnh giữa Webview Editor (TipTap) và định dạng Word (DOCX).
Người viết Antigravity AI Agent
Trạng thái Sẵn sàng để thực hiện (Draft Spec)
Thời gian cập nhật 08/07/2026

1. Tổng quan & Thiết kế Giải pháp

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:

⚠️ Quyết định thiết kế quan trọng: Chuyển Schema Hình ảnh thành Inline Node

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.

2. Đặc tả Cấu trúc Dữ liệu (Schema)

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-block
square: float left/right
top-bottom: block + clear
inline: wp:inline
square: wp:anchor/wp:wrapSquare
top-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).

3. Các File Cần Chỉnh Sửa

src/webview/components/TiptapEditor.tsx

Thay thế import Image mặc định từ @tiptap/extension-image bằng CustomImage mà chúng ta tự định nghĩa.

src/webview/extensions/CustomImage.ts [Tạo Mới]

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.

src/webview/components/ImageNodeView.tsx [Tạo Mới]

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.

src/extension/DocxParser.ts

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.

src/extension/DocxSerializer.ts

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.

src/webview/styles/editor.css

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.

4. Hướng Dẫn Triển Khai Chi Tiết (Hướng Làm)

Bước 4.1: Xây dựng CustomImage Extension & React NodeView

Tạo file src/webview/extensions/CustomImage.ts:

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:

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>
  )
}

Bước 4.2: Tích hợp CSS cho Webview

Thêm các đoạn mã CSS sau vào src/webview/styles/editor.css:

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;
}

Bước 4.3: Xử lý Đọc File Word (DocxParser.ts)

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).

Trước (DocxParser.ts)
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}" />`;
}
Sau (DocxParser.ts)
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}" />`;
}

🚨 Chú ý lỗi Regex khớp thẻ <img>

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.

Bước 4.4: Xử lý Xuất File Word (DocxSerializer.ts)

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ữ.

Trước (DocxSerializer.ts)
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',
  })
}
Sau (DocxSerializer.ts)
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)
}

5. Kế hoạch Kiểm thử & Xác minh

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:

  1. Kiểm thử chiều Import (DOCX → HTML):
    • Tạo một tài liệu Word (bằng MS Word / Google Docs) chứa một hình ảnh đã điều chỉnh kích thước nhỏ lại và được cấu hình bọc chữ (Wrap Text - Square).
    • Mở file DOCX này trong Leandix Editor. Kiểm tra xem hình ảnh hiển thị đúng tỉ lệ nhỏ hơn, và văn bản xung quanh có bao lấy hình bên cạnh hay không.
  2. Kiểm thử chiều Tương tác (Editor Webview):
    • Chọn hình ảnh đã chèn, dùng chuột kéo góc dưới bên phải thu nhỏ/phóng to hình ảnh để xem Live Resize có mượt mà và giữ nguyên tỉ lệ khung hình (Aspect Ratio) không.
    • Chọn các chế độ Wrap Text, Break Text trên Toolbar mini, xem văn bản xung quanh thay đổi vị trí tương ứng tức thời.
  3. Kiểm thử chiều Export (HTML → DOCX):
    • Lưu file sau khi chỉnh sửa trong editor.
    • Mở lại file DOCX vừa lưu bằng MS Word để đối chiếu xem hình ảnh có được thiết lập kiểu wrap và kích thước chính xác y hệt như đã chỉnh sửa trong editor hay không.