<tosijs-styled-editor>
<tosijs-styled-editor> is a rich text editor web component that does not use
contentEditable, execCommand, or browser selection/range APIs.
Instead, it manages selection and editing entirely through DOM manipulation, giving full control over editing behavior.
Usage
<tosijs-styled-editor widgets="default" localized>
<p>Edit this text! Use the 🌐 menu to switch the interface to Suomi.</p>
<p>It supports <b>bold</b>, <i>italic</i>, and more.</p>
</tosijs-styled-editor>
tosijs-styled-editor {
--editor-ink: #27488c;
--editor-surface: var(--tosi-bg, Canvas);
border: 1px solid var(--editor-edge);
border-radius: 6px;
overflow: hidden;
height: 340px;
resize: vertical;
}
.preview.preview {
padding: 0;
}
// Runs in a real browser against the example above. That matters here: click
// position is resolved by measuring character spans with getBoundingClientRect,
// and happy-dom has no layout — every rect is zero — so a unit test can only
// assert against stubbed geometry. Both bugs below shipped past a green suite.
const editor = await waitFor('tosijs-styled-editor')
const doc = editor.parts.doc
const paragraph = doc.querySelector('p')
function clickAt(x, y, detail = 1) {
for (const type of ['mousemove', 'mousedown', 'mouseup']) {
paragraph.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, detail })
)
}
}
test('click position is resolved from real layout', async () => {
// One test: both steps drive the same editor, and test() bodies run concurrently.
const box = paragraph.getBoundingClientRect()
// Clicking the dead space right of a line puts the caret at the END of it.
clickAt(box.right + 200, box.top + box.height / 2)
const caret = doc.querySelector('input.caret')
expect(caret).not.toBe(null)
const rest = document.createRange()
rest.setStartAfter(caret)
rest.setEnd(paragraph, paragraph.childNodes.length)
expect(rest.toString().replace(/\s+/g, '')).toBe('')
// Double-click selects a word and leaves the caret at the end of it.
clickAt(box.left + 12, box.top + box.height / 2)
clickAt(box.left + 12, box.top + box.height / 2, 2)
const selected = [...doc.querySelectorAll('.selected')]
expect(selected.length).toBeGreaterThan(1)
const held = doc.querySelector('input.caret')
const stranded = selected.filter(
(el) => held.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING
)
expect(stranded.length).toBe(0)
})
How It Works
The editor uses three layers:
- DOM utilities — leaf-node traversal (every operation works with text nodes)
- Selection — custom selection via "spanification" (wrapping chars in spans)
- Commands — extensible command system for formatting
The caret is an actual <input> element, which means mobile browsers
will show their keyboard automatically.
Commands
Commands are invoked via doCommand(commandString):
setText font-weight bold— style selected characterssetBlockType h1— change block typesetBlocks text-align center— style selected blocksupdateUndo undo/updateUndo redo— undo/redo
Multiple commands can be chained with semicolons:
setText font-weight bold; setText font-style italic