Просмотр исходного кода

fix(writing): 优先用 field-sizing 避免文章编辑框末尾截断

支持 CSS field-sizing: content 时改由浏览器自适应高度;
不支持时降级为现有 scrollHeight 测量逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 месяцев назад
Родитель
Сommit
282c1d9269

+ 81 - 1
src/components/editor/wiki-editor.immersive.spec.tsx

@@ -4,7 +4,7 @@ import { act, useState } from "react"
 import { createRoot } from "react-dom/client"
 import { describe, expect, it } from "vitest"
 import { formatChapterWriting } from "@/lib/chapter-formatting"
-import { WikiEditor } from "./wiki-editor"
+import { supportsFieldSizingContent, WikiEditor } from "./wiki-editor"
 
 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
 
@@ -44,6 +44,86 @@ describe("WikiEditor immersive writing", () => {
     document.body.removeChild(container)
   })
 
+  it("prefers CSS field-sizing when supported, otherwise keeps JS height sizing", () => {
+    const originalSupports = globalThis.CSS?.supports
+    const cssStub = {
+      supports: (property: string, value?: string) =>
+        property === "field-sizing" && value === "content",
+    }
+    Object.defineProperty(globalThis, "CSS", {
+      configurable: true,
+      writable: true,
+      value: cssStub,
+    })
+    expect(supportsFieldSizingContent()).toBe(true)
+
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+
+    act(() => {
+      root.render(
+        <WikiEditor
+          content={"# 第1章\n\n这是一段正文。"}
+          onSave={() => {}}
+          immersiveWriting
+        />,
+      )
+    })
+
+    const textarea = container.querySelector("textarea")
+    expect(textarea).not.toBeNull()
+    expect(textarea?.style.fieldSizing).toBe("content")
+
+    act(() => root.unmount())
+    document.body.removeChild(container)
+
+    Object.defineProperty(globalThis, "CSS", {
+      configurable: true,
+      writable: true,
+      value: {
+        supports: () => false,
+      },
+    })
+    expect(supportsFieldSizingContent()).toBe(false)
+
+    const fallbackContainer = document.createElement("div")
+    document.body.appendChild(fallbackContainer)
+    const fallbackRoot = createRoot(fallbackContainer)
+
+    act(() => {
+      fallbackRoot.render(
+        <WikiEditor
+          content={"# 第1章\n\n这是一段正文。"}
+          onSave={() => {}}
+          immersiveWriting
+        />,
+      )
+    })
+
+    const fallbackTextarea = fallbackContainer.querySelector("textarea")
+    expect(fallbackTextarea).not.toBeNull()
+    expect(fallbackTextarea?.style.fieldSizing).toBe("")
+
+    act(() => fallbackRoot.unmount())
+    document.body.removeChild(fallbackContainer)
+
+    if (originalSupports) {
+      Object.defineProperty(globalThis, "CSS", {
+        configurable: true,
+        writable: true,
+        value: { supports: originalSupports },
+      })
+    } else {
+      // jsdom may not define CSS.supports
+      Object.defineProperty(globalThis, "CSS", {
+        configurable: true,
+        writable: true,
+        value: undefined,
+      })
+    }
+  })
+
   it("keeps typing on the inserted line after the parent normalizes chapter content", async () => {
     function ControlledEditor() {
       const [content, setContent] = useState("# 第4章\n\n这个是怎么回事呢?")

+ 17 - 3
src/components/editor/wiki-editor.tsx

@@ -44,6 +44,15 @@ interface WikiEditorInnerProps {
   onSave: (markdown: string) => void;
 }
 
+/** Prefer CSS `field-sizing: content` when available; otherwise JS scrollHeight. */
+export function supportsFieldSizingContent(): boolean {
+  return (
+    typeof CSS !== "undefined" &&
+    typeof CSS.supports === "function" &&
+    CSS.supports("field-sizing", "content")
+  );
+}
+
 interface WritingTextareaProps {
   content: string;
   onSave: (markdown: string) => void;
@@ -95,6 +104,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
       () => findAllMatches(value, findQuery, { caseSensitive: false }),
       [value, findQuery],
     );
+    const cssAutoHeight = supportsFieldSizingContent();
 
     useImperativeHandle(
       ref,
@@ -152,6 +162,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
 
     const resize = useMemo(
       () => () => {
+        if (cssAutoHeight) return;
         const el = textareaRef.current;
         if (!el) return;
         // 找到外层滚动容器,保存滚动位置防止跳动
@@ -177,14 +188,16 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
           scrollContainer.scrollLeft = savedScrollLeft;
         }
       },
-      [],
+      [cssAutoHeight],
     );
 
     useEffect(() => {
+      if (cssAutoHeight) return;
       resize();
-    }, [value, resize]);
+    }, [value, resize, cssAutoHeight]);
 
     useEffect(() => {
+      if (cssAutoHeight) return;
       const el = textareaRef.current;
       if (!el) return;
       const resizeTarget = el.parentElement ?? el;
@@ -213,7 +226,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
         if (frame !== null) cancelAnimationFrame(frame);
         observer.disconnect();
       };
-    }, [resize]);
+    }, [resize, cssAutoHeight]);
 
     const refreshSelection = useCallback(() => {
       const el = textareaRef.current;
@@ -520,6 +533,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
               fontFamily: "inherit",
               minHeight: "100%",
               height: "auto",
+              ...(cssAutoHeight ? { fieldSizing: "content" as const } : {}),
             }}
             spellCheck={false}
           />