linkshell-cli 0.10.7 → 0.10.9

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.
Files changed (89) hide show
  1. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  2. package/dist/gateway/src/main.js +4 -0
  3. package/dist/gateway/src/main.js.map +1 -1
  4. package/dist/gateway/src/serve.d.ts +3 -1
  5. package/dist/gateway/src/serve.js +5 -1
  6. package/dist/gateway/src/serve.js.map +1 -1
  7. package/dist/gateway/src/web.d.ts +12 -0
  8. package/dist/gateway/src/web.js +53 -0
  9. package/dist/gateway/src/web.js.map +1 -0
  10. package/dist/host/src/computer-preview-capture.d.ts +11 -0
  11. package/dist/host/src/computer-preview-capture.js +87 -0
  12. package/dist/host/src/computer-preview-capture.js.map +1 -0
  13. package/dist/host/src/computer-preview.d.ts +73 -0
  14. package/dist/host/src/computer-preview.js +335 -0
  15. package/dist/host/src/computer-preview.js.map +1 -0
  16. package/dist/host/src/drivers/acp/connection.d.ts +2 -0
  17. package/dist/host/src/drivers/acp/connection.js +14 -1
  18. package/dist/host/src/drivers/acp/connection.js.map +1 -1
  19. package/dist/host/src/drivers/acp/driver.d.ts +4 -0
  20. package/dist/host/src/drivers/acp/driver.js +45 -1
  21. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  22. package/dist/host/src/drivers/acp/mapper.js +11 -1
  23. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  24. package/dist/host/src/drivers/claude/activity.d.ts +1 -0
  25. package/dist/host/src/drivers/claude/activity.js +1 -0
  26. package/dist/host/src/drivers/claude/activity.js.map +1 -1
  27. package/dist/host/src/drivers/claude/driver.d.ts +6 -0
  28. package/dist/host/src/drivers/claude/driver.js +30 -2
  29. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  30. package/dist/host/src/drivers/claude/tasks.d.ts +33 -0
  31. package/dist/host/src/drivers/claude/tasks.js +138 -0
  32. package/dist/host/src/drivers/claude/tasks.js.map +1 -0
  33. package/dist/host/src/drivers/claude/transcript.d.ts +31 -3
  34. package/dist/host/src/drivers/claude/transcript.js +20 -1
  35. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  36. package/dist/host/src/drivers/codex/app-server.js +0 -3
  37. package/dist/host/src/drivers/codex/app-server.js.map +1 -1
  38. package/dist/host/src/drivers/codex/commands.js +7 -1
  39. package/dist/host/src/drivers/codex/commands.js.map +1 -1
  40. package/dist/host/src/drivers/codex/computer-preview.d.ts +3 -0
  41. package/dist/host/src/drivers/codex/computer-preview.js +44 -0
  42. package/dist/host/src/drivers/codex/computer-preview.js.map +1 -0
  43. package/dist/host/src/drivers/codex/driver.d.ts +16 -1
  44. package/dist/host/src/drivers/codex/driver.js +262 -6
  45. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  46. package/dist/host/src/drivers/codex/mapper.js +38 -3
  47. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  48. package/dist/host/src/drivers/registry.js +1 -1
  49. package/dist/host/src/drivers/registry.js.map +1 -1
  50. package/dist/host/src/drivers/types.d.ts +23 -1
  51. package/dist/host/src/hub.d.ts +18 -1
  52. package/dist/host/src/hub.js +102 -1
  53. package/dist/host/src/hub.js.map +1 -1
  54. package/dist/host/src/input.d.ts +9 -0
  55. package/dist/host/src/input.js +15 -0
  56. package/dist/host/src/input.js.map +1 -1
  57. package/dist/host/src/rpc/server.d.ts +1 -0
  58. package/dist/host/src/rpc/server.js +20 -2
  59. package/dist/host/src/rpc/server.js.map +1 -1
  60. package/dist/host/src/screen-viewer.js +641 -109
  61. package/dist/host/src/screen-viewer.js.map +1 -1
  62. package/dist/host/src/store.d.ts +10 -0
  63. package/dist/host/src/store.js +29 -0
  64. package/dist/host/src/store.js.map +1 -1
  65. package/dist/host/src/task-output.d.ts +6 -0
  66. package/dist/host/src/task-output.js +22 -0
  67. package/dist/host/src/task-output.js.map +1 -0
  68. package/dist/host/src/terminals.d.ts +37 -2
  69. package/dist/host/src/terminals.js +54 -14
  70. package/dist/host/src/terminals.js.map +1 -1
  71. package/dist/wire/src/computer-preview.d.ts +35 -0
  72. package/dist/wire/src/computer-preview.js +51 -0
  73. package/dist/wire/src/computer-preview.js.map +1 -0
  74. package/dist/wire/src/goal.d.ts +66 -0
  75. package/dist/wire/src/goal.js +19 -0
  76. package/dist/wire/src/goal.js.map +1 -0
  77. package/dist/wire/src/index.d.ts +2 -0
  78. package/dist/wire/src/index.js +2 -0
  79. package/dist/wire/src/index.js.map +1 -1
  80. package/dist/wire/src/model.d.ts +78 -0
  81. package/dist/wire/src/model.js +31 -0
  82. package/dist/wire/src/model.js.map +1 -1
  83. package/dist/wire/src/rpc.d.ts +1289 -60
  84. package/dist/wire/src/rpc.js +53 -3
  85. package/dist/wire/src/rpc.js.map +1 -1
  86. package/dist/wire/src/updates.d.ts +334 -22
  87. package/dist/wire/src/updates.js +11 -1
  88. package/dist/wire/src/updates.js.map +1 -1
  89. package/package.json +5 -5
@@ -30,13 +30,16 @@ const ICONS = {
30
30
  down: icon('<path d="m6 9.5 6 6 6-6"/>'),
31
31
  quick: icon('<path d="M13 2.8 5.2 13.6H11l-1 7.6 7.8-10.8H12Z"/>'),
32
32
  compose: icon('<path d="M4 5h16v10.5H9.5L5.5 19v-3.5H4Z"/><path d="M8 9h8M8 12h5"/>'),
33
+ info: icon('<circle cx="12" cy="12" r="8.5"/><path d="M12 11v6M12 7.2v.6"/>'),
33
34
  close: icon('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'),
34
35
  };
35
36
  const STYLE = String.raw `
36
37
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
37
- html, body { margin: 0; height: 100%; background: #000; overflow: hidden; overscroll-behavior: none; }
38
+ html, body { margin: 0; height: 100%; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; background: #000; overflow: hidden; overscroll-behavior: none; }
38
39
  body { position: fixed; inset: 0; color: #fff; font: 15px/1.45 -apple-system, system-ui, "PingFang SC", "Noto Sans CJK SC", sans-serif; }
39
40
  #stage { position: absolute; inset: 0; overflow: hidden; touch-action: none; }
41
+ #picture { position: absolute; inset: 0; transform-origin: 0 0; will-change: transform; }
42
+ #stage.presenting #pointer { opacity: 0 !important; }
40
43
  canvas, video { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
41
44
  video { object-fit: fill; pointer-events: none; }
42
45
  #pointer { position: absolute; left: -9px; top: -9px; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;
@@ -50,51 +53,62 @@ const STYLE = String.raw `
50
53
  color: rgba(255,255,255,0.72); text-align: center; pointer-events: none; }
51
54
  .glass { background: rgba(38,38,42,0.74); -webkit-backdrop-filter: blur(22px) saturate(1.6); backdrop-filter: blur(22px) saturate(1.6);
52
55
  border: 0.5px solid rgba(255,255,255,0.16); box-shadow: 0 8px 28px rgba(0,0,0,0.38); }
53
- #bar { position: absolute; display: flex; gap: 2px; padding: 4px; border-radius: 26px; transition: opacity 0.4s; }
56
+ #control-shell { position: absolute; pointer-events: none; z-index: 1; will-change: width, height, transform, border-radius, opacity; }
57
+ #bar { position: absolute; z-index: 2; width: max-content; height: max-content; display: flex; gap: 2px; padding: 4px; border-radius: 26px; overflow: auto; scrollbar-width: none; touch-action: pan-x pan-y; }
54
58
  #bar.side { flex-direction: column; }
55
- #bar.idle { opacity: 0.38; }
56
- #bar.hidden, .gone { display: none !important; }
57
- .tool { width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.92); }
59
+ #orb { position: absolute; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; touch-action: none; z-index: 3; }
60
+ .orb-core { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; }
61
+ .orb-core svg { width: 18px; height: 18px; }
62
+ .gone { display: none !important; }
63
+ .tool { flex: none; width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.92); }
58
64
  .tool.on { background: #fff; color: #111; }
59
65
  .tool:active { background: rgba(255,255,255,0.18); }
60
66
  .tool.on:active { background: rgba(255,255,255,0.82); }
61
- #menu { position: absolute; width: 248px; padding: 6px; border-radius: 20px; }
62
- .choice { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; }
67
+ #menu, #connection { position: absolute; z-index: 6; width: 248px; padding: 6px; border-radius: 20px; overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y; }
68
+ #menu.horizontal { display: flex; gap: 4px; width: 468px; }
69
+ #menu.horizontal .choice { flex: 1; min-width: 0; gap: 8px; padding: 9px 8px; }
70
+ #menu.horizontal .choice span { font-size: calc(11px * var(--text-scale, 1)); white-space: nowrap; }
71
+ .choice { display: flex; align-items: center; min-height: 52px; gap: 10px; padding: 8px 10px; border-radius: 12px; transition: background-color 160ms ease-out; }
72
+ .choice i { display: flex; flex: none; }
73
+ .choice i svg { width: 20px; height: 20px; }
74
+ #connectionhead { display: flex; align-items: center; padding-left: 10px; font-size: calc(13px * var(--text-scale, 1)); color: rgba(255,255,255,0.7); }
75
+ #connectionhead span { flex: 1; }
63
76
  .choice:active { background: rgba(255,255,255,0.12); }
64
77
  .choice.on { background: rgba(255,255,255,0.16); }
65
- .choice b { display: block; font-weight: 600; font-size: 15px; }
66
- .choice span { display: block; font-size: 12.5px; color: rgba(255,255,255,0.62); }
67
- #status { margin-top: 6px; padding: 8px 12px 5px; border-top: 0.5px solid rgba(255,255,255,0.12); font-size: 12px; line-height: 1.5;
78
+ .choice b { display: block; font-weight: 600; font-size: calc(15px * var(--text-scale, 1)); }
79
+ .choice span { display: block; font-size: calc(12.5px * var(--text-scale, 1)); color: rgba(255,255,255,0.62); }
80
+ #status { padding: 2px 10px 10px; font-size: calc(12px * var(--text-scale, 1)); line-height: 1.5;
68
81
  color: rgba(255,255,255,0.5); white-space: pre-line; font-variant-numeric: tabular-nums; }
