@swiftbrowser/web 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -11,6 +11,8 @@ import type { TextSelectionRange } from './protocol';
11
11
 
12
12
  /** The selection each bound editor last reported, or was last moved to. */
13
13
  const reported = new WeakMap<HTMLElement, string>();
14
+ /** The text each editor last reported (what the viewer typed): a `text` coming back equal to it is an echo, any other came from outside. */
15
+ const reportedText = new WeakMap<HTMLElement, string>();
14
16
  /** A selection the app set while the editor was not focused: where its caret goes when the app focuses it. */
15
17
  const pending = new WeakMap<HTMLElement, TextSelectionRange>();
16
18
 
@@ -41,14 +43,47 @@ export function wantsDeleteBackward(el: HTMLElement): boolean {
41
43
  return el.dataset['sbDeleteBackward'] !== undefined;
42
44
  }
43
45
 
46
+ /** The editor reported `text` (the viewer typed it). */
47
+ export function noteReportedText(el: HTMLElement, text: string): void {
48
+ reportedText.set(el, text);
49
+ }
50
+
51
+ /**
52
+ * Where a `text` the app sends came from: the echo of what the editor
53
+ * reported, or outside (another person's edit, the app's own change). The
54
+ * caret survives an echo on its own; an outside change moves what is under
55
+ * it, so the caret is moved too (`shiftOffset`), or the app's selection
56
+ * re-applied even when its numbers did not change.
57
+ */
58
+ export function textArrived(el: HTMLElement, text: string): 'echo' | 'remote' {
59
+ return reportedText.get(el) === text ? 'echo' : 'remote';
60
+ }
61
+
62
+ /**
63
+ * Where an offset into `before` is in `after`: the same offset before the
64
+ * change (the common prefix), moved by the change's length after it (the
65
+ * common suffix), and at the change's end inside it.
66
+ */
67
+ export function shiftOffset(before: string, after: string, offset: number): number {
68
+ const shortest = Math.min(before.length, after.length);
69
+ let prefix = 0;
70
+ while (prefix < shortest && before.charCodeAt(prefix) === after.charCodeAt(prefix)) prefix += 1;
71
+ let suffix = 0;
72
+ while (suffix < shortest - prefix && before.charCodeAt(before.length - 1 - suffix) === after.charCodeAt(after.length - 1 - suffix)) suffix += 1;
73
+ if (offset <= prefix) return offset;
74
+ if (offset >= before.length - suffix) return offset + (after.length - before.length);
75
+ return after.length - suffix;
76
+ }
77
+
44
78
  /**
45
79
  * The app sent `range` (or no selection): the range the editor moves to now,
46
80
  * when it is focused; otherwise null, the range kept for `takePendingSelection`
47
81
  * when the app focuses the editor. Nothing for the selection the editor
48
- * itself reported, coming back.
82
+ * itself reported, coming back, unless `force` (the text changed from
83
+ * outside under it, so the same numbers mean a move).
49
84
  */
