aboutsummaryrefslogtreecommitdiffstats
path: root/src/rich-text.js
blob: 7003f5cd329a7534ba074c23afe77d1d3d2d3201 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// <coinos-text>: a plain-text field built on contenteditable, standing in
// for <textarea> in the composers.
//
// Why: Android keyboards (Gboard's GIFs, stickers, image suggestions) only
// offer pictures to a field that says it accepts them, and Chrome says so
// for contenteditable — never for <textarea>, where the GIF key sits greyed
// out with "This app does not support images here". So the field is
// editable HTML that behaves, to the code around it, like a textarea:
// `value`, `selectionStart`/`selectionEnd` (read and write),
// `setSelectionRange`, a `placeholder`, Enter handled by the page. Text
// stays plain — pasted formatting is dropped — and any picture that arrives
// (a keyboard GIF, a pasted or dropped image) is handed to `onmedia(file)`
// as a File, the same path as the paperclip, instead of landing in the text.

const BLOCK = new Set(['DIV', 'P', 'LI']);

// The field's text, newlines where the editor put <br> or a new block.
function textOf(root) {
  let out = '';
  const walk = (n) => {
    for (const c of n.childNodes) {
      if (c.nodeType === 3) out += c.data;
      else if (c.nodeName === 'BR') out += '\n';
      else if (c.nodeType === 1) {
        if (BLOCK.has(c.nodeName) && out && !out.endsWith('\n')) out += '\n';
        walk(c);
      }
    }
  };
  walk(root);
  // an editor leaves a lone trailing <br> to hold an empty last line open
  return out.endsWith('\n') && root.lastChild && root.lastChild.nodeName === 'BR' ? out.slice(0, -1) : out;
}

// Text length from the start of `root` up to (node, offset).
function offsetOf(root, node, offset) {
  const r = document.createRange();
  r.selectNodeContents(root);
  try { r.setEnd(node, offset); } catch { return textOf(root).length; }
  const frag = r.cloneContents();
  const div = document.createElement('div');
  div.append(frag);
  let t = textOf(div);
  // a <br> right at the caret counts as the newline it is
  if (div.lastChild && div.lastChild.nodeName === 'BR') t += '\n';
  return t.length;
}

// (node, offset) for a text position; the field is normalised to text nodes
// and <br>s, so that is all this walks.
function pointAt(root, pos) {
  let left = pos;
  for (const c of root.childNodes) {
    if (c.nodeType === 3) {
      if (left <= c.data.length) return [c, left];
      left -= c.data.length;
    } else if (c.nodeName === 'BR') {
      if (left === 0) return [root, [...root.childNodes].indexOf(c)];
      left -= 1;
    }
  }
  return [root, root.childNodes.length];
}

