Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import {
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { usePreventIosInputZoom } from "~/hooks/usePreventIosInputZoom";
import {
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
type TerminalContextDraft,
Expand Down Expand Up @@ -893,6 +895,13 @@ function ComposerPromptEditorInner({
}: ComposerPromptEditorInnerProps) {
const [editor] = useLexicalComposerContext();
const onChangeRef = useRef(onChange);
const isCoarsePointer = useMediaQuery({ pointer: "coarse" });
const composerFontSizePx = isCoarsePointer ? 16 : 14;
const {
onBlur: handleEditorBlur,
onFocus: handleEditorFocus,
onTouchStartCapture: handleEditorTouchStartCapture,
} = usePreventIosInputZoom();
const initialCursor = clampCollapsedComposerCursor(value, cursor);
const terminalContextsSignature = terminalContextSignature(terminalContexts);
const terminalContextsSignatureRef = useRef(terminalContextsSignature);
Expand Down Expand Up @@ -1093,18 +1102,25 @@ function ComposerPromptEditorInner({
contentEditable={
<ContentEditable
className={cn(
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent leading-relaxed text-foreground focus:outline-none",
className,
)}
data-testid="composer-editor"
aria-placeholder={placeholder}
placeholder={<span />}
style={{ fontSize: composerFontSizePx }}
onBlur={handleEditorBlur}
onFocus={handleEditorFocus}
onPaste={onPaste}
onTouchStartCapture={handleEditorTouchStartCapture}
/>
}
placeholder={
terminalContexts.length > 0 ? null : (
<div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">
<div
className="pointer-events-none absolute inset-0 leading-relaxed text-muted-foreground/35"
style={{ fontSize: composerFontSizePx }}
>
{placeholder}
</div>
)
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/hooks/usePreventIosInputZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";

import {
buildInputZoomLockedViewportContent,
isIosInputZoomPlatform,
} from "./usePreventIosInputZoom";

describe("isIosInputZoomPlatform", () => {
it("detects iPhone user agents", () => {
expect(
isIosInputZoomPlatform({
maxTouchPoints: 5,
platform: "iPhone",
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15",
}),
).toBe(true);
});

it("detects iPadOS devices that report as MacIntel", () => {
expect(
isIosInputZoomPlatform({
maxTouchPoints: 5,
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
}),
).toBe(true);
});

it("ignores desktop macOS", () => {
expect(
isIosInputZoomPlatform({
maxTouchPoints: 0,
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
}),
).toBe(false);
});
});

describe("buildInputZoomLockedViewportContent", () => {
it("adds the focus zoom lock directives", () => {
expect(buildInputZoomLockedViewportContent("width=device-width, initial-scale=1.0")).toBe(
"width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no",
);
});

it("replaces existing maximum scale directives", () => {
expect(
buildInputZoomLockedViewportContent(
"width=device-width, initial-scale=1.0, maximum-scale=5, user-scalable=yes",
),
).toBe("width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no");
});
});
126 changes: 126 additions & 0 deletions apps/web/src/hooks/usePreventIosInputZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useRef } from "react";

const DEFAULT_VIEWPORT_CONTENT = "width=device-width, initial-scale=1.0";
const IOS_USER_AGENT_PATTERN = /\b(iPad|iPhone|iPod)\b/i;
const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]';

export function isIosInputZoomPlatform(input: {
maxTouchPoints: number;
platform: string;
userAgent: string;
}): boolean {
return (
IOS_USER_AGENT_PATTERN.test(input.userAgent) ||
(input.platform === "MacIntel" && input.maxTouchPoints > 1)
);
}

export function buildInputZoomLockedViewportContent(content: string): string {
const baseContent = content.trim() || DEFAULT_VIEWPORT_CONTENT;
const segments = baseContent
.split(",")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
const filteredSegments = segments.filter((segment) => {
const [key] = segment.split("=", 1);
const normalizedKey = key?.trim().toLowerCase();
return normalizedKey !== "maximum-scale" && normalizedKey !== "user-scalable";
});

filteredSegments.push("maximum-scale=1", "user-scalable=no");
return filteredSegments.join(", ");
}

export function usePreventIosInputZoom(): {
onBlur: () => void;
onFocus: () => void;
onTouchStartCapture: () => void;
} {
const isEnabled = useMemo(() => {
if (typeof navigator === "undefined") {
return false;
}

return isIosInputZoomPlatform({
maxTouchPoints: navigator.maxTouchPoints,
platform: navigator.platform,
userAgent: navigator.userAgent,
});
}, []);
const focusedRef = useRef(false);
const lockStateRef = useRef<{
isLocked: boolean;
originalContent: string | null;
viewportMeta: HTMLMetaElement | null;
}>({
isLocked: false,
originalContent: null,
viewportMeta: null,
});

const restoreViewport = useCallback(() => {
const lockState = lockStateRef.current;
if (!lockState.isLocked) {
return;
}

if (lockState.viewportMeta && lockState.originalContent != null) {
lockState.viewportMeta.setAttribute("content", lockState.originalContent);
}

lockState.isLocked = false;
lockState.originalContent = null;
lockState.viewportMeta = null;
}, []);

const lockViewport = useCallback(() => {
if (!isEnabled || typeof document === "undefined") {
return;
}

const lockState = lockStateRef.current;
if (lockState.isLocked) {
return;
}

const viewportMeta = document.querySelector<HTMLMetaElement>(VIEWPORT_META_SELECTOR);
if (!viewportMeta) {
return;
}

lockState.isLocked = true;
lockState.originalContent = viewportMeta.getAttribute("content") ?? DEFAULT_VIEWPORT_CONTENT;
lockState.viewportMeta = viewportMeta;
viewportMeta.setAttribute(
"content",
buildInputZoomLockedViewportContent(lockState.originalContent),
);
}, [isEnabled]);

const handleTouchStartCapture = useCallback(() => {
lockViewport();
requestAnimationFrame(() => {
if (!focusedRef.current) {
restoreViewport();
}
});
}, [lockViewport, restoreViewport]);

const handleFocus = useCallback(() => {
focusedRef.current = true;
lockViewport();
}, [lockViewport]);

const handleBlur = useCallback(() => {
focusedRef.current = false;
restoreViewport();
}, [restoreViewport]);

useEffect(() => restoreViewport, [restoreViewport]);

return {
onBlur: handleBlur,
onFocus: handleFocus,
onTouchStartCapture: handleTouchStartCapture,
};
}
Loading