clay.js
  1. ClayJS
  2. Reference
  3. Plugins
  4. bind-text

bind-text

Bind a textarea in a freeze region to one record, so long text lives in the record and the editor does not.

Keeps long text in a record’s text element and the editor outside the saved records. Ask for it by name: ?plugins=bind-text.

clay.bindText#

clay.bindText(textarea, { onWrite, onShow, onConflict, onGone }) binds a textarea in a clay="freeze" region to one record: every keystroke is written into the record, a change from elsewhere shows in the textarea with the caret kept, and an IME composition is held until it ends. Two people editing different paragraphs both keep their words. OverType: a markdown editor shows it with OverType.

It returns the binding, { open, close, flush, dispose, current, attach, composing }:

MemberReturnsWhat it does
open(textEl, { key, resolve }){ ok: true } or { ok: false, code }Opens the record whose text element is textEl, showing its text from the start. Opening the same key again rebinds through resolve() without touching the textarea; opening another key flushes the open record first
close(){ ok: true } or { ok: false, code }Flushes the open record and releases it
flush(){ ok: true } or { ok: false, code }Writes text the record has not taken yet and reports whether the record now holds the textarea’s text
dispose(){ ok: true } or { ok: false, code }Detaches the textarea’s listeners and the observer, once the exit barrier has landed. It never flushes
current()the record’s text element, or nullThe record the textarea is bound to
attach(textarea)nothingMoves the binding to a replacement textarea; ignored after dispose()
composing()true or falseWhether an IME composition is running

Refusals#

flush() is how the exit barrier asks whether the page holds text the record does not, so its answer is a result rather than a boolean: { ok: true } when the record holds exactly the textarea’s text, and { ok: false, code } otherwise.

codeMeaning
compositionAn IME composition is running. It is left exactly as it was, flag included, so it finishes through its own operation
conflictThe record changed under the textarea and the two edits could not be merged. onConflict has the typed text, and the refusal stands until that text is in the record
goneresolve() returned nothing, so onGone has the text and the binding has released the record
busyThe mode lifecycle is not admitting writes right now, because a mode transition is running. A standalone page has no lifecycle and always admits

A conflict or a gone record is a refusal, not a success: nothing was represented, so a flush only reports { ok: true } once a resolution or a later flush has put the text in the record. With nothing open, flush() is { ok: true }.

open() and close() return a flush’s refusal instead of going ahead: the text that has not landed, the record it belongs to, its textarea and its observer all stay as they were. A record that was gone has already handed its text to onGone and released itself, so there is nothing left to protect and the switch goes ahead. open() also answers { ok: false, code: "disposed" } after dispose(), and { ok: false, code: "gone" } when reopening the same key finds the record unreachable. dispose() is idempotent: { ok: false, code: "composition" } while a composition runs, { ok: false, code: "pending-input" } while the record has not taken the text, and { ok: true } once detached, including on a second call.

A refused open() leaves the entry that was open still open, so show the selection that is actually in the textarea and retry when the refusal clears:

JavaScript
function showEntry(entry) {
  const result = binding.open(entry.body, { key: entry.id, resolve: () => entry.body });
  markSelected(result.ok ? entry.body : binding.current());
}

// A composition is the refusal that clears itself.
textarea.addEventListener("compositionend", () => showEntry(selectedEntry()));

Callbacks#

CallbackCalled when
onWrite(code)the binding wrote the textarea’s value into the record; code is the record’s text element
onShow()the record’s value was put into the textarea, with the caret kept
onConflict(typed, { key, base, remote })the record changed while the textarea held text the record had not taken, and the two could not be merged
onGone(typed, { key, base })the record the textarea is bound to is gone

The second argument is additive, so a callback written for the string alone keeps working. base is the text both sides were editing, read before the textarea was shown the incoming value: it is what a clash was measured against, and what a caller staging the losing side needs. remote is the record’s new value; key is the key the binding was opened with.

JavaScript
clay.bindText(textarea, {
  onConflict(typed, { key, base }) {
    clay.recovery?.register({ channel: "journal.entries", version: 1 })
      .stage({ key, base, payload: typed });
  }
});

A composition is never forced to end. flush(), close() and open() refuse while one runs and leave it where it is, so nothing a switch does can clear the composing flag or interfere with the input. To journal IME text, listen for input on the textarea instead, which sees the composed text as it is typed. Recovery covers the copy that listener keeps.