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> là 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 ý
shadowRoot.getSelection()không khả dụng trên mọi trình duyệt. Fallbacktarget.getRootNode().getSelection?.() ?? window.getSelection()xử lý trường hợp này.- Copy/paste qua ranh giới shadow có thể cần xử lý
ClipboardEventtùy chỉnh. - Sắp xếp kéo thả cần xử lý sự kiện
dragstart,dragover, vàdropqua các shadow root.