<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:

  1. DOM utilities — leaf-node traversal (every operation works with text nodes)
  2. Selection — custom selection via "spanification" (wrapping chars in spans)
  3. 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):

Multiple commands can be chained with semicolons: setText font-weight bold; setText font-style italic