Organize composer into its own module
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./height-mirror.native";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.visibility = "hidden";
|
||||
style.pointerEvents = "none";
|
||||
style.overflow = "hidden";
|
||||
style.border = "0";
|
||||
style.margin = "0";
|
||||
style.resize = "none";
|
||||
style.zIndex = "-1";
|
||||
style.boxSizing = "border-box";
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const {
|
||||
value: currentValue,
|
||||
minHeight: currentMinHeight,
|
||||
maxHeight: currentMaxHeight,
|
||||
onHeight: currentOnHeight,
|
||||
} = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = currentValue.endsWith("\n") ? `${currentValue} ` : currentValue;
|
||||
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
}, [textareaRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
let previousWidth = source.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure();
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeCanStartDictation } from "./state";
|
||||
|
||||
const connected = { isConnected: true } as never;
|
||||
const disconnected = { isConnected: false } as never;
|
||||
|
||||
describe("computeCanStartDictation", () => {
|
||||
it("returns false when socket is disconnected", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: disconnected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when isReadyForDictation is explicitly false", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: false,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when connected and ready", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: undefined,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: disconnected,
|
||||
isReadyForDictation: undefined,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the input is disabled", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: true,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when a dictation unavailable message is present", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: connected,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: "Microphone unavailable",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when client is null", () => {
|
||||
expect(
|
||||
computeCanStartDictation({
|
||||
client: null,
|
||||
isReadyForDictation: true,
|
||||
disabled: false,
|
||||
dictationUnavailableMessage: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
|
||||
export function computeCanStartDictation(input: {
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
disabled: boolean;
|
||||
dictationUnavailableMessage: string | null | undefined;
|
||||
}): boolean {
|
||||
const socketConnected = input.client?.isConnected ?? false;
|
||||
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
||||
return (
|
||||
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user