mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
fix(iPad): CJK input positioning, paste dialog, and voice dictation duplication
Three iPad-specific issues fixed:
1. CJK input hidden behind keyboard: updateLayoutForKeyboard() gate changed
from screen-size to touch-device detection. On iPad, CJK textarea (always
position:fixed) gets bottom offset computed from keyboard HEIGHT directly
instead of keyboardOffset (which depends on visualViewport.offsetTop that
iOS adjusts when the CJK textarea receives focus). Toolbar/accessory bar
transforms remain phone-only (they're normal-flow on iPad).
2. Paste dialog invisible on iPad: paste overlay CSS was inside
@media (max-width: 430px) phone breakpoint — iPad (≥768px) had no styling.
Extracted to universal section alongside keyboard accessory bar styles.
3. Voice dictation character duplication (Doubao/third-party IME):
iOS voice dictation does NOT fire composition events (WebKit Bug 261764).
Text arrives as bare input events; refinement is a delete→reinsert cycle.
Rewrote CJK input handler with two-tier debounce:
- Keyboard typing (no delete/replacement events): 150ms debounce
- Dictation mode (deleteContentBackward or insertReplacementText detected):
1500ms debounce, persists 3s to cover multi-word dictation
- Composition path (compositionend): immediate flush, unchanged
- Keydown singles/Enter/Esc/Ctrl: immediate, unchanged
Also: keep cjkActive=true on blur while CJK is visible (prevents xterm
from processing duplicate input when iOS dictation UI steals focus);
keydown single-char sends tracked via timestamp to suppress the echo
input event that third-party IMEs fire despite preventDefault.
This commit is contained in:
+110
-47
@@ -14,13 +14,21 @@
|
|||||||
* This means compositionstart fires even for English text, and compositionend
|
* This means compositionstart fires even for English text, and compositionend
|
||||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||||
*
|
*
|
||||||
* We use InputEvent.inputType to distinguish:
|
* During composition, all input events are ignored — only compositionend
|
||||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
* triggers a flush (CJK candidate selection).
|
||||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
|
||||||
*
|
*
|
||||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
* ## iOS dictation challenge (WebKit Bug 261764)
|
||||||
* English words confirmed by IME). `insertCompositionText` waits for
|
*
|
||||||
* compositionend (CJK candidate selection).
|
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
|
||||||
|
* as bare input events with isComposing === false. Dictation refinement is
|
||||||
|
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
|
||||||
|
* all within a few ms. Flushing on every input event would send irrevocable
|
||||||
|
* provisional text to the PTY, causing duplication when the IME replaces it.
|
||||||
|
*
|
||||||
|
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||||
|
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||||
|
* Keyboard typing of single printable characters still goes through the
|
||||||
|
* keydown handler (immediate, no debounce).
|
||||||
*
|
*
|
||||||
* ## Phantom character for Android backspace
|
* ## Phantom character for Android backspace
|
||||||
*
|
*
|
||||||
@@ -44,11 +52,27 @@ const CjkInput = (() => {
|
|||||||
let _send = null;
|
let _send = null;
|
||||||
let _initialized = false;
|
let _initialized = false;
|
||||||
let _composing = false;
|
let _composing = false;
|
||||||
|
let _flushTimer = null;
|
||||||
|
let _dictationActive = false;
|
||||||
|
let _dictationDecayTimer = null;
|
||||||
|
let _keydownSentAt = 0;
|
||||||
const _listeners = {};
|
const _listeners = {};
|
||||||
|
|
||||||
// Zero-width space: always present in textarea so Android backspace has
|
const PHANTOM = '';
|
||||||
// something to delete, triggering the `input` event we need to detect it.
|
|
||||||
const PHANTOM = '\u200B';
|
// Two-tier debounce for non-composition input:
|
||||||
|
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||||
|
// composition events even for keyboard CJK typing)
|
||||||
|
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
|
||||||
|
// refinement cycles without composition events — WebKit Bug 261764)
|
||||||
|
//
|
||||||
|
// Dictation is detected by deleteContentBackward on non-empty text or
|
||||||
|
// insertReplacementText — signals that the IME is rewriting provisional
|
||||||
|
// text. Once detected, dictation mode persists for 3s (covers multi-word
|
||||||
|
// dictation with natural pauses between words).
|
||||||
|
const DEBOUNCE_KEYBOARD_MS = 150;
|
||||||
|
const DEBOUNCE_DICTATION_MS = 1500;
|
||||||
|
const DICTATION_DECAY_MS = 3000;
|
||||||
|
|
||||||
const PASSTHROUGH_KEYS = {
|
const PASSTHROUGH_KEYS = {
|
||||||
ArrowUp: '\x1b[A',
|
ArrowUp: '\x1b[A',
|
||||||
@@ -64,18 +88,15 @@ const CjkInput = (() => {
|
|||||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Strip phantom characters from a string */
|
|
||||||
function _strip(str) {
|
function _strip(str) {
|
||||||
return str.replace(/\u200B/g, '');
|
return str.replace(//g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset textarea to phantom-only state with cursor at end */
|
|
||||||
function _resetToPhantom() {
|
function _resetToPhantom() {
|
||||||
_textarea.value = PHANTOM;
|
_textarea.value = PHANTOM;
|
||||||
_textarea.setSelectionRange(1, 1);
|
_textarea.setSelectionRange(1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
|
||||||
function _isEffectivelyEmpty() {
|
function _isEffectivelyEmpty() {
|
||||||
return !_strip(_textarea.value);
|
return !_strip(_textarea.value);
|
||||||
}
|
}
|
||||||
@@ -89,12 +110,41 @@ const CjkInput = (() => {
|
|||||||
_resetToPhantom();
|
_resetToPhantom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel any pending debounced flush */
|
||||||
|
function _cancelDebouncedFlush() {
|
||||||
|
if (_flushTimer) {
|
||||||
|
clearTimeout(_flushTimer);
|
||||||
|
_flushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark that dictation rewriting is in progress */
|
||||||
|
function _enterDictationMode() {
|
||||||
|
_dictationActive = true;
|
||||||
|
clearTimeout(_dictationDecayTimer);
|
||||||
|
_dictationDecayTimer = setTimeout(() => {
|
||||||
|
_dictationActive = false;
|
||||||
|
_dictationDecayTimer = null;
|
||||||
|
}, DICTATION_DECAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule a flush after input settles */
|
||||||
|
function _debouncedFlush() {
|
||||||
|
_cancelDebouncedFlush();
|
||||||
|
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
|
||||||
|
_flushTimer = setTimeout(() => {
|
||||||
|
_flushTimer = null;
|
||||||
|
_flush();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init({ send }) {
|
init({ send }) {
|
||||||
if (_initialized) this.destroy();
|
if (_initialized) this.destroy();
|
||||||
|
|
||||||
_send = send;
|
_send = send;
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_flushTimer = null;
|
||||||
_textarea = document.getElementById('cjkInput');
|
_textarea = document.getElementById('cjkInput');
|
||||||
if (!_textarea) return this;
|
if (!_textarea) return this;
|
||||||
|
|
||||||
@@ -105,20 +155,28 @@ const CjkInput = (() => {
|
|||||||
window.cjkActive = true;
|
window.cjkActive = true;
|
||||||
if (!_textarea.value) _resetToPhantom();
|
if (!_textarea.value) _resetToPhantom();
|
||||||
};
|
};
|
||||||
_listeners.blur = () => { window.cjkActive = false; };
|
_listeners.blur = () => {
|
||||||
|
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||||
|
// UI may steal focus temporarily, and clearing the flag during that
|
||||||
|
// window lets xterm's onData process duplicated input.
|
||||||
|
if (!_textarea.classList.contains('cjk-input-visible')) {
|
||||||
|
window.cjkActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||||
_textarea.addEventListener('focus', _listeners.focus);
|
_textarea.addEventListener('focus', _listeners.focus);
|
||||||
_textarea.addEventListener('blur', _listeners.blur);
|
_textarea.addEventListener('blur', _listeners.blur);
|
||||||
|
|
||||||
// ── Composition tracking ──
|
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||||
_listeners.compositionstart = () => {
|
_listeners.compositionstart = () => {
|
||||||
_composing = true;
|
_composing = true;
|
||||||
|
_cancelDebouncedFlush();
|
||||||
// Leave textarea.value untouched — programmatic changes during
|
// Leave textarea.value untouched — programmatic changes during
|
||||||
// compositionstart cancel the IME composition on iOS Safari.
|
// compositionstart cancel the IME composition on iOS Safari.
|
||||||
// The phantom (U+200B) is invisible and _strip() removes it on flush.
|
|
||||||
};
|
};
|
||||||
_listeners.compositionend = () => {
|
_listeners.compositionend = () => {
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_cancelDebouncedFlush();
|
||||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||||
setTimeout(_flush, 0);
|
setTimeout(_flush, 0);
|
||||||
@@ -128,13 +186,10 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||||
_listeners.keydown = (e) => {
|
_listeners.keydown = (e) => {
|
||||||
// Enter: flush accumulated text (or bare Enter if empty).
|
|
||||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
|
||||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
|
||||||
// the IME from also handling Enter (which could double-send or do nothing).
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_cancelDebouncedFlush();
|
||||||
const val = _strip(_textarea.value);
|
const val = _strip(_textarea.value);
|
||||||
if (val) {
|
if (val) {
|
||||||
_send(val + '\r');
|
_send(val + '\r');
|
||||||
@@ -145,15 +200,14 @@ const CjkInput = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: clear textarea (always works)
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_composing = false;
|
_composing = false;
|
||||||
|
_cancelDebouncedFlush();
|
||||||
_resetToPhantom();
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl combos: forward to PTY (always works)
|
|
||||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_send(CTRL_KEYS[e.key]);
|
_send(CTRL_KEYS[e.key]);
|
||||||
@@ -164,7 +218,6 @@ const CjkInput = (() => {
|
|||||||
if (_composing) return;
|
if (_composing) return;
|
||||||
|
|
||||||
// Backspace: forward to PTY when no real text in textarea
|
// Backspace: forward to PTY when no real text in textarea
|
||||||
// (Desktop path — Android uses the input event + phantom approach)
|
|
||||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_send('\x7f');
|
_send('\x7f');
|
||||||
@@ -179,55 +232,62 @@ const CjkInput = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single printable character: send immediately to PTY
|
// Single printable character: send immediately to PTY.
|
||||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||||
|
// still enters the textarea and fires an input event — _keydownSentAt
|
||||||
|
// tells the input handler to skip that echo.
|
||||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_send(e.key);
|
_send(e.key);
|
||||||
|
_keydownSentAt = performance.now();
|
||||||
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||||
|
|
||||||
// ── Input event: the primary path for Android virtual keyboards ──
|
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
|
||||||
// making keydown unreliable. input fires AFTER character insertion and
|
|
||||||
// carries inputType which tells us whether the text is final or tentative.
|
|
||||||
_listeners.input = (e) => {
|
_listeners.input = (e) => {
|
||||||
// ── Backspace / delete detection ──
|
// ── Backspace / delete detection ──
|
||||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
|
||||||
// The phantom character ensures the textarea is never truly empty, so each
|
|
||||||
// press/repeat fires an input event that we can catch here.
|
|
||||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||||
|
if (_composing) return;
|
||||||
if (_isEffectivelyEmpty()) {
|
if (_isEffectivelyEmpty()) {
|
||||||
// No real text left — forward backspace to PTY
|
_cancelDebouncedFlush();
|
||||||
_send('\x7f');
|
_send('\x7f');
|
||||||
_resetToPhantom();
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// User is editing their own text in the textarea — let it be.
|
// Delete on non-empty text outside composition = dictation rewrite.
|
||||||
// Ensure phantom is still present for the NEXT backspace.
|
// The IME is revising provisional text — switch to long debounce.
|
||||||
|
_enterDictationMode();
|
||||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||||
_textarea.value = PHANTOM + _textarea.value;
|
_textarea.value = PHANTOM + _textarea.value;
|
||||||
_textarea.setSelectionRange(1, 1);
|
_textarea.setSelectionRange(1, 1);
|
||||||
}
|
}
|
||||||
|
_debouncedFlush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_composing) {
|
// insertReplacementText = dictation/autocorrect refinement
|
||||||
// insertText during composition = IME committed final text
|
if (e.inputType === 'insertReplacementText') {
|
||||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
_enterDictationMode();
|
||||||
// Flush immediately — this text won't change.
|
_debouncedFlush();
|
||||||
if (e.inputType === 'insertText') {
|
|
||||||
_flush();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
|
||||||
// English prediction). Wait for compositionend to flush.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Outside composition: send immediately
|
|
||||||
_flush();
|
if (_composing) return;
|
||||||
|
|
||||||
|
// Keydown handler already sent this character — just clear the
|
||||||
|
// textarea echo that the IME inserted despite preventDefault.
|
||||||
|
if (performance.now() - _keydownSentAt < 100) {
|
||||||
|
_resetToPhantom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside composition: keyboard typing or voice dictation.
|
||||||
|
// If dictation mode was detected (delete/replacement events seen
|
||||||
|
// recently), use long debounce. Otherwise short debounce for keyboard.
|
||||||
|
_debouncedFlush();
|
||||||
};
|
};
|
||||||
_textarea.addEventListener('input', _listeners.input);
|
_textarea.addEventListener('input', _listeners.input);
|
||||||
|
|
||||||
@@ -236,6 +296,9 @@ const CjkInput = (() => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
_cancelDebouncedFlush();
|
||||||
|
clearTimeout(_dictationDecayTimer);
|
||||||
|
_dictationActive = false;
|
||||||
if (_textarea) {
|
if (_textarea) {
|
||||||
for (const [event, handler] of Object.entries(_listeners)) {
|
for (const [event, handler] of Object.entries(_listeners)) {
|
||||||
if (handler) _textarea.removeEventListener(event, handler);
|
if (handler) _textarea.removeEventListener(event, handler);
|
||||||
|
|||||||
@@ -291,57 +291,50 @@ const KeyboardHandler = {
|
|||||||
updateLayoutForKeyboard() {
|
updateLayoutForKeyboard() {
|
||||||
if (!window.visualViewport) return;
|
if (!window.visualViewport) return;
|
||||||
|
|
||||||
// Only adjust on mobile
|
if (!MobileDetection.isTouchDevice()) {
|
||||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
|
||||||
this.resetLayout();
|
this.resetLayout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolbar = document.querySelector('.toolbar');
|
|
||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
|
||||||
const cjkInput = document.getElementById('cjkInput');
|
const cjkInput = document.getElementById('cjkInput');
|
||||||
const main = document.querySelector('.main');
|
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
|
||||||
|
|
||||||
if (this.keyboardVisible) {
|
if (this.keyboardVisible) {
|
||||||
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
|
|
||||||
// translate up so it sits at the bottom of the visual viewport.
|
|
||||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
|
||||||
// when the user types in xterm's hidden textarea.
|
|
||||||
//
|
|
||||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
|
||||||
// bars are position:fixed, which anchors to the layout viewport — on
|
|
||||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
|
||||||
// the shrunken .app instead (its height tracks --app-height = visual
|
|
||||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
|
||||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
|
||||||
// Android the layout viewport itself shrinks with the keyboard, so
|
|
||||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
|
||||||
const layoutHeight = window.innerHeight;
|
|
||||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
|
||||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
|
||||||
|
|
||||||
// Move toolbar and accessory bar above keyboard.
|
|
||||||
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
|
|
||||||
// the bars are naturally positioned via their CSS bottom values —
|
|
||||||
// just clear the transforms. Never dismiss keyboard state here;
|
|
||||||
// that's handleViewportResize's job.
|
|
||||||
if (toolbar) {
|
|
||||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
|
||||||
}
|
|
||||||
if (accessoryBar) {
|
|
||||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
|
||||||
}
|
|
||||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
|
||||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
|
||||||
// the visual viewport; adding its height here creates a large blank area
|
|
||||||
// above the mobile toolbar on iPhone.
|
|
||||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||||
if (main && keyboardHeight > 0) {
|
|
||||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
// Toolbar and accessory bar: only transform on phones/small tablets where
|
||||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
// they are position:fixed. On iPad (≥768px) they're in normal flow inside
|
||||||
|
// the shrunk .app container and already sit above the keyboard.
|
||||||
|
if (isSmallMedium) {
|
||||||
|
const toolbar = document.querySelector('.toolbar');
|
||||||
|
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||||
|
const main = document.querySelector('.main');
|
||||||
|
|
||||||
|
const layoutHeight = window.innerHeight;
|
||||||
|
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||||
|
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||||
|
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
|
}
|
||||||
|
if (accessoryBar) {
|
||||||
|
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
|
}
|
||||||
|
if (main && keyboardHeight > 0) {
|
||||||
|
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||||
|
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CJK textarea: position on ALL touch devices (it's always position:fixed).
|
||||||
|
// Uses keyboard HEIGHT directly — independent of visualViewport.offsetTop
|
||||||
|
// which iOS adjusts when the CJK textarea receives focus/dictation.
|
||||||
|
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
|
||||||
|
// On phones, sit above toolbar+accessory (84px). On tablets in normal
|
||||||
|
// flow, the toolbar/accessory are in-flow so use a smaller offset.
|
||||||
|
const bottomOffset = isSmallMedium ? 84 : 44;
|
||||||
|
cjkInput.style.bottom = `${keyboardHeight + bottomOffset}px`;
|
||||||
|
cjkInput.style.transform = '';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.resetLayout();
|
this.resetLayout();
|
||||||
@@ -363,6 +356,7 @@ const KeyboardHandler = {
|
|||||||
}
|
}
|
||||||
if (cjkInput) {
|
if (cjkInput) {
|
||||||
cjkInput.style.transform = '';
|
cjkInput.style.transform = '';
|
||||||
|
cjkInput.style.bottom = '';
|
||||||
}
|
}
|
||||||
if (main) {
|
if (main) {
|
||||||
main.style.paddingBottom = '';
|
main.style.paddingBottom = '';
|
||||||
|
|||||||
+84
-79
@@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Paste overlay for iOS clipboard access */
|
/* Paste overlay for iOS clipboard access */
|
||||||
.paste-overlay {
|
/* Paste overlay styles extracted to universal section below (line ~2293+) */
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 15vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-dialog {
|
|
||||||
background: var(--bg-secondary, #1e1e2e);
|
|
||||||
border: 1px solid var(--border-color, #444);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
width: calc(100% - 24px);
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 80px;
|
|
||||||
max-height: 200px;
|
|
||||||
background: var(--bg-primary, #0d0d14);
|
|
||||||
color: var(--text-primary, #e0e0e0);
|
|
||||||
border: 1px solid var(--border-color, #444);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 16px;
|
|
||||||
resize: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color, #7aa2f7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
|
||||||
padding: 8px 18px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Image attach button — left-aligned, accent outline */
|
|
||||||
.paste-image {
|
|
||||||
margin-right: auto;
|
|
||||||
background: var(--bg-tertiary, #333);
|
|
||||||
color: var(--accent-color, #7aa2f7);
|
|
||||||
border: 1px solid var(--accent-color, #7aa2f7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-cancel {
|
|
||||||
background: var(--bg-tertiary, #333);
|
|
||||||
color: var(--text-secondary, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-new {
|
|
||||||
background: var(--bg-tertiary, #333);
|
|
||||||
color: var(--accent-color, #7aa2f7);
|
|
||||||
border: 1px solid var(--accent-color, #7aa2f7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-send {
|
|
||||||
background: var(--accent-color, #7aa2f7);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
|
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
|
||||||
.toolbar-select {
|
.toolbar-select {
|
||||||
@@ -2267,6 +2189,89 @@ html.mobile-init .file-browser-panel {
|
|||||||
background: #3d5f85;
|
background: #3d5f85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Paste Overlay — all mobile/tablet sizes
|
||||||
|
Extracted from phone breakpoint so it applies on iPad too.
|
||||||
|
============================================================================ */
|
||||||
|
.paste-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-dialog {
|
||||||
|
background: var(--bg-secondary, #1e1e2e);
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 200px;
|
||||||
|
background: var(--bg-primary, #0d0d14);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
resize: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color, #7aa2f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-image {
|
||||||
|
margin-right: auto;
|
||||||
|
background: var(--bg-tertiary, #333);
|
||||||
|
color: var(--accent-color, #7aa2f7);
|
||||||
|
border: 1px solid var(--accent-color, #7aa2f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-cancel {
|
||||||
|
background: var(--bg-tertiary, #333);
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-new {
|
||||||
|
background: var(--bg-tertiary, #333);
|
||||||
|
color: var(--accent-color, #7aa2f7);
|
||||||
|
border: 1px solid var(--accent-color, #7aa2f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-send {
|
||||||
|
background: var(--accent-color, #7aa2f7);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
iOS Safari Specific Fixes
|
iOS Safari Specific Fixes
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|||||||
Reference in New Issue
Block a user