File size: 4,534 Bytes
ff29fba
ad1b8ec
ff29fba
ad1b8ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff29fba
 
 
 
 
 
 
 
 
 
 
ad1b8ec
ff29fba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad1b8ec
 
 
 
 
 
 
 
 
 
 
ff29fba
 
ad1b8ec
 
 
 
 
 
 
ff29fba
ad1b8ec
 
 
 
 
ff29fba
ad1b8ec
 
 
 
 
 
 
 
ff29fba
ad1b8ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff29fba
 
 
 
 
ad1b8ec
 
 
 
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
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { OutputField } from '../types';
import { createBus, KEY_PARAM, OUTPUT_PARAM, type BusMessage } from '../core/bus';
import { OutputView } from './OutputField';

/**
 * The page a popped-out output loads (the hub opens `?uif-output=<id>`).
 * Render it instead of your app when `isOutputWindow()` is true.
 */
export function OutputWindow() {
  const params = useMemo(() => new URLSearchParams(window.location.search), []);
  const id = params.get(OUTPUT_PARAM) ?? '';
  const bus = useMemo(() => createBus(params.get(KEY_PARAM) ?? 'default'), [params]);
  const [field, setField] = useState<OutputField | null>(null);
  const [active, setActive] = useState(false);
  const [flash, setFlash] = useState(0);
  const [lost, setLost] = useState(false);

  // Shown on another display through the Presentation API, the hub may be on another device (a cast receiver):
  // every message also travels over the presentation connections.
  const conns = useRef<{ send(d: string): void; state?: string }[]>([]);
  const post = useCallback(
    (msg: BusMessage) => {
      bus.post(msg);
      conns.current.forEach(c => c.state !== 'closed' && c.state !== 'terminated' && c.send(JSON.stringify(msg)));
    },
    [bus],
  );

  useEffect(() => {
    const receiver = (navigator as Navigator & { presentation?: { receiver?: { connectionList: Promise<{ connections: unknown[]; addEventListener(t: string, f: (e: { connection: unknown }) => void): void }> } } }).presentation?.receiver;
    const onRemote = (fn: (msg: BusMessage) => void) => (c: unknown) => {
      const conn = c as { send(d: string): void; state?: string; addEventListener(t: string, f: (e: { data?: unknown }) => void): void };
      conns.current.push(conn);
      conn.addEventListener('message', e => {
        try {
          fn(JSON.parse(String(e.data)));
        } catch {
          /* not ours */
        }
      });
      conn.send(JSON.stringify({ type: 'out:hello', id } satisfies BusMessage));
    };
    const off = bus.on(msg => handle(msg));
    receiver?.connectionList.then(list => {
      list.connections.forEach(onRemote(handle));
      list.addEventListener('connectionavailable', e => onRemote(handle)(e.connection));
    });
    function handle(msg: BusMessage) {
      if (msg.type === 'hub:hello') return post({ type: 'out:hello', id });
      if (!('id' in msg) || msg.id !== id) return;
      if (msg.type === 'out:state') {
        setField(msg.field);
        setActive(msg.active);
        setLost(false);
      } else if (msg.type === 'out:home') {
        window.close();
      } else if (msg.type === 'out:ping') {
        setFlash(n => n + 1);
        window.focus();
      }
    }
    post({ type: 'out:hello', id });
    // No event fires when a window moves, so report geometry on a timer.
    let last = '';
    const report = () => {
      const geom = { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight };
      const key = JSON.stringify(geom);
      if (key !== last) {
        last = key;
        post({ type: 'out:geom', id, geom });
      }
    };
    report();
    const timer = setInterval(report, 500);
    const noHub = setTimeout(() => setLost(true), 2000);
    const bye = () => post({ type: 'out:bye', id });
    window.addEventListener('pagehide', bye);
    return () => {
      off();
      clearInterval(timer);
      clearTimeout(noHub);
      window.removeEventListener('pagehide', bye);
      bus.close();
    };
  }, [bus, id, post]);

  useEffect(() => {
    if (field) document.title = field.title;
  }, [field?.title]);

  if (!field) {
    return (
      <div className="uif-output uif-output-window">
        <div className="uif-output-empty">{lost ? 'The input field this output belongs to is not open.' : 'Connecting…'}</div>
      </div>
    );
  }

  return (
    <div className={`uif-output uif-output-window ${active ? 'is-active' : ''}`} style={{ '--uif-color': field.color } as React.CSSProperties}>
      <OutputView
        field={field}
        active={active}
        flash={flash}
        popped
        onRename={title => post({ type: 'out:request-rename', id, title })}
        onSubmit={text => post({ type: 'out:input', id, text })}
        onRemember={text => post({ type: 'out:remember', id, text })}
        onHome={() => post({ type: 'out:request-home', id })}
        onClose={() => post({ type: 'out:request-close', id })}
      />
    </div>
  );
}