69
82
  #toast { position: absolute; left: 50%; max-width: min(86vw, 420px); width: max-content; transform: translateX(-50%); padding: 10px 16px; border-radius: 18px;
70
- font-size: 13.5px; text-align: center; color: rgba(255,255,255,0.94); pointer-events: none; opacity: 0; transition: opacity 0.3s; }
83
+ font-size: calc(13.5px * var(--text-scale, 1)); text-align: center; color: rgba(255,255,255,0.94); pointer-events: none; opacity: 0; transition: opacity 0.3s; }
71
84
  #toast.on { opacity: 1; }
72
- #keys { position: absolute; left: 0; right: 0; height: 46px; display: flex; align-items: center; gap: 4px; padding: 0 5px;
85
+ #keys { position: absolute; z-index: 4; left: 0; right: 0; height: 56px; display: flex; align-items: center; gap: 4px; padding: 0 5px; overflow-x: auto; overscroll-behavior: contain; touch-action: pan-x;
73
86
  background: rgba(28,28,30,0.96); border-top: 0.5px solid rgba(255,255,255,0.14); }
74
- .key { flex: 1 1 0; min-width: 0; height: 34px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
75
- background: rgba(255,255,255,0.13); color: #fff; font-size: 15px; font-variant-numeric: tabular-nums; }
76
- .key.word { font-size: 12.5px; }
87
+ .key { flex: 1 0 44px; min-width: 44px; height: calc(44px * var(--text-scale, 1)); border-radius: 8px; display: flex; align-items: center; justify-content: center;
88
+ background: rgba(255,255,255,0.13); color: #fff; font-size: calc(15px * var(--text-scale, 1)); font-variant-numeric: tabular-nums; }
89
+ .key.word { font-size: calc(12.5px * var(--text-scale, 1)); }
77
90
  .key svg { width: 20px; height: 20px; }
78
91
  .key:active { background: rgba(255,255,255,0.3); }
79
92
  .key.on { background: #fff; color: #111; }
80
- #sheet { position: absolute; display: flex; flex-direction: column; border-radius: 24px; overflow: hidden; }
93
+ #sheet { position: absolute; z-index: 4; display: flex; flex-direction: column; border-radius: 24px; overflow: hidden; }
81
94
  #sheethead { display: flex; align-items: center; gap: 2px; padding: 8px 6px 4px 10px; }
82
- #compose { flex: 1; min-width: 0; height: 40px; margin-right: 4px; border-radius: 20px; display: flex; align-items: center; gap: 8px; padding: 0 14px;
83
- background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); font-size: 14.5px; white-space: nowrap; }
95
+ #compose { flex: 1; min-width: 0; min-height: 44px; margin-right: 4px; border-radius: 20px; display: flex; align-items: center; gap: 8px; padding: 0 14px;
96
+ background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); font-size: calc(14.5px * var(--text-scale, 1)); white-space: nowrap; }
84
97
  #compose:active { background: rgba(255,255,255,0.2); }
85
98
  #compose svg { width: 19px; height: 19px; flex: none; }
86
99
  #actions, #maker { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; padding: 0 10px 12px; }