const mediaOf = (dt) => [...((dt && dt.files) || [])].filter((f) => /^(image|video)\//.test(f.type));

class CoinosText extends HTMLElement {
  constructor() {
    super();
    this._pendingValue = null;
    this.addEventListener('paste', (e) => this._onPaste(e));
    this.addEventListener('drop', (e) => {
      const files = mediaOf(e.dataTransfer);
      if (files.length) { e.preventDefault(); this._media(files); }
    });
    this.addEventListener('beforeinput', (e) => this._onBeforeInput(e));
    this.addEventListener('input', () => this._tidy());
  }
  connectedCallback() {
    if (!this.hasAttribute('contenteditable')) this.setAttribute('contenteditable', 'true');
    this.setAttribute('role', 'textbox');
    this.setAttribute('aria-multiline', 'true');
    if (this.hasAttribute('placeholder')) this.setAttribute('aria-placeholder', this.getAttribute('placeholder'));
    this._empty();
  }

  get value() { return textOf(this); }
  set value(v) {
    v = v == null ? '' : String(v);
    if (v === this.value) return;
    // text nodes and <br>s only: what pointAt walks
    this.replaceChildren(...v.split('\n').flatMap((line, i) => [...(i ? [document.createElement('br')] : []), ...(line ? [document.createTextNode(line)] : [])]));
    if (v.endsWith('\n')) this.append(document.createElement('br'));
    this._empty();
  }
  get placeholder() { return this.getAttribute('placeholder') || ''; }
  set placeholder(v) { this.setAttribute('placeholder', v); }

  get selectionStart() { return this._sel()[0]; }
  get selectionEnd() { return this._sel()[1]; }
  set selectionStart(v) { const [, e] = this._sel(); this.setSelectionRange(v, Math.max(v, e)); }
  set selectionEnd(v) { const [s] = this._sel(); this.setSelectionRange(Math.min(s, v), v); }
  _sel() {
    const sel = document.getSelection();
    if (!sel || !sel.rangeCount || !this.contains(sel.anchorNode)) { const n = this.value.length; return [n, n]; }
    const r = sel.getRangeAt(0);
    return [offsetOf(this, r.startContainer, r.startOffset), offsetOf(this, r.endContainer, r.endOffset)];
  }
  setSelectionRange(start, end = start) {
    this.normalize();
    const sel = document.getSelection();
    if (!sel) return;
    const r = document.createRange();
    const [sn, so] = pointAt(this, start);
    const [en, eo] = pointAt(this, end);
    try { r.setStart(sn, so); r.setEnd(en, eo); sel.removeAllRanges(); sel.addRange(r); } catch {}
  }

  // Insert text at the caret as the editor would (undo history intact).
  _insert(text) {
    if (!document.execCommand('insertText', false, text)) {
      const s = this.selectionStart, e = this.selectionEnd, v = this.value;
      this.value = v.slice(0, s) + text + v.slice(e);
      this.setSelectionRange(s + text.length);
      this.dispatchEvent(new Event('input', { bubbles: true }));
    }
  }
  _media(files) {
    for (const f of files) {
      if (typeof this.onmedia === 'function') this.onmedia(f);
    }
  }
  _onPaste(e) {
    const dt = e.clipboardData;
    const files = mediaOf(dt);
    if (files.length) { e.preventDefault(); this._media(files); return; }
    // plain text only: no fonts, links or colours from wherever it was copied
    e.preventDefault();
    const text = dt ? dt.getData('text/plain') : '';
    if (text) this._insert(text.replace(/\r\n?/g, '\n'));
  }
  _onBeforeInput(e) {
    // keyboard-inserted pictures (Android's commitContent) and drops arrive
    // as insertions carrying a DataTransfer
    if (e.dataTransfer) {
      const files = mediaOf(e.dataTransfer);
      if (files.length) { e.preventDefault(); this._media(files); return; }
      if (/^insertFrom/.test(e.inputType)) {
        e.preventDefault();
        const text = e.dataTransfer.getData('text/plain');
        if (text) this._insert(text.replace(/\r\n?/g, '\n'));
        return;
      }
    }
    // a new line is a newline, not a new <div>
    if (e.inputType === 'insertParagraph') { e.preventDefault(); this._insert('\n'); return; }
    if (/^format/.test(e.inputType)) e.preventDefault(); // no bold/italic shortcuts
  }
  // Anything that isn't text or <br> (an <img> a keyboard managed to put
  // in, a styled span) is taken out; a picture goes to onmedia.
  _tidy() {
    for (const img of this.querySelectorAll('img')) {
      const src = img.getAttribute('src');
      img.remove();
      if (src) fetch(src).then((r) => r.blob()).then((b) => {
        if (/^(image|video)\//.test(b.type)) this._media([new File([b], 'pasted.' + (b.type.split('/')[1] || 'png'), { type: b.type })]);
      }).catch(() => {});
    }
    if ([...this.children].some((c) => c.nodeName !== 'BR')) {
      const s = this.selectionStart, v = this.value;
      this.value = v;
      if (document.activeElement === this) this.setSelectionRange(Math.min(s, v.length));
    }
    this._empty();
  }
  _empty() { this.toggleAttribute('data-empty', !this.value); }
}

if (typeof customElements !== 'undefined' && !customElements.get('coinos-text')) {
  customElements.define('coinos-text', CoinosText);
}