Đến nội dung chính
sang.id.vn
Quay lại

Contenteditable lồng nhau: Dùng Shadow DOM để chuyển editing host

English

Nếu bạn đang xây editor dạng block (kiểu Notion), sớm muộn gì cũng sẽ thử lồng các phần tử contenteditable. Nó không hoạt động như bạn nghĩ.

Vấn đề

<div contenteditable="true" id="editor">
  <div class="block" contenteditable="true">
    <p>Block 1</p>
  </div>
  <div class="block" contenteditable="true">
    <p>Block 2</p>
  </div>
</div>

Hỏng: vùng chọn tràn qua các block, Backspace ở đầu Block 2 gộp nó vào Block 1, phím mũi tên di chuyển liên tục qua dòng text của outer editable thay vì nhảy giữa các block.

Không thể chỉ bỏ contenteditable khỏi container ngoài — sẽ mất drag-and-drop native, undo/redo thống nhất, và hành vi Ctrl+A.

Giải pháp Shadow DOM

Shadow DOM tạo ranh giới đóng gói reset editing host. Một contenteditable="true" bên trong shadow root trở thành editing host độc lập.

Định nghĩa custom element:

class EditorBlock extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          margin: 4px 0;
          padding: 8px;
          border: 1px solid #e0e0e0;
          border-radius: 4px;
        }
        :host(:focus-within) {
          border-color: #4a90d9;
        }
        #content {
          outline: none;
          min-height: 1em;
        }
      </style>
      <div id="content" contenteditable="true"><slot></slot></div>
    `;
  }
}

customElements.define("editor-block", EditorBlock);

Mỗi <editor-block>contenteditable="false" ở bên ngoài (vùng không chỉnh sửa), nhưng contenteditable="true" bên trong shadow root (editing host độc lập):

<div contenteditable="true" id="editor">
  <editor-block contenteditable="false">
    <p>Block 1</p>
  </editor-block>
  <editor-block contenteditable="false">
    <p>Block 2</p>
  </editor-block>
</div>

Cấu trúc:

Outer editor (contenteditable="true")

  ├── editor-block (contenteditable="false")  ← vùng không chỉnh sửa
  │     └── [Shadow Root]
  │           └── #content (contenteditable="true")  ← editing host MỚI
  │                 └── <slot> → light DOM content

  └── editor-block (contenteditable="false")
        └── [Shadow Root]
              └── #content (contenteditable="true")  ← editing host MỚI
                    └── <slot> → light DOM content

Điều hướng bàn phím giữa các block

Cần tự xử lý phím mũi tên vì mỗi block giờ đã bị cô lập:

document.getElementById("editor").addEventListener("keydown", e => {
  const active = document.activeElement;
  if (!active || !active.shadowRoot) return;

  if (e.key === "ArrowDown" || e.key === "Enter") {
    const next = active.nextElementSibling;
    if (next && next.shadowRoot) {
      e.preventDefault();
      const target = next.shadowRoot.getElementById("content");
      target.focus();
      const sel = target.getRootNode().getSelection?.() ?? window.getSelection();
      sel.collapse(target, 0);
    }
  }

  if (e.key === "ArrowUp") {
    const prev = active.previousElementSibling;
    if (prev && prev.shadowRoot) {
      e.preventDefault();
      const target = prev.shadowRoot.getElementById("content");
      target.focus();
      const sel = target.getRootNode().getSelection?.() ?? window.getSelection();
      sel.collapse(target, target.childNodes.length);
    }
  }
});

Lưu ý


Share this post on:

Bài trước
Bẫy float và fixed positioning trong contenteditable
Bài tiếp
Khi nào bạn thực sự cần capture: true trong addEventListener