87
- .group { display: flex; align-items: center; justify-content: space-between; padding: 12px 4px 7px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
88
- .group i { font-style: normal; padding: 4px 8px; margin: -4px -4px -4px 0; border-radius: 10px; color: rgba(255,255,255,0.86); }
100
+ .group { display: flex; align-items: center; justify-content: space-between; padding: 12px 4px 7px; font-size: calc(12.5px * var(--text-scale, 1)); color: rgba(255,255,255,0.55); }
101
+ .group i { display: inline-flex; align-items: center; min-width: 44px; min-height: 44px; font-style: normal; padding: 4px 8px; margin: -4px -4px -4px 0; border-radius: 10px; color: rgba(255,255,255,0.86); }
89
102
  .group i:active { background: rgba(255,255,255,0.16); }
90
103
  .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
91
- .tiles.six { grid-template-columns: repeat(6, minmax(0, 1fr)); margin-bottom: 6px; }
104
+ .tiles.six { grid-template-columns: repeat(auto-fit, minmax(calc(44px * var(--text-scale, 1)), 1fr)); margin-bottom: 6px; }
92
105
  #sheet.narrow .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
93
- #sheet.narrow .tiles.six { grid-template-columns: repeat(6, minmax(0, 1fr)); }
106
+ #sheet.folded .tiles:not(.six) { grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); }
107
+ #sheet.narrow .tiles.six { grid-template-columns: repeat(auto-fit, minmax(calc(44px * var(--text-scale, 1)), 1fr)); }
94
108
  .tile { position: relative; min-height: 52px; padding: 6px 3px; border-radius: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center;
95
109
  background: rgba(255,255,255,0.1); text-align: center; }
96
- .tile b { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; font-size: 13.5px; line-height: 1.3; }
97
- .tile span { font-size: 11.5px; line-height: 1.3; color: rgba(255,255,255,0.55); font-variant-numeric: tabular-nums; }
110
+ .tile b { max-width: 100%; overflow: hidden; white-space: normal; overflow-wrap: anywhere; font-weight: 500; font-size: calc(13.5px * var(--text-scale, 1)); line-height: 1.3; }
111
+ .tile span { font-size: calc(11.5px * var(--text-scale, 1)); line-height: 1.3; color: rgba(255,255,255,0.55); font-variant-numeric: tabular-nums; }
98
112
  .tiles.six .tile { min-height: 44px; }
99
113
  .tile:active, .tile.hit { background: rgba(255,255,255,0.3); }
100
114
  .tile.on { background: #fff; color: #111; }
@@ -103,38 +117,151 @@ const STYLE = String.raw `
103
117
  .tile.sure span { color: rgba(255,255,255,0.86); }
104
118
  .tile.add { background: transparent; border: 1px dashed rgba(255,255,255,0.3); color: rgba(255,255,255,0.78); }
105
119
  .tile.loose::after { content: "×"; position: absolute; top: -5px; right: -4px; width: 19px; height: 19px; border-radius: 10px; background: #d9392f;
106
- font-size: 14px; line-height: 18px; text-align: center; }
120
+ font-size: calc(14px * var(--text-scale, 1)); line-height: 18px; text-align: center; }
107
121
  .field { display: flex; align-items: center; gap: 12px; height: 46px; margin-top: 6px; padding: 0 12px; border-radius: 12px; background: rgba(255,255,255,0.1); }
108
- .field span { flex: none; font-size: 14px; color: rgba(255,255,255,0.62); }
122
+ .field span { flex: none; font-size: calc(14px * var(--text-scale, 1)); color: rgba(255,255,255,0.62); }
109
123
  .field select, .field input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; border-radius: 0; background: transparent; color: #fff;
110
- font: inherit; font-size: 16px; text-align: right; text-align-last: right; -webkit-appearance: none; appearance: none; -webkit-user-select: text; user-select: text; }
124
+ font: inherit; font-size: calc(16px * var(--text-scale, 1)); text-align: right; text-align-last: right; -webkit-appearance: none; appearance: none; -webkit-user-select: text; user-select: text; }
111
125
  .field input::placeholder { color: rgba(255,255,255,0.36); }
112
126
  .ends { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
113
- .soft { flex: none; height: 38px; padding: 0 13px; border-radius: 19px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.13);
114
- font-size: 14px; white-space: nowrap; }
127
+ .soft { flex: none; min-height: 44px; padding: 0 13px; border-radius: 19px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.13);
128
+ font-size: calc(14px * var(--text-scale, 1)); white-space: nowrap; }
115
129
  .soft:active { background: rgba(255,255,255,0.3); }
116
130
  .soft.strong { padding: 0 17px; background: #fff; color: #111; font-weight: 600; }
117
131
  .soft.strong:active { background: rgba(255,255,255,0.82); }
118
132
  .soft svg { width: 20px; height: 20px; }
119
133
  .spring { flex: 1; }
120
- #composer { position: absolute; left: 0; right: 0; display: flex; flex-direction: column; gap: 8px; background: rgba(28,28,30,0.96); border-top: 0.5px solid rgba(255,255,255,0.14); }
134
+ #composer { position: absolute; z-index: 4; left: 0; right: 0; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 8px; background: rgba(28,28,30,0.96); border-top: 0.5px solid rgba(255,255,255,0.14); }
121
135
  #composer.wide { flex-direction: row; align-items: flex-end; }
122
136
  #wordsbox { position: relative; flex: none; }
123
137
  #composer.wide #wordsbox { flex: 1; min-width: 0; }
124
138
  #words { display: block; width: 100%; height: 66px; margin: 0; padding: 9px 12px; border: 0; outline: 0; resize: none; border-radius: 12px; background: rgba(255,255,255,0.12);
125
- color: #fff; font: inherit; font-size: 16px; line-height: 1.4; -webkit-user-select: text; user-select: text; }
139
+ color: #fff; font: inherit; font-size: calc(16px * var(--text-scale, 1)); line-height: 1.4; -webkit-user-select: text; user-select: text; }
126
140
  #composer.wide #words { height: 44px; }
127
- #wordshint { position: absolute; left: 12px; right: 12px; top: 9px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 16px; line-height: 1.4;
141
+ #wordshint { position: absolute; left: 12px; right: 12px; top: 9px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: calc(16px * var(--text-scale, 1)); line-height: 1.4;
128
142
  color: rgba(255,255,255,0.36); pointer-events: none; }
129
- #sendrow { display: flex; align-items: center; gap: 6px; }
143
+ #sendrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
130
144
  #typing, #shortcut { position: absolute; left: 0; top: 0; width: 2px; height: 2px; padding: 0; border: 0; outline: 0; resize: none; opacity: 0;
131
- background: transparent; color: transparent; caret-color: transparent; font-size: 16px; -webkit-user-select: text; user-select: text; }
145
+ background: transparent; color: transparent; caret-color: transparent; font-size: calc(16px * var(--text-scale, 1)); -webkit-user-select: text; user-select: text; }
132
146
  `;
133
147
  /**
134
148
  * The parts of the page's script that only work things out, with nothing of the page in them:
135
149
  * exported so that they can be tried without a browser.
136
150
  */
137
151
  export const VIEWER_LOGIC = String.raw `
152
+ /** All controls share the same usable rectangle, including asymmetric system chrome. */
153
+ function usableViewport(v, inset) {
154
+ const left = Math.max(0, inset.left || 0), right = Math.max(0, inset.right || 0);
155
+ const top = Math.max(0, inset.top || 0), bottom = Math.max(0, inset.bottom || 0);
156
+ return { x: v.x + left, y: v.y + top, w: Math.max(1, v.w - left - right), h: Math.max(1, v.h - top - bottom) };
157
+ }
158
+ function containedBox(safe, width, height, x, y, gap = 8) {
159
+ const w = Math.min(width, Math.max(1, safe.w - gap * 2)), h = Math.min(height, Math.max(1, safe.h - gap * 2));
160
+ return { x: Math.max(safe.x + gap, Math.min(x, safe.x + safe.w - gap - w)), y: Math.max(safe.y + gap, Math.min(y, safe.y + safe.h - gap - h)), w, h };
161
+ }
162
+
163
+ /** Try each edge of the toolbar, preferring the black outside the fitted picture. */
164
+ function anchoredPanel(safe, picture, anchor, width, height) {
165
+ const overlap = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
166
+ const candidates = [
167
+ [anchor.x - width - 10, anchor.y + (anchor.h - height) / 2],
168
+ [anchor.x + anchor.w + 10, anchor.y + (anchor.h - height) / 2],
169
+ [anchor.x + (anchor.w - width) / 2, anchor.y - height - 10],
170
+ [anchor.x + (anchor.w - width) / 2, anchor.y + anchor.h + 10],
171
+ ].map(([x, y]) => containedBox(safe, width, height, x, y));
172
+ const score = (box) => overlap(box, picture) + overlap(box, anchor) * 100;
173
+ return candidates.reduce((best, box) => score(box) < score(best) ? box : best);
174
+ }
175
+
176
+ /** FLIP keeps the live media at its visible position while its destination changes. */
177
+ function presentationFlip(from, to, reduceMotion) {
178
+ if (reduceMotion || !from || !to || ![from.x, from.y, from.w, from.h, to.x, to.y, to.w, to.h].every(Number.isFinite) || Math.min(from.w, from.h, to.w, to.h) <= 0) return null;
179
+ const sx = from.w / to.w, sy = from.h / to.h;
180
+ return { x: from.x - to.x * sx, y: from.y - to.y * sy, sx, sy };
181
+ }
182
+
183
+ /** Exact damped-spring integration keeps momentum when the destination changes mid-flight. */
184
+ function presentationSpring(current, velocity, target, seconds, frequency = 32) {
185
+ const damping = 0.8;
186
+ const decay = frequency * damping, oscillation = frequency * Math.sqrt(1 - damping * damping);
187
+ const dt = Math.max(0, Math.min(seconds, 0.064)), envelope = Math.exp(-decay * dt);
188
+ const cosine = Math.cos(oscillation * dt), sine = Math.sin(oscillation * dt);
189
+ const next = {}, speed = {};
190
+ let settled = true;
191
+ for (const key of ["x", "y", "w", "h"]) {
192
+ const displacement = current[key] - target[key], v = velocity[key];
193
+ next[key] = target[key] + envelope * (displacement * cosine + (v + decay * displacement) / oscillation * sine);
194
+ speed[key] = envelope * (v * cosine - (decay * v + frequency * frequency * displacement) / oscillation * sine);
195
+ if (Math.abs(next[key] - target[key]) > 0.05 || Math.abs(speed[key]) > 0.4) settled = false;
196
+ }
197
+ return { current: next, velocity: speed, settled };
198
+ }
199
+
200
+ /** The same panel enters from its available edge in both narrow and wide layouts. */
201
+ function sheetPresentation(progress, width, height, dock) {
202
+ const remaining = 1 - progress;
203
+ const x = dock === "left" ? -(width + 24) * remaining : dock === "right" ? (width + 24) * remaining : 0;
204
+ const y = dock === "bottom" ? (height + 24) * remaining : dock === "top" ? -(height + 24) * remaining : 0;
205
+ return { x, y, opacity: Math.max(0, Math.min(1, progress)) };
206
+ }
207
+
208
+ function popoverPresentation(progress, dx, dy) {
209
+ return { x: dx * (1 - progress), y: dy * (1 - progress), scale: 0.94 + 0.06 * progress, opacity: Math.max(0, Math.min(1, progress)) };
210
+ }
211
+ function controlCanCollapse(state) {
212
+ return !!state.fullscreen && !state.menu && !state.sheet && !state.keyboard && !state.pressed && !state.presenting;
213
+ }
214
+ function orbBounds(safe, anchor, point) {
215
+ const x = point ? safe.x + 8 + Math.max(0, Math.min(1, point.u)) * Math.max(0, safe.w - 60) : anchor.x + anchor.w / 2 - 22;
216
+ const y = point ? safe.y + 8 + Math.max(0, Math.min(1, point.v)) * Math.max(0, safe.h - 60) : anchor.y + anchor.h / 2 - 22;
217
+ return containedBox(safe, 44, 44, x, y);
218
+ }
219
+
220
+ /** One glass surface changes shape; its contents can fade without swapping backgrounds. */
221
+ function controlsMorph(progress, anchor, ball) {
222
+ const p = Math.max(0, Math.min(1, progress)), phase = Math.min(1, p / 0.3);
223
+ const x = ball.x + 6 + (anchor.x - ball.x - 6) * progress;
224
+ const y = ball.y + 6 + (anchor.y - ball.y - 6) * progress;
225
+ const w = Math.max(1, 32 + (anchor.w - 32) * progress), h = Math.max(1, 32 + (anchor.h - 32) * progress);
226
+ return { x, y, w, h, radius: Math.min(w, h) / 2, shellOpacity: 0.3 + 0.7 * phase * phase * (3 - 2 * phase),
227
+ dx: x + w / 2 - anchor.x - anchor.w / 2, dy: y + h / 2 - anchor.y - anchor.h / 2,
228
+ contentScale: Math.max(0.01, Math.min(w / Math.max(1, anchor.w), h / Math.max(1, anchor.h))),
229
+ barOpacity: p * p, orbOpacity: 1 - p };
230
+ }
231
+
232
+ /** Keep controls in one usable pane while the live desktop remains continuous across a fold. */
233
+ function interactionViewport(viewport, insets, divisions, preferred) {
234
+ const safe = usableViewport(viewport, insets);
235
+ let regions = [safe];
236
+ for (const reported of Array.isArray(divisions) ? divisions : []) {
237
+ if (!reported.active || ![reported.x, reported.y, reported.width, reported.height].every(Number.isFinite)) continue;
238
+ if (reported.width < 0 || reported.height < 0) continue;
239
+ const division = { ...reported, x: viewport.x + reported.x, y: viewport.y + reported.y };
240
+ const vertical = division.height > division.width;
241
+ const next = [];
242
+ for (const region of regions) {
243
+ const right = region.x + region.w, bottom = region.y + region.h;
244
+ if (division.x >= right || division.y >= bottom || division.x + division.width <= region.x || division.y + division.height <= region.y) { next.push(region); continue; }
245
+ if (vertical) {
246
+ if (division.x > region.x) next.push({ x: region.x, y: region.y, w: division.x - region.x, h: region.h });
247
+ if (division.x + division.width < right) next.push({ x: division.x + division.width, y: region.y, w: right - division.x - division.width, h: region.h });
248
+ } else {
249
+ if (division.y > region.y) next.push({ x: region.x, y: region.y, w: region.w, h: division.y - region.y });
250
+ if (division.y + division.height < bottom) next.push({ x: region.x, y: division.y + division.height, w: region.w, h: bottom - division.y - division.height });
251
+ }
252
+ }
253
+ if (next.length) regions = next;
254
+ }
255
+ const usable = regions.filter(region => region.w >= Math.min(180, safe.w) && region.h >= Math.min(80, safe.h));
256
+ const candidates = usable.length ? usable : regions;
257
+ if (preferred && Number.isFinite(preferred.x) && Number.isFinite(preferred.y)) {
258
+ const distance = region => Math.hypot(Math.max(region.x - preferred.x, 0, preferred.x - region.x - region.w), Math.max(region.y - preferred.y, 0, preferred.y - region.y - region.h));
259
+ return candidates.reduce((best, region) => distance(region) <= distance(best) ? region : best);
260
+ }
261
+ // The outer right edge and the lower tabletop pane are the natural default reach areas.
262
+ return candidates[candidates.length - 1] || safe;
263
+ }
264
+
138
265
  /** Undoes the Gray code the time on a clock strip is written in. */
139
266
  function fromGray(gray) {
140
267
  let n = gray;
@@ -282,8 +409,8 @@ function cleanShortcuts(list) {
282
409
  `;
283
410
  const BODY = String.raw `
284
411
  const $ = (id) => document.getElementById(id);
285
- const stage = $("stage"), canvas = $("screen"), ctx = canvas.getContext("2d"), video = $("video"), pointer = $("pointer");
286
- const note = $("note"), bar = $("bar"), menu = $("menu"), toast = $("toast"), keys = $("keys"), typing = $("typing"), shortcut = $("shortcut");
412
+ const stage = $("stage"), picture = $("picture"), canvas = $("screen"), ctx = canvas.getContext("2d"), video = $("video"), pointer = $("pointer");
413
+ const note = $("note"), bar = $("bar"), controlShell = $("control-shell"), orb = $("orb"), menu = $("menu"), connection = $("connection"), toast = $("toast"), keys = $("keys"), typing = $("typing"), shortcut = $("shortcut");
287
414
  const sheet = $("sheet"), actions = $("actions"), maker = $("maker"), naming = $("makername"), composer = $("composer"), words = $("words");
288
415
  const query = new URLSearchParams(location.search);
289
416
  const app = window.ReactNativeWebView;
@@ -295,7 +422,7 @@ const android = /Android/i.test(navigator.userAgent);
295
422
  const MODES = ["view", "trackpad", "touch"];
296
423
  const HINTS = {
297
424
  view: "只看:双指缩放,单指移动画面",
298
- trackpad: "触控板:滑动移动指针,轻点点击 · 双指轻点右键 · 双指滑动滚动 · 轻点两下并按住拖拽",
425
+ trackpad: "触控板:滑动指针 · 轻点点击 · 双指轻点右键 · 双指滑动滚动 · 轻点两下并按住拖拽",
299
426
  touch: "点按:点哪里就点哪里 · 长按右键 · 双指滑动滚动 · 轻点两下并按住拖拽",
300
427
  };
301
428
  let stored = null;
@@ -658,54 +785,407 @@ function viewport() {
658
785
  // What a browser's window has been seen to do: a keyboard coming up makes it shorter, on a phone (see keyboardIsUp).
659
786
  const seen = { w: 0, h: 0, follows: false };
660
787
 
788
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
789
+ let sheetTransition = null, sheetDock = "bottom";
790
+ const presentation = { active: false, target: null, geometry: "", current: null, velocity: { x: 0, y: 0, w: 0, h: 0 }, destination: null, waiting: false, frame: 0, time: null, timer: 0 };
791
+ function visiblePicture() {
792
+ if (!content.w) return null;
793
+ const rect = (live ? video : canvas).getBoundingClientRect();
794
+ return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
795
+ }
796
+ function finishPresentation() {
797
+ clearTimeout(presentation.timer);
798
+ cancelAnimationFrame(presentation.frame);
799
+ picture.style.transform = "";
800
+ sheetTransition = null;
801
+ sheet.style.transform = sheet.style.opacity = sheet.style.pointerEvents = "";
802
+ sheet.classList.toggle("gone", !sheetOpen);
803
+ presentation.frame = 0;
804
+ presentation.current = null;
805
+ presentation.time = null;
806
+ presentation.active = false;
807
+ stage.classList.remove("presenting");
808
+ armControlsIdle();
809
+ }
810
+ function drawPresentation() {
811
+ if (sheetTransition) {
812
+ const panel = sheetPresentation(sheetTransition.progress, sheet.offsetWidth, sheet.offsetHeight, sheetDock);
813
+ sheet.style.transform = "translate(" + panel.x + "px," + panel.y + "px)";
814
+ sheet.style.opacity = String(panel.opacity);
815
+ sheet.style.pointerEvents = sheetTransition.target ? "auto" : "none";
816
+ }
817
+ if (!content.w) return;
818
+ const flip = presentationFlip(presentation.current, shown, reducedMotion.matches);
819
+ if (!flip) return finishPresentation();
820
+ picture.style.transform = "translate(" + flip.x + "px," + flip.y + "px) scale(" + flip.sx + "," + flip.sy + ")";
821
+ }
822
+ function stepPresentation(time) {
823
+ presentation.frame = 0;
824
+ if (!presentation.active) return;
825
+ if (presentation.time === null) presentation.time = time;
826
+ const seconds = (time - presentation.time) / 1000;
827
+ const next = presentationSpring(presentation.current, presentation.velocity, presentation.destination, seconds);
828
+ let sheetSettled = true;
829
+ if (sheetTransition) {
830
+ const panel = presentationSpring({ x: sheetTransition.progress, y: 0, w: 1, h: 1 }, { x: sheetTransition.velocity, y: 0, w: 0, h: 0 }, { x: sheetTransition.target, y: 0, w: 1, h: 1 }, seconds);
831
+ sheetTransition.progress = panel.current.x;
832
+ sheetTransition.velocity = panel.velocity.x;
833
+ sheetSettled = Math.abs(panel.current.x - sheetTransition.target) < 0.001 && Math.abs(panel.velocity.x) < 0.01;
834
+ }
835
+ presentation.time = time;
836
+ presentation.current = next.current;
837
+ presentation.velocity = next.velocity;
838
+ drawPresentation();
839
+ if (!presentation.active) return;
840
+ if (next.settled && sheetSettled && !presentation.waiting) return finishPresentation();
841
+ presentation.frame = requestAnimationFrame(stepPresentation);
842
+ }
843
+ function beginPresentation(full) {
844
+ if ((presentation.active && presentation.target === "fullscreen:" + full) || (!presentation.active && full === (app ? chrome.fullscreen : !!document.fullscreenElement))) return;
845
+ beginLayoutTransition("fullscreen:" + full);
846
+ }
847
+ function beginLayoutTransition(target) {
848
+ if (reducedMotion.matches) return finishPresentation();
849
+ // A held mouse button must be released before the displayed coordinate system moves.
850
+ wake();
851
+ closeMenu();
852
+ endOne();
853
+ if (mouseHeld && mouseHeld !== "pan") act({ t: "up", b: mouseHeld });
854
+ mouseHeld = null;
855
+ gesture = null;
856
+ touches.clear();
857
+ lastTap = null;
858
+ if (!presentation.active) {
859
+ presentation.current = visiblePicture() || { ...shown };
860
+ presentation.velocity = { x: 0, y: 0, w: 0, h: 0 };
861
+ presentation.destination = { ...shown };
862
+ presentation.time = null;
863
+ }
864
+ presentation.active = true;
865
+ presentation.target = target;
866
+ presentation.waiting = !sheetTransition;
867
+ presentation.geometry = JSON.stringify(shown);
868
+ stage.classList.add("presenting");
869
+ if (!presentation.frame) presentation.frame = requestAnimationFrame(stepPresentation);
870
+ clearTimeout(presentation.timer);
871
+ presentation.timer = setTimeout(finishPresentation, 350);
872
+ }
873
+ function beginSheetTransition(open) {
874
+ if (!reducedMotion.matches) {
875
+ sheetTransition = sheetTransition ? { ...sheetTransition, target: open ? 1 : 0 } : { progress: open ? 0 : 1, velocity: 0, target: open ? 1 : 0 };
876
+ }
877
+ beginLayoutTransition("shortcuts:" + open);
878
+ }
879
+ function animatePresentation() {
880
+ if (!presentation.active) return;
881
+ const geometry = JSON.stringify(shown);
882
+ if (geometry !== presentation.geometry) {
883
+ presentation.geometry = geometry;
884
+ presentation.destination = { ...shown };
885
+ presentation.waiting = false;
886
+ clearTimeout(presentation.timer);
887
+ presentation.timer = setTimeout(finishPresentation, 800);
888
+ }
889
+ // Layout may change while the status bar moves. Rebase the same live spring without resetting it.
890
+ drawPresentation();
891
+ if (presentation.active && !presentation.frame) presentation.frame = requestAnimationFrame(stepPresentation);
892
+ }
893
+ reducedMotion.addEventListener("change", () => { if (reducedMotion.matches) finishPresentation(); });
894
+ window.linkshellPresent = beginPresentation;
895
+ document.addEventListener("visibilitychange", () => { if (document.hidden) finishPresentation(); });
896
+
897
+ // Scalar springs drive controls without touching the live video or remote pointer coordinates.
898
+ const controlSprings = new Map(), popoverMotions = new Map(), pressedControls = new Set();
899
+ let controlFrame = 0;
900
+ const controlsVisibility = {};
901
+ const controls = { value: 1, target: 1, visibility: 1, visibilityTarget: 1, timer: 0, point: null, preferred: null, safe: null, anchor: null, orb: null, drag: null };
902
+ function advanceControls(time) {
903
+ controlFrame = 0;
904
+ for (const [key, motion] of [...controlSprings]) {
905
+ if (motion.time === null) motion.time = time;
906
+ // A changing glass silhouette needs more time to read than a button press.
907
+ const frequency = key === controls ? 17 : key === menu || key === connection ? 22 : 32;
908
+ const next = presentationSpring({ x: motion.value, y: 0, w: 1, h: 1 }, { x: motion.velocity, y: 0, w: 0, h: 0 }, { x: motion.target, y: 0, w: 1, h: 1 }, (time - motion.time) / 1000, frequency);
909
+ motion.time = time;
910
+ motion.value = next.current.x;
911
+ motion.velocity = next.velocity.x;
912
+ motion.render(motion.value);
913
+ if (Math.abs(motion.value - motion.target) < 0.001 && Math.abs(motion.velocity) < 0.01) {
914
+ controlSprings.delete(key);
915
+ motion.render(motion.target);
916
+ motion.done?.(motion.target);
917
+ }
918
+ }
919
+ if (controlSprings.size) controlFrame = requestAnimationFrame(advanceControls);
920
+ }
921
+ function controlSpring(key, target, initial, render, done) {
922
+ const motion = controlSprings.get(key) || { value: initial, velocity: 0, time: null };
923
+ Object.assign(motion, { target, render, done });
924
+ if (reducedMotion.matches) {
925
+ controlSprings.delete(key);
926
+ render(target);
927
+ done?.(target);
928
+ return;
929
+ }
930
+ controlSprings.set(key, motion);
931
+ render(motion.value);
932
+ if (!controlFrame) controlFrame = requestAnimationFrame(advanceControls);
933
+ }
934
+ function pressFeedback(element, down) {
935
+ if (down) {
936
+ pressedControls.add(element);
937
+ const v = viewport(), rect = element.getBoundingClientRect();
938
+ controls.preferred = { u: (rect.left + rect.width / 2 - v.x) / Math.max(1, v.w), v: (rect.top + rect.height / 2 - v.y) / Math.max(1, v.h) };
939
+ } else pressedControls.delete(element);
940
+ const pending = controlSprings.get(element);
941
+ // Even a tap released before the first animation frame gets a visible press, without delaying its action.
942
+ if (!down && !reducedMotion.matches && pending?.target === 0.92 && pending.value > 0.97) pending.velocity = Math.min(pending.velocity, -6);
943
+ controlSpring(element, down ? 0.92 : 1, 1, (scale) => { element.style.scale = reducedMotion.matches || scale === 1 ? "" : String(scale); });
944
+ if (down) clearTimeout(controls.timer); else armControlsIdle();
945
+ }
946
+ function popoverOpen(panel) {
947
+ return popoverMotions.has(panel) ? popoverMotions.get(panel).target === 1 : !panel.classList.contains("gone");
948
+ }
949
+ function drawPopover(panel) {
950
+ const motion = popoverMotions.get(panel);
951
+ if (!motion) return;
952
+ const trigger = $(panel === menu ? "mode" : "info").getBoundingClientRect();
953
+ const x = parseFloat(panel.style.left) || 0, y = parseFloat(panel.style.top) || 0;
954
+ const width = panel.offsetWidth, height = panel.offsetHeight;
955
+ const ax = trigger.left + trigger.width / 2, ay = trigger.top + trigger.height / 2;
956
+ const dx = ax - x - width / 2, dy = ay - y - height / 2, distance = Math.max(1, Math.hypot(dx, dy));
957
+ const frame = popoverPresentation(motion.value, dx / distance * 10, dy / distance * 10);
958
+ panel.style.transformOrigin = clamp(ax - x, 0, width) + "px " + clamp(ay - y, 0, height) + "px";
959
+ panel.style.transform = "translate(" + frame.x + "px," + frame.y + "px) scale(" + frame.scale + ")";
960
+ panel.style.opacity = String(frame.opacity);
961
+ }
962
+ function presentPopover(panel, open) {
963
+ if (!open && panel.classList.contains("gone")) return;
964
+ const existing = popoverMotions.get(panel);
965
+ const motion = existing || { value: open ? 0 : 1, target: open ? 1 : 0 };
966
+ motion.target = open ? 1 : 0;
967
+ popoverMotions.set(panel, motion);
968
+ panel.style.opacity = String(Math.max(0, Math.min(1, motion.value)));
969
+ panel.style.pointerEvents = open ? "auto" : "none";
970
+ panel.classList.remove("gone");
971
+ // Measure at its final location before revealing it.
972
+ layout();
973
+ controlSpring(panel, motion.target, motion.value, (value) => { motion.value = value; drawPopover(panel); }, (target) => {
974
+ if (target === 0) panel.classList.add("gone");
975
+ panel.style.transform = panel.style.transformOrigin = panel.style.opacity = panel.style.pointerEvents = "";
976
+ popoverMotions.delete(panel);
977
+ armControlsIdle();
978
+ });
979
+ }
980
+ function immersiveControls() { return app ? chrome.fullscreen : !!document.fullscreenElement; }
981
+ function controlsMayCollapse() {
982
+ if (document.hidden) return false;
983
+ return controlCanCollapse({ fullscreen: immersiveControls(), menu: !menu.classList.contains("gone") || !connection.classList.contains("gone"), sheet: sheetOpen || !!sheetTransition || composerOpen, keyboard: keysOpen || chrome.keyboard === true, pressed: pressedControls.size > 0 || !!controls.drag, presenting: presentation.active });
984
+ }
985
+ function renderControls(value) {
986
+ controls.value = value;
987
+ if (!controls.safe || !controls.anchor || !controls.orb) return;
988
+ const visible = Math.max(0, Math.min(1, value)), anchor = controls.anchor, ball = controls.orb;
989
+ const groupOpacity = Math.max(0, Math.min(1, controls.visibility));
990
+ const hiddenByPanel = keysOpen || sheetOpen || composerOpen;
991
+ const morph = controlsMorph(value, anchor, ball);
992
+ controlShell.style.left = morph.x + "px";
993
+ controlShell.style.top = morph.y + "px";
994
+ controlShell.style.width = morph.w + "px";
995
+ controlShell.style.height = morph.h + "px";
996
+ controlShell.style.borderRadius = morph.radius + "px";
997
+ controlShell.style.opacity = String(Math.max(morph.shellOpacity, controls.drag ? 0.7 : 0) * groupOpacity);
998
+ bar.style.transformOrigin = "50% 50%";
999
+ bar.style.transform = "translate(" + morph.dx + "px," + morph.dy + "px) scale(" + morph.contentScale + ")";
1000
+ bar.style.opacity = String(morph.barOpacity * groupOpacity);
1001
+ bar.style.pointerEvents = visible > 0.6 && !hiddenByPanel ? "auto" : "none";
1002
+ orb.style.left = ball.x + "px";
1003
+ orb.style.top = ball.y + "px";
1004
+ orb.style.opacity = String(morph.orbOpacity * (controls.drag ? 0.7 : 0.3) * groupOpacity);
1005
+ orb.style.transform = "scale(" + (1 - 0.2 * visible) + ")";
1006
+ orb.style.pointerEvents = immersiveControls() && visible < 0.5 && !hiddenByPanel ? "auto" : "none";
1007
+ }
1008
+
1009
+ function expandControls(open) {
1010
+ const target = open ? 1 : 0;
1011
+ if (controls.target === target && (controlSprings.has(controls) || controls.value === target)) return;
1012
+ controls.target = target;
1013
+ controlSpring(controls, target, controls.value, renderControls);
1014
+ }
1015
+ function armControlsIdle() {
1016
+ clearTimeout(controls.timer);
1017
+ if (!controlsMayCollapse() || controls.target === 0) return;
1018
+ controls.timer = setTimeout(() => { if (controlsMayCollapse()) expandControls(false); }, 3000);
1019
+ }
1020
+ function layoutControls(safe, anchor) {
1021
+ if (controls.drag && controls.safe && ["x", "y", "w", "h"].some((key) => controls.safe[key] !== safe[key])) {
1022
+ const id = controls.drag.id;
1023
+ controls.drag = null;
1024
+ pressFeedback(orb, false);
1025
+ try { orb.releasePointerCapture(id); } catch {}
1026
+ }
1027
+ controls.safe = safe;
1028
+ const fullscreen = immersiveControls();
1029
+ const v = viewport();
1030
+ const ball = fullscreen && controls.point && controls.preferred
1031
+ ? containedBox(safe, 44, 44, v.x + controls.preferred.u * v.w - 22, v.y + controls.preferred.v * v.h - 22)
1032
+ : orbBounds(safe, anchor, fullscreen ? controls.point : null);
1033
+ if (fullscreen && controls.point) anchor = containedBox(safe, anchor.w, anchor.h, ball.x + 22 - anchor.w / 2, ball.y + 22 - anchor.h / 2);
1034
+ controls.anchor = anchor;
1035
+ controls.orb = ball;
1036
+ const visibility = keysOpen || sheetOpen || composerOpen ? 0 : 1;
1037
+ if (controls.visibilityTarget !== visibility) {
1038
+ controls.visibilityTarget = visibility;
1039
+ controlSpring(controlsVisibility, visibility, controls.visibility, (value) => { controls.visibility = value; renderControls(controls.value); });
1040
+ }
1041
+ if (!fullscreen || keysOpen || sheetOpen || composerOpen || !menu.classList.contains("gone") || !connection.classList.contains("gone")) expandControls(true);
1042
+ renderControls(controls.value);
1043
+ return anchor;
1044
+ }
1045
+ reducedMotion.addEventListener("change", () => {
1046
+ if (!reducedMotion.matches) return;
1047
+ for (const [key, motion] of [...controlSprings]) {
1048
+ controlSprings.delete(key);
1049
+ motion.render(motion.target);
1050
+ motion.done?.(motion.target);
1051
+ }
1052
+ cancelAnimationFrame(controlFrame);
1053
+ controlFrame = 0;
1054
+ });
1055
+ orb.addEventListener("pointerdown", (event) => {
1056
+ event.preventDefault(); event.stopPropagation();
1057
+ if (!controls.orb) return;
1058
+ try { orb.setPointerCapture(event.pointerId); } catch {}
1059
+ clearTimeout(controls.timer);
1060
+ controls.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, origin: { ...controls.orb }, moved: false };
1061
+ pressFeedback(orb, true);
1062
+ renderControls(controls.value);
1063
+ });
1064
+ orb.addEventListener("pointermove", (event) => {
1065
+ event.preventDefault(); event.stopPropagation();
1066
+ const drag = controls.drag;
1067
+ if (!drag || drag.id !== event.pointerId || !controls.safe) return;
1068
+ const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
1069
+ if (Math.hypot(dx, dy) > 6) drag.moved = true;
1070
+ if (!drag.moved) return;
1071
+ const v = viewport();
1072
+ const preferred = { x: drag.origin.x + dx + 22, y: drag.origin.y + dy + 22 };
1073
+ const safe = interactionViewport(v, chrome.insets || { top: 0, right: 0, bottom: 0, left: 0 }, chrome.divisions, preferred);
1074
+ const point = containedBox(safe, 44, 44, preferred.x - 22, preferred.y - 22);
1075
+ controls.point = { u: (point.x - safe.x - 8) / Math.max(1, safe.w - 60), v: (point.y - safe.y - 8) / Math.max(1, safe.h - 60) };
1076
+ controls.preferred = { u: (point.x + 22 - v.x) / Math.max(1, v.w), v: (point.y + 22 - v.y) / Math.max(1, v.h) };
1077
+ controls.safe = safe;
1078
+ controls.orb = point;
1079
+ renderControls(controls.value);
1080
+ });
1081
+ function releaseOrb(event, cancelled) {
1082
+ event.preventDefault(); event.stopPropagation();
1083
+ const drag = controls.drag;
1084
+ if (!drag || drag.id !== event.pointerId) return;
1085
+ controls.drag = null;
1086
+ try { orb.releasePointerCapture(event.pointerId); } catch {}
1087
+ pressFeedback(orb, false);
1088
+ layout();
1089
+ if (!cancelled && !drag.moved) wake();
1090
+ else renderControls(controls.value);
1091
+ }
1092
+ orb.addEventListener("pointerup", (event) => releaseOrb(event, false));
1093
+ orb.addEventListener("pointercancel", (event) => releaseOrb(event, true));
1094
+ orb.addEventListener("lostpointercapture", (event) => releaseOrb(event, true));
1095
+ document.addEventListener("visibilitychange", () => {
1096
+ if (!document.hidden) {
1097
+ layout();
1098
+ armControlsIdle();
1099
+ return;
1100
+ }
1101
+ const captured = controls.drag?.id;
1102
+ controls.drag = null;
1103
+ if (captured !== undefined) { try { orb.releasePointerCapture(captured); } catch {} }
1104
+ for (const element of [...pressedControls]) pressFeedback(element, false);
1105
+ clearTimeout(controls.timer);
1106
+ });
1107
+ for (const name of ["touchstart", "touchend", "mousedown", "click"]) orb.addEventListener(name, (event) => { event.preventDefault(); event.stopPropagation(); }, { passive: false });
1108
+
661
1109
  function layout() {
662
- const v = viewport(), inset = chrome.insets;
1110
+ const v = viewport(), inset = chrome.insets, screenSafe = usableViewport(v, inset);
1111
+ const preferred = controls.preferred ? { x: v.x + controls.preferred.u * v.w, y: v.y + controls.preferred.v * v.h } : null;
1112
+ const safe = interactionViewport(v, inset, chrome.divisions, preferred);
1113
+ const divided = safe.x !== screenSafe.x || safe.y !== screenSafe.y || safe.w !== screenSafe.w || safe.h !== screenSafe.h;
1114
+ const horizontalDivision = divided && safe.h < screenSafe.h;
1115
+ const controlInset = { top: safe.y - v.y, right: v.x + v.w - safe.x - safe.w, bottom: v.y + v.h - safe.y - safe.h, left: safe.x - v.x };
1116
+ // The app keeps its authored type sizes while window geometry remains adaptive.
1117
+ const fontScale = 1;
1118
+ document.documentElement.style.setProperty("--text-scale", String(fontScale));
663
1119
  if (v.w !== seen.w) Object.assign(seen, { w: v.w, h: v.h });
664
1120
  else if (v.h > seen.h) seen.h = v.h;
665
1121
  if (seen.h - v.h > 120 && fieldFocused()) seen.follows = true;
666
1122
  // A phone lying down has its room beside the picture, not under it.
667
- const wide = v.w > v.h && v.w >= 560;
1123
+ const wide = !horizontalDivision && (divided || safe.w > safe.h && safe.w >= 560);
668
1124
  // Beside the picture, things take the side without the camera.
669
- const onLeft = chrome.clear ? chrome.clear === "left" : inset.left < inset.right;
1125
+ const onLeft = chrome.clear ? chrome.clear === "left" : controlInset.left < controlInset.right;
670
1126
  // Under the picture: the box text is written in, or the key bar (above the keyboard, or above the phone's
671
1127
  // bottom edge when the keys are a real keyboard's), or the shortcuts. Each has the picture make room for it.
672
- let under = 0, aside = 0;
1128
+ let under = 0, aside = 0, over = 0;
673
1129
  composer.classList.toggle("gone", !composerOpen);
674
- composer.classList.toggle("wide", wide);
1130
+ const composerWide = wide && safe.w >= 560;
1131
+ composer.classList.toggle("wide", composerWide);
675
1132
  keys.classList.toggle("gone", !keysOpen || composerOpen);
676
- sheet.classList.toggle("gone", !sheetOpen);
1133
+ sheet.classList.toggle("gone", !sheetOpen && !sheetTransition);
677
1134
  sheet.classList.toggle("narrow", wide);
1135
+ sheet.classList.toggle("folded", divided);
678
1136
  if (composerOpen) {
679
- composer.style.padding = "8px " + (8 + inset.right) + "px " + (8 + inset.bottom) + "px " + (8 + inset.left) + "px";
680
- under = composer.offsetHeight;
681
- composer.style.top = v.y + v.h - under + "px";
1137
+ composer.style.maxHeight = safe.h + "px";
1138
+ composer.style.left = divided ? safe.x + "px" : "0px";
1139
+ composer.style.right = divided ? "auto" : "0px";
1140
+ composer.style.width = divided ? safe.w + "px" : "";
1141
+ composer.style.padding = divided ? "8px" : "8px " + (8 + inset.right) + "px " + (8 + inset.bottom) + "px " + (8 + inset.left) + "px";
1142
+ words.style.height = "";
1143
+ words.style.height = clamp(words.scrollHeight, (composerWide ? 44 : 66) * fontScale, (composerWide ? 66 : 132) * fontScale) + "px";
1144
+ const height = composer.offsetHeight, top = divided ? safe.y + safe.h - height : v.y + v.h - height;
1145
+ composer.style.top = top + "px";
1146
+ if (horizontalDivision && safe.y + safe.h / 2 < v.y + v.h / 2) over = top + height - v.y + 8;
1147
+ else under = v.y + v.h - top;
682
1148
  } else if (keysOpen) {
683
- under = 46 + inset.bottom;
684
- keys.style.top = v.y + v.h - under + "px";
685
- keys.style.height = under + "px";
686
- // Clear of the camera, with the phone lying down.
687
- keys.style.padding = "0 " + (5 + inset.right) + "px " + inset.bottom + "px " + (5 + inset.left) + "px";
688
- } else if (sheetOpen && wide) {
689
- // Clear of the screen's round corners; on a side that may have something of the system's in it, clear of that too.
690
- const edge = chrome.clear ? 12 : Math.max(12, onLeft ? inset.left : inset.right), top = Math.max(inset.top, 12);
691
- const width = Math.min(320, Math.round(v.w * 0.42));
1149
+ const height = divided ? Math.min(56, safe.h) : 44 * fontScale + 12 + inset.bottom;
1150
+ const top = divided ? safe.y + safe.h - height : v.y + v.h - height;
1151
+ keys.style.left = divided ? safe.x + "px" : "0px";
1152
+ keys.style.right = divided ? "auto" : "0px";
1153
+ keys.style.width = divided ? safe.w + "px" : "";
1154
+ keys.style.top = top + "px";
1155
+ keys.style.height = height + "px";
1156
+ keys.style.padding = divided ? "0 5px" : "0 " + (5 + inset.right) + "px " + inset.bottom + "px " + (5 + inset.left) + "px";
1157
+ if (horizontalDivision && safe.y + safe.h / 2 < v.y + v.h / 2) over = top + height - v.y + 8;
1158
+ else under = v.y + v.h - top;
1159
+ } else if ((sheetOpen || sheetTransition) && wide) {
1160
+ sheetDock = onLeft ? "left" : "right";
1161
+ const edge = chrome.clear ? 12 : Math.max(12, onLeft ? controlInset.left : controlInset.right), top = Math.max(controlInset.top, 12);
1162
+ const width = Math.min(320, Math.max(180, Math.round(safe.w * 0.42)), Math.max(1, safe.w - 24));
692
1163
  sheet.style.width = width + "px";
693
- sheet.style.height = Math.max(96, v.h - top - 12) + "px";
1164
+ sheet.style.height = Math.max(1, v.h - top - Math.max(controlInset.bottom, 12)) + "px";
694
1165
  sheet.style.left = (onLeft ? v.x + edge : v.x + v.w - edge - width) + "px";
695
1166
  sheet.style.top = v.y + top + "px";
696
- aside = edge + width + 8;
697
- } else if (sheetOpen) {
698
- const edge = Math.max(inset.bottom, 8), side = Math.max(inset.left, inset.right, 8), most = Math.max(96, v.h - inset.top - edge - 8);
699
- // All the room there is while a name is typed into it: the keyboard has the rest.
1167
+ aside = sheetOpen ? edge + width + 8 : 0;
1168
+ } else if (sheetOpen || sheetTransition) {
1169
+ const upper = horizontalDivision && safe.y + safe.h / 2 < v.y + v.h / 2;
1170
+ sheetDock = upper ? "top" : "bottom";
1171
+ const edge = divided ? 8 : Math.max(inset.bottom, 8), side = divided ? 8 : Math.max(inset.left, inset.right, 8);
1172
+ const most = Math.max(1, divided ? safe.h - 16 : v.h - inset.top - edge - 8);
700
1173
  const height = document.activeElement === naming ? most : Math.min(most, clamp(Math.round(v.h * 0.5), 260, 400));
701
- sheet.style.width = v.w - side * 2 + "px";
1174
+ const top = divided ? upper ? safe.y + 8 : safe.y + safe.h - edge - height : v.y + v.h - edge - height;
1175
+ sheet.style.width = Math.max(1, (divided ? safe.w : v.w) - side * 2) + "px";
702
1176
  sheet.style.height = height + "px";
703
- sheet.style.left = v.x + side + "px";
704
- sheet.style.top = v.y + v.h - edge - height + "px";
705
- under = height + edge + 8;
1177
+ sheet.style.left = (divided ? safe.x + side : v.x + side) + "px";
1178
+ sheet.style.top = top + "px";
1179
+ if (sheetOpen) {
1180
+ if (upper) over = top + height - v.y + 8;
1181
+ else under = v.y + v.h - top + 8;
1182
+ }
706
1183
  }
707
- const left = Math.max(inset.left, onLeft ? aside : 0), right = Math.max(inset.right, onLeft ? 0 : aside);
708
- area = { x: v.x + left, y: v.y + inset.top, w: Math.max(1, v.w - left - right), h: Math.max(1, v.h - inset.top - under) };
1184
+ // Immersive video uses the entire surface; controls still stay inside the system's safe rectangle.
1185
+ const pictureInsets = chrome.fullscreen || document.fullscreenElement ? { top: 0, right: 0, bottom: 0, left: 0 } : inset;
1186
+ const left = Math.max(pictureInsets.left, onLeft ? aside : 0), right = Math.max(pictureInsets.right, onLeft ? 0 : aside);
1187
+ const top = Math.max(pictureInsets.top, over);
1188
+ area = { x: v.x + left, y: v.y + top, w: Math.max(1, v.w - left - right), h: Math.max(1, v.h - top - Math.max(under, pictureInsets.bottom)) };
709
1189
  if (content.w) {
710
1190
  fit = Math.min(area.w / content.w, area.h / content.h);
711
1191
  const w = content.w * fit * zoom, h = content.h * fit * zoom;
@@ -723,8 +1203,13 @@ function layout() {
723
1203
  }
724
1204
  // The toolbar stands in the black beside the picture when there is some, under it otherwise; whatever opens takes its place.
725
1205
  const beside = content.w ? area.w - content.w * fit > area.h - content.h * fit + inset.bottom : v.w > v.h;
1206
+ if (bar.classList.contains("side") !== beside) {
1207
+ bar.scrollLeft = 0;
1208
+ bar.scrollTop = 0;
1209
+ }
726
1210
  bar.classList.toggle("side", beside);
727
- bar.classList.toggle("hidden", keysOpen || sheetOpen || composerOpen);
1211
+ bar.style.maxWidth = Math.max(1, safe.w - 16) + "px";
1212
+ bar.style.maxHeight = Math.max(1, safe.h - 16) + "px";
728
1213
  const size = { w: bar.offsetWidth, h: bar.offsetHeight };
729
1214
  // Beside the picture it stands in the middle of the black there.
730
1215
  if (beside) {
@@ -734,16 +1219,28 @@ function layout() {
734
1219
  bar.style.left = (onLeft ? v.x + gap : v.x + v.w - gap - size.w) + "px";
735
1220
  bar.style.top = area.y + (area.h - size.h) / 2 + "px";
736
1221
  } else {
737
- bar.style.left = v.x + (v.w - size.w) / 2 + "px";
1222
+ bar.style.left = (safe.w >= 600 ? safe.x + safe.w - size.w - 16 : safe.x + (safe.w - size.w) / 2) + "px";
738
1223
  bar.style.top = v.y + v.h - Math.max(inset.bottom, 8) - 8 - size.h + "px";
739
1224
  }
740
- if (!menu.classList.contains("gone")) {
741
- const box = bar.getBoundingClientRect();
742
- menu.style.left = clamp(!beside ? box.left + box.width / 2 - 124 : onLeft ? box.right + 10 : box.left - 248 - 10, v.x + 8, v.x + v.w - 256) + "px";
743
- menu.style.top = clamp(beside ? box.top : box.top - menu.offsetHeight - 10, v.y + inset.top + 8, v.y + v.h - menu.offsetHeight - 8) + "px";
1225
+ const boundedBar = layoutControls(safe, containedBox(safe, size.w, size.h, parseFloat(bar.style.left), parseFloat(bar.style.top)));
1226
+ bar.style.left = boundedBar.x + "px";
1227
+ bar.style.top = boundedBar.y + "px";
1228
+ const horizontalMenu = fontScale <= 1.15 && !beside && safe.w >= 468 + size.w + 32;
1229
+ menu.classList.toggle("horizontal", horizontalMenu);
1230
+ for (const panel of [menu, connection]) {
1231
+ if (panel.classList.contains("gone")) continue;
1232
+ panel.style.width = Math.min(panel === menu && horizontalMenu ? 468 : 232 * fontScale, Math.max(1, safe.w - 16)) + "px";
1233
+ panel.style.maxHeight = Math.max(1, safe.h - 16) + "px";
1234
+ const placed = anchoredPanel(safe, content.w ? shown : { x: 0, y: 0, w: 0, h: 0 }, boundedBar, panel.offsetWidth, panel.offsetHeight);
1235
+ panel.style.left = placed.x + "px";
1236
+ panel.style.top = placed.y + "px";
1237
+ drawPopover(panel);
744
1238
  }
1239
+ toast.style.left = safe.x + safe.w / 2 + "px";
1240
+ toast.style.maxWidth = Math.max(1, Math.min(420, safe.w - 24)) + "px";
745
1241
  toast.style.top = area.y + 14 + "px";
746
1242
  place();
1243
+ animatePresentation();
747
1244
  }
748
1245
 
749
1246
  function place() {
@@ -753,11 +1250,18 @@ function place() {
753
1250
  pointer.style.transform = "translate(" + (shown.x + cursor.x * shown.w) + "px," + (shown.y + cursor.y * shown.h) + "px)" + size;
754
1251
  }
755
1252
 
756
- addEventListener("resize", layout);
1253
+ let pendingLayout = 0;
1254
+ function requestLayout() {
1255
+ if (pendingLayout) return;
1256
+ pendingLayout = requestAnimationFrame(() => { pendingLayout = 0; layout(); });
1257
+ }
1258
+ addEventListener("resize", requestLayout);
757
1259
  if (window.visualViewport) {
758
- visualViewport.addEventListener("resize", layout);
759
- visualViewport.addEventListener("scroll", layout);
1260
+ visualViewport.addEventListener("resize", requestLayout);
1261
+ visualViewport.addEventListener("scroll", requestLayout);
760
1262
  }
1263
+ // A resized native WebView can change its frame before the window reports a new viewport.
1264
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(requestLayout).observe(stage);
761
1265
 
762
1266
  function zoomTo(next, at) {
763
1267
  // The point of the picture under the fingers stays under them.
@@ -920,7 +1424,8 @@ function endOne() {
920
1424
  }
921
1425
 
922
1426
  stage.addEventListener("pointerdown", (event) => {
923
- wake();
1427
+ if (presentation.active) { event.preventDefault(); return; }
1428
+ if (!immersiveControls() || controls.target > 0) wake();
924
1429
  // A touch that closes the menu, or the shortcuts, does nothing else.
925
1430
  const closing = [closeMenu(), closeSheet()].some(Boolean);
926
1431
  if (event.pointerType === "mouse") return closing ? undefined : mouseDown(event);
@@ -952,6 +1457,7 @@ stage.addEventListener("pointerdown", (event) => {
952
1457
  });
953
1458
 
954
1459
  stage.addEventListener("pointermove", (event) => {
1460
+ if (presentation.active) return;
955
1461
  if (event.pointerType === "mouse") return mouseMove(event);
956
1462
  const touch = touches.get(event.pointerId);
957
1463
  if (!touch || !gesture) return;
@@ -1083,6 +1589,7 @@ function mouseUp(event) {
1083
1589
  }
1084
1590
  stage.addEventListener("wheel", (event) => {
1085
1591
  event.preventDefault();
1592
+ if (presentation.active) return;
1086
1593
  if (controlling() && !event.ctrlKey) return act({ t: "scroll", dx: -event.deltaX, dy: -event.deltaY });
1087
1594
  zoomTo(zoom * Math.exp(-event.deltaY / 300), { x: event.clientX, y: event.clientY });
1088
1595
  }, { passive: false });
@@ -1248,6 +1755,7 @@ function dropKeys() {
1248
1755
  mods.clear();
1249
1756
  drawKeys();
1250
1757
  refresh();
1758
+ armControlsIdle();
1251
1759
  }
1252
1760
 
1253
1761
  function raiseKeys() {
@@ -1284,22 +1792,26 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) sett
1284
1792
 
1285
1793
  /** A control that works on touch-up and never takes the focus (the keyboard stays as it is). */
1286
1794
  function tappable(element, action) {
1287
- element.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); wake(); });
1795
+ element.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); wake(); pressFeedback(element, true); });
1796
+ element.addEventListener("pointercancel", () => pressFeedback(element, false));
1797
+ element.addEventListener("pointerleave", () => pressFeedback(element, false));
1288
1798
  element.addEventListener("touchstart", (event) => event.preventDefault(), { passive: false });
1289
1799
  element.addEventListener("mousedown", (event) => event.preventDefault());
1290
1800
  element.addEventListener("pointerup", (event) => {
1291
1801
  event.preventDefault();
1292
1802
  event.stopPropagation();
1293
- const box = element.getBoundingClientRect();
1294
- if (event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom) action();
1803
+ pressFeedback(element, false);
1804
+ const box = element.getBoundingClientRect(), cx = box.left + box.width / 2, cy = box.top + box.height / 2;
1805
+ if (Math.abs(event.clientX - cx) <= element.offsetWidth / 2 && Math.abs(event.clientY - cy) <= element.offsetHeight / 2) action();
1295
1806
  });
1296
1807
  }
1297
1808
 
1298
1809
  /** The same inside something that scrolls: the touch is left to the browser, and counts if it stayed where it landed. */
1299
1810
  function pressable(element, action) {
1300
1811
  let from = null;
1301
- element.addEventListener("pointerdown", (event) => { event.stopPropagation(); wake(); from = { id: event.pointerId, x: event.clientX, y: event.clientY }; });
1302
- element.addEventListener("pointercancel", () => { from = null; });
1812
+ element.addEventListener("pointerdown", (event) => { event.stopPropagation(); wake(); pressFeedback(element, true); from = { id: event.pointerId, x: event.clientX, y: event.clientY }; });
1813
+ element.addEventListener("pointercancel", () => { from = null; pressFeedback(element, false); });
1814
+ element.addEventListener("pointerleave", () => pressFeedback(element, false));
1303
1815
  // The mouse's events a browser makes up after a touch would land on whatever the action has left under the
1304
1816
  // finger, and take the focus from the field it gave it to. A touch that scrolled has none to stop.
1305
1817
  element.addEventListener("touchend", (event) => { if (event.cancelable) event.preventDefault(); });
@@ -1307,15 +1819,15 @@ function pressable(element, action) {
1307
1819
  element.addEventListener("pointerup", (event) => {
1308
1820
  const was = from;
1309
1821
  from = null;
1822
+ pressFeedback(element, false);
1823
+ event.stopPropagation();
1310
1824
  if (was && was.id === event.pointerId && Math.hypot(event.clientX - was.x, event.clientY - was.y) < SLOP) action();
1311
1825
  });
1312
1826
  }
1313
1827
 
1314
- let idle = 0;
1315
1828
  function wake() {
1316
- bar.classList.remove("idle");
1317
- clearTimeout(idle);
1318
- idle = setTimeout(() => { if (menu.classList.contains("gone")) bar.classList.add("idle"); }, 4500);
1829
+ expandControls(true);
1830
+ armControlsIdle();
1319
1831
  }
1320
1832
 
1321
1833
  let toastTimer = 0;
@@ -1358,8 +1870,9 @@ function setMode(next) {
1358
1870
  }
1359
1871
 
1360
1872
  function closeMenu() {
1361
- if (menu.classList.contains("gone")) return false;
1362
- menu.classList.add("gone");
1873
+ if (!popoverOpen(menu) && !popoverOpen(connection)) return false;
1874
+ presentPopover(menu, false);
1875
+ presentPopover(connection, false);
1363
1876
  clearInterval(statusTimer);
1364
1877
  wake();
1365
1878
  return true;
@@ -1376,9 +1889,13 @@ function refresh() {
1376
1889
  const full = app ? chrome.fullscreen : !!document.fullscreenElement;
1377
1890
  $("full").classList.toggle("gone", !app && !document.fullscreenEnabled);
1378
1891
  $("full").innerHTML = full ? ICON.shrink : ICON.expand;
1379
- for (const choice of choices) choice.classList.toggle("on", choice.dataset.mode === mode);
1892
+ for (const choice of choices) {
1893
+ choice.classList.toggle("on", choice.dataset.mode === mode);
1894
+ choice.setAttribute("aria-pressed", String(choice.dataset.mode === mode));
1895
+ }
1380
1896
  dress();
1381
1897
  layout();
1898
+ armControlsIdle();
1382
1899
  }
1383
1900
 
1384
1901
  function drawKeys() {
@@ -1418,7 +1935,7 @@ async function vitals(pc, before) {
1418
1935
  };
1419
1936
  }
1420
1937
 
1421
- /** The line at the foot of the menu: which way the picture comes and, for a video track, how it is doing. */
1938
+ /** Connection details are requested separately from the control mode. */
1422
1939
  let statusTimer = 0, statusWas = null;
1423
1940
  async function status() {
1424
1941
  const line = $("status");
@@ -1438,25 +1955,33 @@ async function status() {
1438
1955
 
1439
1956
  const ICON = JSON.parse($("icons").textContent);
1440
1957
  const choices = menu.querySelectorAll(".choice");
1441
- tappable($("mode"), () => {
1958
+ pressable($("mode"), () => {
1442
1959
  closeSheet();
1443
- menu.classList.toggle("gone");
1444
- clearInterval(statusTimer);
1445
- // Kept up to date while it can be seen.
1446
- if (!menu.classList.contains("gone")) {
1960
+ const wasOpen = popoverOpen(menu);
1961
+ closeMenu();
1962
+ if (!wasOpen) presentPopover(menu, true);
1963
+ });
1964
+ pressable($("info"), () => {
1965
+ closeSheet();
1966
+ const wasOpen = popoverOpen(connection);
1967
+ closeMenu();
1968
+ if (!wasOpen) presentPopover(connection, true);
1969
+ if (!wasOpen) {
1447
1970
  statusWas = null;
1448
1971
  status();
1449
1972
  statusTimer = setInterval(status, 1500);
1450
1973
  }
1451
1974
  layout();
1452
1975
  });
1976
+ tappable($("closeinfo"), closeMenu);
1453
1977
  // On a phone the button is there only while the key bar is not, which is while the page knows of no keyboard:
1454
1978
  // there it only ever raises one, whatever state the field was left in. With a mouse it gives the keys and takes them.
1455
- tappable($("keyboard"), () => (coarse ? raiseKeys() : toggleKeys()));
1456
- tappable($("quick"), openSheet);
1457
- tappable($("fit"), () => zoomTo(1, { x: area.x + area.w / 2, y: area.y + area.h / 2 }));
1458
- tappable($("rotate"), () => tellApp({ type: "landscape", on: !chrome.landscape }));
1459
- tappable($("full"), () => {
1979
+ pressable($("keyboard"), () => (coarse ? raiseKeys() : toggleKeys()));
1980
+ pressable($("quick"), openSheet);
1981
+ pressable($("fit"), () => zoomTo(1, { x: area.x + area.w / 2, y: area.y + area.h / 2 }));
1982
+ pressable($("rotate"), () => tellApp({ type: "landscape", on: !chrome.landscape }));
1983
+ pressable($("full"), () => {
1984
+ beginPresentation(app ? !chrome.fullscreen : !document.fullscreenElement);
1460
1985
  if (app) return tellApp({ type: "fullscreen", on: !chrome.fullscreen });
1461
1986
  if (document.fullscreenElement) document.exitFullscreen();
1462
1987
  else document.documentElement.requestFullscreen().catch(() => {});
@@ -1464,10 +1989,10 @@ tappable($("full"), () => {
1464
1989
  document.addEventListener("fullscreenchange", refresh);
1465
1990
  for (const choice of choices) {
1466
1991
  choice.querySelector("i").innerHTML = ICON[choice.dataset.mode];
1467
- tappable(choice, () => { closeMenu(); setMode(choice.dataset.mode); });
1992
+ pressable(choice, () => { closeMenu(); setMode(choice.dataset.mode); });
1468
1993
  }
1469
1994
  for (const key of keys.children) {
1470
- tappable(key, () => {
1995
+ pressable(key, () => {
1471
1996
  const mod = key.dataset.mod;
1472
1997
  if (mod) {
1473
1998
  if (!mods.delete(mod)) mods.add(mod);
@@ -1632,6 +2157,7 @@ tappable($("makersave"), saveMaker);
1632
2157
 
1633
2158
  function openSheet() {
1634
2159
  if (mode === "view" || sheetOpen) return;
2160
+ beginSheetTransition(true);
1635
2161
  closeMenu();
1636
2162
  closeComposer();
1637
2163
  back = keysOpen && fieldFocused();
@@ -1646,6 +2172,7 @@ function openSheet() {
1646
2172
  /** True when there was a sheet to close. toKeys: the keyboard it took the place of comes back. */
1647
2173
  function closeSheet(toKeys) {
1648
2174
  if (!sheetOpen) return false;
2175
+ beginSheetTransition(false);
1649
2176
  sheetOpen = false;
1650
2177
  disarm();
1651
2178
  if (document.activeElement === naming) naming.blur();
@@ -1672,12 +2199,9 @@ words.addEventListener("compositionend", () => { writing = false; });
1672
2199
  const unfinished = () => writing && (hint("先在键盘上选好字,再发送", 2500), true);
1673
2200
 
1674
2201
  function grow() {
1675
- const wide = composer.classList.contains("wide");
1676
2202
  // What to write there, said by the page: a phone's browser has been seen to draw a text box's own
1677
2203
  // placeholder cut to the width of what an input method last wrote in it.
1678
2204
  $("wordshint").classList.toggle("gone", words.value !== "");
1679
- words.style.height = "";
1680
- words.style.height = clamp(words.scrollHeight, wide ? 44 : 66, wide ? 66 : 132) + "px";
1681
2205
  layout();
1682
2206
  }
1683
2207
 
@@ -1740,6 +2264,7 @@ $("composeclose").innerHTML = ICON.down;
1740
2264
 
1741
2265
  /** The app tells the page what it did with the phone's screen, whether the keyboard is on it, and what shortcuts it keeps for the user. */
1742
2266
  window.linkshellChrome = (next) => {
2267
+ if (typeof next.fullscreen === "boolean" && next.fullscreen !== chrome.fullscreen) beginPresentation(next.fullscreen);
1743
2268
  const was = chrome.keyboard;
1744
2269
  Object.assign(chrome, next);
1745
2270
  if (chrome.keyboard === true) wentDown = false;
@@ -1752,6 +2277,7 @@ window.linkshellChrome = (next) => {
1752
2277
  drawMine();
1753
2278
  }
1754
2279
  refresh();
2280
+ armControlsIdle();
1755
2281
  };
1756
2282
 
1757
2283
  // ---- Measuring (?measure=1) ------------------------------------------------
@@ -1919,23 +2445,29 @@ export function viewerPage() {
1919
2445
  <title>屏幕</title>
1920
2446
  <style>${STYLE}</style></head>
1921
2447
  <body>
1922
- <div id="stage"><canvas id="screen"></canvas><video id="video" class="gone" autoplay playsinline muted></video><div id="pointer"></div></div>
2448
+ <div id="stage"><div id="picture"><canvas id="screen"></canvas><video id="video" class="gone" autoplay playsinline muted></video><div id="pointer"></div></div></div>
1923
2449
  <div id="note">正在连接电脑屏幕…</div>
1924
2450
  <div id="toast" class="glass"></div>
1925
2451
  <div id="menu" class="glass gone">
1926
- <div class="choice" data-mode="view"><i></i><div><b>只看</b><span>不会碰到电脑上的任何东西</span></div></div>
1927
- <div class="choice" data-mode="trackpad"><i></i><div><b>触控板</b><span>滑动移动指针,轻点点击</span></div></div>
1928
- <div class="choice" data-mode="touch"><i></i><div><b>点按</b><span>点哪里,就点击哪里</span></div></div>
2452
+ <div class="choice" role="button" aria-label="只看,不操作电脑" data-mode="view"><i></i><div><b>只看</b><span>不操作电脑</span></div></div>
2453
+ <div class="choice" role="button" aria-label="触控板,滑动指针,轻点点击" data-mode="trackpad"><i></i><div><b>触控板</b><span>滑动指针 · 轻点点击</span></div></div>
2454
+ <div class="choice" role="button" aria-label="点按,点按画面直接操作" data-mode="touch"><i></i><div><b>点按</b><span>点按画面直接操作</span></div></div>
2455
+ </div>
2456
+ <div id="connection" class="glass gone">
2457
+ <div id="connectionhead"><span>连接信息</span><div class="tool" id="closeinfo" role="button" aria-label="关闭连接信息">${ICONS.close}</div></div>
1929
2458
  <div id="status"></div>
1930
2459
  </div>
1931
- <div id="bar" class="glass">
2460
+ <div id="control-shell" class="glass" aria-hidden="true"></div>
2461
+ <div id="bar">
1932
2462
  <div class="tool" id="mode" role="button" aria-label="控制方式"></div>
1933
2463
  <div class="tool" id="keyboard" role="button" aria-label="键盘">${ICONS.keyboard}</div>
1934
2464
  <div class="tool" id="quick" role="button" aria-label="快捷操作">${ICONS.quick}</div>
1935
2465
  <div class="tool" id="fit" role="button" aria-label="还原缩放">${ICONS.fit}</div>
1936
2466
  <div class="tool" id="rotate" role="button" aria-label="横屏">${ICONS.rotate}</div>
2467
+ <div class="tool" id="info" role="button" aria-label="连接信息">${ICONS.info}</div>
1937
2468
  <div class="tool" id="full" role="button" aria-label="全屏"></div>
1938
2469
  </div>
2470
+ <div id="orb" role="button" aria-label="展开屏幕控制"><div class="orb-core"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg></div></div>
1939
2471
  <div id="sheet" class="glass gone">
1940
2472
  <div id="sheethead">
1941
2473
  <div id="compose" role="button">发送文字…</div>