50
- export function selectionToApply(el: HTMLElement, range: TextSelectionRange | undefined): TextSelectionRange | null {
51
- if (!range || reported.get(el) === key(range)) return null;
85
+ export function selectionToApply(el: HTMLElement, range: TextSelectionRange | undefined, force = false): TextSelectionRange | null {
86
+ if (!range || (!force && reported.get(el) === key(range))) return null;
52
87
  reported.set(el, key(range));
53
88
  if (el.ownerDocument.activeElement === el) return range;
54
89
  pending.set(el, range);
@@ -9,15 +9,36 @@
9
9
  */
10
10
 
11
11
  import type { TextEditorProps, TextSelectionRange } from './protocol';
12
- import { editorBlurred, isSelectionBound, markSelectionBound, noteViewerSelection, selectionToApply, takePendingSelection, viewerFocused } from './editorSelection';
12
+ import { editorBlurred, isSelectionBound, markSelectionBound, noteViewerSelection, selectionToApply, shiftOffset, takePendingSelection, viewerFocused } from './editorSelection';
13
+
14
+ /** A `text` that came from outside, with the editor's value and caret before it was applied (src/editorSelection.ts, `textArrived`). */
15
+ export interface OutsideChange {
16
+ before: string;
17
+ caret: TextSelectionRange | null;
18
+ }
13
19
 
14
20
  export function isPlainEditor(el: unknown): el is HTMLTextAreaElement {
15
21
  return el instanceof HTMLTextAreaElement && el.classList.contains('sb-texteditor');
16
22
  }
17
23
 
18
- /** After the text: a selection the app set (not the viewer's coming back), moved to now, or when the app focuses the editor. */
19
- export function applyPlainEditorSelection(area: HTMLTextAreaElement, props: TextEditorProps): void {
24
+ /**
25
+ * After the text: a selection the app set (not the viewer's coming back),
26
+ * moved to now, or when the app focuses the editor. After a text from
27
+ * outside (`outside`): the app's selection even when its numbers did not
28
+ * change, else the viewer's caret moved past the change (setting a
29
+ * textarea's value puts it at the end otherwise).
30
+ */
31
+ export function applyPlainEditorSelection(area: HTMLTextAreaElement, props: TextEditorProps, outside: OutsideChange | null = null): void {
20
32
  markSelectionBound(area, props.selection !== undefined);
33
+ if (outside) {
34
+ if (props.selection !== undefined) {
35
+ const range = selectionToApply(area, props.selection, true);
36
+ if (range) area.setSelectionRange(range.start, range.end);
37
+ } else if (outside.caret) {
38
+ area.setSelectionRange(shiftOffset(outside.before, area.value, outside.caret.start), shiftOffset(outside.before, area.value, outside.caret.end));
39
+ }
40
+ return;
41
+ }
21
42
  const range = selectionToApply(area, props.selection);
22
43
  if (range) area.setSelectionRange(range.start, range.end);
23
44
  }
package/src/renderer.ts CHANGED
@@ -62,7 +62,7 @@ import { LayoutReporter } from './layoutEvents';
62
62
  import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
63
63
  import { bitmapStore, showBitmap } from './bitmaps';
64
64
  import { clearRichText, runTemplates } from './richText';
65
- import { markDeleteBackward, wantsDeleteBackward } from './editorSelection';
65
+ import { markDeleteBackward, noteReportedText, textArrived, wantsDeleteBackward } from './editorSelection';
66
66
  import { applyPlainEditorSelection, focusPlainEditor, isPlainEditor, listenToPlainEditors } from './plainEditor';
67
67
  import { applyRichEditor, createRichEditor, focusRichEditor, isRichEditor, listenToRichEditors, richEditorText } from './richEditor';
68
68
  import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
@@ -899,6 +899,7 @@ export class Renderer {
899
899
  const id = Number(owner?.dataset[ID_ATTR]);
900
900
  if (!Number.isInteger(id)) return;
901
901
  const value = isRichEditor(input) ? richEditorText(input) : input.value;
902
+ noteReportedText(input, value);
902
903
  this.onEvent({ type: 'text', id, value });
903
904
  };
904
905
 
@@ -3925,16 +3926,25 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
3925
3926
  }
3926
3927
  }
3927
3928
 
3928
- /** Like `applyTextField`: the caret survives an echo of the text the editor shows. */
3929
+ /**
3930
+ * Like `applyTextField`: the caret survives an echo of the text the editor
3931
+ * shows. A text from outside (not what the editor reported) would put the
3932
+ * caret at the end: the editor's value and caret from before are kept for
3933
+ * `applyPlainEditorSelection` to move it past the change.
3934
+ */
3929
3935
  function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
3930
3936
  const text = props.text ?? '';
3931
- if (area.value !== text) area.value = text;
3937
+ const before = area.value;
3938
+ const outside = before !== text && textArrived(area, text) === 'remote';
3939
+ const focused = area.ownerDocument.activeElement === area;
3940
+ const caret = outside && focused ? { start: area.selectionStart, end: area.selectionEnd } : null;
3941
+ if (before !== text) area.value = text;
3932
3942
  // Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
3933
3943
  if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
3934
3944
  else delete area.dataset['sbFocus'];
3935
3945
  markDeleteBackward(area, props.deleteBackward === true);
3936
3946
  // A bound selection, under the rich editor's rules (src/plainEditor.ts).
3937
- applyPlainEditorSelection(area, props);
3947
+ applyPlainEditorSelection(area, props, outside ? { before, caret } : null);
3938
3948
  }
3939
3949
 
3940
3950
  // ---------------------------------------------------------------------------