Explorar el Código

feat(editor): 正文编辑页支持 Cmd+F 查找

为章节沉浸式 WritingTextarea 添加查找条、overlay 高亮与上下条导航
支持不区分大小写匹配,并修正外层 scroll container 滚动定位

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hace 2 meses
padre
commit
7196df1289

+ 107 - 0
src/components/editor/textarea-find-bar.tsx

@@ -0,0 +1,107 @@
+import { useEffect, useRef } from "react"
+import { ChevronDown, ChevronUp, X } from "lucide-react"
+import { isImeComposing } from "@/lib/keyboard-utils"
+
+export interface TextareaFindBarProps {
+  open: boolean
+  query: string
+  activeMatchIndex: number
+  matchCount: number
+  onQueryChange: (query: string) => void
+  onNext: () => void
+  onPrevious: () => void
+  onClose: () => void
+}
+
+export function TextareaFindBar({
+  open,
+  query,
+  activeMatchIndex,
+  matchCount,
+  onQueryChange,
+  onNext,
+  onPrevious,
+  onClose,
+}: TextareaFindBarProps) {
+  const inputRef = useRef<HTMLInputElement | null>(null)
+
+  useEffect(() => {
+    if (!open) return
+    requestAnimationFrame(() => {
+      inputRef.current?.focus()
+      inputRef.current?.select()
+    })
+  }, [open])
+
+  if (!open) return null
+
+  const statusLabel = query
+    ? matchCount > 0
+      ? `${activeMatchIndex + 1}/${matchCount}`
+      : "未找到"
+    : ""
+
+  return (
+    <div
+      data-find-bar="true"
+      className="sticky top-2 z-40 ml-auto flex w-fit items-center gap-1 rounded-md border border-border/80 bg-background/95 px-2 py-1 shadow-lg backdrop-blur"
+    >
+      <input
+        ref={inputRef}
+        type="text"
+        value={query}
+        onChange={(e) => onQueryChange(e.target.value)}
+        onKeyDown={(e) => {
+          if (isImeComposing(e)) return
+          if (e.key === "Enter") {
+            e.preventDefault()
+            if (e.shiftKey) {
+              onPrevious()
+            } else {
+              onNext()
+            }
+            return
+          }
+          if (e.key === "Escape") {
+            e.preventDefault()
+            onClose()
+          }
+        }}
+        placeholder="查找"
+        aria-label="查找正文"
+        className="h-7 w-44 rounded border border-input bg-background px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
+      />
+      <span className="min-w-10 text-center text-xs text-muted-foreground">{statusLabel}</span>
+      <button
+        type="button"
+        aria-label="上一条"
+        title="上一条"
+        className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+        onMouseDown={(e) => e.preventDefault()}
+        onClick={onPrevious}
+      >
+        <ChevronUp className="h-3.5 w-3.5" />
+      </button>
+      <button
+        type="button"
+        aria-label="下一条"
+        title="下一条"
+        onMouseDown={(e) => e.preventDefault()}
+        onClick={onNext}
+        className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+      >
+        <ChevronDown className="h-3.5 w-3.5" />
+      </button>
+      <button
+        type="button"
+        aria-label="关闭查找"
+        title="关闭"
+        className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+        onMouseDown={(e) => e.preventDefault()}
+        onClick={onClose}
+      >
+        <X className="h-3.5 w-3.5" />
+      </button>
+    </div>
+  )
+}

+ 61 - 0
src/components/editor/textarea-find-highlights.tsx

@@ -0,0 +1,61 @@
+import { useMemo } from "react"
+import {
+  buildFindHighlightParts,
+  type FindHighlightPart,
+} from "@/lib/textarea-find"
+
+export interface TextareaFindHighlightsProps {
+  text: string
+  query: string
+  matches: number[]
+  activeMatchIndex: number
+}
+
+function renderPart(part: FindHighlightPart, index: number) {
+  if (part.kind === "plain") {
+    return <span key={index}>{part.text}</span>
+  }
+  if (part.kind === "active") {
+    return (
+      <mark
+        key={index}
+        data-find-highlight-active="true"
+        className="rounded-[2px] bg-amber-400/55 text-transparent ring-1 ring-amber-500/80 dark:bg-amber-300/45 dark:ring-amber-300/70"
+      >
+        {part.text}
+      </mark>
+    )
+  }
+  return (
+    <mark
+      key={index}
+      data-find-highlight-match="true"
+      className="rounded-[2px] bg-amber-300/30 text-transparent dark:bg-amber-200/20"
+    >
+      {part.text}
+    </mark>
+  )
+}
+
+export function TextareaFindHighlights({
+  text,
+  query,
+  matches,
+  activeMatchIndex,
+}: TextareaFindHighlightsProps) {
+  const parts = useMemo(
+    () => buildFindHighlightParts(text, matches, query.length, activeMatchIndex),
+    [text, matches, query.length, activeMatchIndex],
+  )
+
+  return (
+    <div
+      aria-hidden="true"
+      data-find-highlights="true"
+      className="pointer-events-none absolute inset-x-0 top-0 z-0 w-full whitespace-pre-wrap break-words border-0 p-0 text-lg leading-8 text-transparent"
+      style={{ fontFamily: "inherit" }}
+    >
+      {parts.map(renderPart)}
+    </div>
+  )
+}

+ 51 - 0
src/components/editor/wiki-editor.immersive.spec.tsx

@@ -94,4 +94,55 @@ describe("WikiEditor immersive writing", () => {
     act(() => root.unmount())
     document.body.removeChild(container)
   })
+
+  it("opens find bar on Cmd+F and selects matching text", async () => {
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+
+    await act(async () => {
+      root.render(
+        <WikiEditor
+          content={"# 第1章\n\n这是一段正文,正文里有重复正文。"}
+          onSave={() => {}}
+          immersiveWriting
+        />,
+      )
+      await nextFrame()
+    })
+
+    const textarea = container.querySelector("textarea")
+    expect(textarea).not.toBeNull()
+    if (!textarea) throw new Error("textarea not found")
+
+    textarea.focus()
+
+    await act(async () => {
+      window.dispatchEvent(new KeyboardEvent("keydown", {
+        key: "f",
+        metaKey: true,
+        bubbles: true,
+        cancelable: true,
+      }))
+      await nextFrame()
+    })
+
+    const findInput = container.querySelector("[data-find-bar='true'] input")
+    expect(findInput).not.toBeNull()
+
+    await act(async () => {
+      const input = findInput as HTMLInputElement
+      const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
+      setter?.call(input, "正文")
+      input.dispatchEvent(new Event("input", { bubbles: true }))
+      input.dispatchEvent(new Event("change", { bubbles: true }))
+      await nextFrame()
+      await nextFrame()
+    })
+
+    expect(container.querySelector("[data-find-highlight-active='true']")?.textContent).toBe("正文")
+
+    act(() => root.unmount())
+    document.body.removeChild(container)
+  })
 })

+ 148 - 8
src/components/editor/wiki-editor.tsx

@@ -21,6 +21,15 @@ import {
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
 import type { PendingEditorHighlight } from "@/stores/wiki-store"
+import { TextareaFindBar } from "@/components/editor/textarea-find-bar"
+import { TextareaFindHighlights } from "@/components/editor/textarea-find-highlights"
+import {
+  findAllMatches,
+  findInitialMatchIndex,
+  findNextMatchIndex,
+  findPrevMatchIndex,
+  scrollTextareaMatchIntoView,
+} from "@/lib/textarea-find"
 
 interface WikiEditorInnerProps {
   content: string
@@ -56,10 +65,18 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
   const initial = useMemo(() => splitChapterHeading(content), [content])
   const [heading, setHeading] = useState(initial.heading)
   const [value, setValue] = useState(initial.body)
+  const editorRootRef = useRef<HTMLDivElement | null>(null)
   const textareaRef = useRef<HTMLTextAreaElement | null>(null)
   const previousBodyRef = useRef(initial.body)
   const [selection, setSelection] = useState<ChapterBodySelection | null>(null)
   const [toolbarPosition, setToolbarPosition] = useState<FloatingToolbarPosition | null>(null)
+  const [findOpen, setFindOpen] = useState(false)
+  const [findQuery, setFindQuery] = useState("")
+  const [activeMatchIndex, setActiveMatchIndex] = useState(-1)
+  const findMatches = useMemo(
+    () => findAllMatches(value, findQuery, { caseSensitive: false }),
+    [value, findQuery],
+  )
 
   useImperativeHandle(ref, () => ({
     getLiveBodyMarkdown: () => {
@@ -215,9 +232,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
     requestAnimationFrame(() => {
       textarea.focus()
       textarea.setSelectionRange(start, end)
-      const lineHeight = Number.parseFloat(window.getComputedStyle(textarea).lineHeight || "32") || 32
-      const scrollTop = Math.max(0, value.slice(0, start).split("\n").length * lineHeight - textarea.clientHeight / 3)
-      textarea.scrollTop = scrollTop
+      scrollTextareaMatchIntoView(textarea, start, end)
       refreshSelection()
       onHighlightHandled?.()
     })
@@ -240,8 +255,122 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
     setToolbarPosition(null)
   }, [selection, onSelectionAction])
 
+  const applyFindMatch = useCallback((matchIndex: number) => {
+    const textarea = textareaRef.current
+    if (!textarea || matchIndex < 0 || matchIndex >= findMatches.length || !findQuery) {
+      setActiveMatchIndex(-1)
+      return
+    }
+    const start = findMatches[matchIndex]
+    const end = start + findQuery.length
+    setActiveMatchIndex(matchIndex)
+    requestAnimationFrame(() => {
+      scrollTextareaMatchIntoView(textarea, start, end)
+      if (findOpen) {
+        textarea.setSelectionRange(start, start)
+        const findInput = editorRootRef.current?.querySelector<HTMLInputElement>("[data-find-bar='true'] input")
+        findInput?.focus()
+        return
+      }
+      textarea.setSelectionRange(start, end)
+      textarea.focus()
+    })
+  }, [findMatches, findQuery, findOpen])
+
+  const openFindBar = useCallback((initialQuery = "") => {
+    const textarea = textareaRef.current
+    let query = initialQuery
+    if (!query && textarea) {
+      const start = textarea.selectionStart
+      const end = textarea.selectionEnd
+      if (start !== end) {
+        query = value.slice(start, end)
+      }
+    }
+    setFindOpen(true)
+    setFindQuery(query)
+  }, [value])
+
+  const closeFindBar = useCallback(() => {
+    setFindOpen(false)
+    requestAnimationFrame(() => {
+      textareaRef.current?.focus()
+    })
+  }, [])
+
+  const goToNextMatch = useCallback(() => {
+    if (!findQuery || findMatches.length === 0) {
+      setActiveMatchIndex(-1)
+      return
+    }
+    const textarea = textareaRef.current
+    const selectionStart = textarea?.selectionStart ?? 0
+    const nextIndex = findNextMatchIndex(findMatches, selectionStart, findQuery.length)
+    applyFindMatch(nextIndex)
+  }, [applyFindMatch, findMatches, findQuery])
+
+  const goToPreviousMatch = useCallback(() => {
+    if (!findQuery || findMatches.length === 0) {
+      setActiveMatchIndex(-1)
+      return
+    }
+    const textarea = textareaRef.current
+    const selectionStart = textarea?.selectionStart ?? 0
+    const prevIndex = findPrevMatchIndex(findMatches, selectionStart)
+    applyFindMatch(prevIndex)
+  }, [applyFindMatch, findMatches, findQuery])
+
+  useEffect(() => {
+    if (!findOpen) return
+    if (!findQuery) {
+      setActiveMatchIndex(-1)
+      return
+    }
+    const textarea = textareaRef.current
+    const cursor = textarea?.selectionStart ?? 0
+    const matchIndex = findInitialMatchIndex(findMatches, cursor)
+    setActiveMatchIndex(matchIndex)
+    if (matchIndex >= 0) {
+      applyFindMatch(matchIndex)
+    }
+  }, [findOpen, findQuery, findMatches, value, applyFindMatch])
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "f") return
+      const root = editorRootRef.current
+      const active = document.activeElement
+      if (!root) return
+      const inEditor = active instanceof Node && root.contains(active)
+      const findBarActive = active instanceof HTMLElement && active.closest("[data-find-bar='true']")
+      if (!inEditor && !findBarActive && !findOpen) return
+      event.preventDefault()
+      if (findOpen) {
+        requestAnimationFrame(() => {
+          const input = editorRootRef.current?.querySelector<HTMLInputElement>("[data-find-bar='true'] input")
+          input?.focus()
+          input?.select()
+        })
+        return
+      }
+      openFindBar("")
+    }
+    window.addEventListener("keydown", handleKeyDown)
+    return () => window.removeEventListener("keydown", handleKeyDown)
+  }, [findOpen, findQuery, openFindBar])
+
   return (
-    <div className="relative flex w-full flex-col">
+    <div ref={editorRootRef} data-writing-editor="true" className="relative flex w-full flex-col">
+      <TextareaFindBar
+        open={findOpen}
+        query={findQuery}
+        activeMatchIndex={activeMatchIndex}
+        matchCount={findMatches.length}
+        onQueryChange={setFindQuery}
+        onNext={goToNextMatch}
+        onPrevious={goToPreviousMatch}
+        onClose={closeFindBar}
+      />
       {selection && toolbarPosition && onSelectionAction ? (
         <div
           data-selection-toolbar="true"
@@ -266,7 +395,16 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
           </button>
         </div>
       ) : null}
-      <textarea
+      <div className="relative w-full">
+        {findOpen && findQuery ? (
+          <TextareaFindHighlights
+            text={value}
+            query={findQuery}
+            matches={findMatches}
+            activeMatchIndex={activeMatchIndex}
+          />
+        ) : null}
+        <textarea
         ref={textareaRef}
         value={value}
         onChange={(e) => {
@@ -282,8 +420,9 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
         onBlur={() => {
           window.setTimeout(() => {
             const active = document.activeElement
-            if (active instanceof HTMLElement && active.closest("[data-selection-toolbar='true']")) {
-              return
+            if (active instanceof HTMLElement) {
+              if (active.closest("[data-selection-toolbar='true']")) return
+              if (active.closest("[data-find-bar='true']")) return
             }
             setSelection(null)
             setToolbarPosition(null)
@@ -305,7 +444,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
             resize()
           })
         }}
-        className="w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-lg leading-8 text-foreground outline-none"
+        className="relative z-1 w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-lg leading-8 text-foreground outline-none"
         style={{ 
           fontFamily: "inherit",
           minHeight: "100%",
@@ -313,6 +452,7 @@ const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(
         }}
         spellCheck={false}
       />
+      </div>
     </div>
   )
 })

+ 60 - 0
src/lib/textarea-find.spec.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest"
+import {
+  findAllMatches,
+  findInitialMatchIndex,
+  findNextMatchIndex,
+  findPrevMatchIndex,
+  buildFindHighlightParts,
+} from "./textarea-find"
+
+describe("textarea-find", () => {
+  it("collects all matches in order", () => {
+    expect(findAllMatches("abcabc", "abc")).toEqual([0, 3])
+    expect(findAllMatches("Hello hello", "hello", { caseSensitive: false })).toEqual([0, 6])
+    expect(findAllMatches("Hello hello", "HELLO", { caseSensitive: false })).toEqual([0, 6])
+  })
+
+  it("returns empty matches for empty query", () => {
+    expect(findAllMatches("abc", "")).toEqual([])
+  })
+
+  it("finds initial match at or after cursor", () => {
+    const matches = findAllMatches("abcabc", "abc")
+    expect(findInitialMatchIndex(matches, 0)).toBe(0)
+    expect(findInitialMatchIndex(matches, 1)).toBe(1)
+    expect(findInitialMatchIndex(matches, 4)).toBe(0)
+  })
+
+  it("wraps initial match to first result when cursor is after last match", () => {
+    const matches = findAllMatches("abcabc", "abc")
+    expect(findInitialMatchIndex(matches, 6)).toBe(0)
+  })
+
+  it("finds next and previous matches with wrap", () => {
+    const matches = findAllMatches("abcabc", "abc")
+    expect(findNextMatchIndex(matches, 0, 3)).toBe(1)
+    expect(findNextMatchIndex(matches, 3, 3)).toBe(0)
+    expect(findPrevMatchIndex(matches, 3)).toBe(0)
+    expect(findPrevMatchIndex(matches, 0)).toBe(1)
+  })
+
+  it("returns -1 for next match when wrap is disabled and already at last", () => {
+    const matches = findAllMatches("abcabc", "abc")
+    expect(findNextMatchIndex(matches, 3, 3, false)).toBe(-1)
+  })
+
+  it("builds highlight parts with one active match", () => {
+    const text = "这是一段正文,正文里有重复正文。"
+    const matches = findAllMatches(text, "正文")
+    const parts = buildFindHighlightParts(text, matches, "正文".length, 1)
+    expect(parts).toEqual([
+      { text: "这是一段", kind: "plain" },
+      { text: "正文", kind: "match" },
+      { text: ",", kind: "plain" },
+      { text: "正文", kind: "active" },
+      { text: "里有重复", kind: "plain" },
+      { text: "正文", kind: "match" },
+      { text: "。", kind: "plain" },
+    ])
+  })
+})

+ 190 - 0
src/lib/textarea-find.ts

@@ -0,0 +1,190 @@
+export interface TextareaFindOptions {
+  caseSensitive?: boolean
+}
+
+export function findAllMatches(
+  text: string,
+  query: string,
+  options: TextareaFindOptions = {},
+): number[] {
+  if (!query) return []
+  const caseSensitive = options.caseSensitive ?? true
+  const haystack = caseSensitive ? text : text.toLowerCase()
+  const needle = caseSensitive ? query : query.toLowerCase()
+  const matches: number[] = []
+  let from = 0
+  while (from <= haystack.length - needle.length) {
+    const index = haystack.indexOf(needle, from)
+    if (index < 0) break
+    matches.push(index)
+    from = index + Math.max(needle.length, 1)
+  }
+  return matches
+}
+
+export function findNextMatchIndex(
+  matches: number[],
+  selectionStart: number,
+  queryLength: number,
+  wrap = true,
+): number {
+  if (matches.length === 0) return -1
+  const afterCurrent = selectionStart + queryLength
+  const idx = matches.findIndex((start) => start >= afterCurrent)
+  if (idx >= 0) return idx
+  return wrap ? 0 : -1
+}
+
+export function findPrevMatchIndex(
+  matches: number[],
+  selectionStart: number,
+  wrap = true,
+): number {
+  if (matches.length === 0) return -1
+  for (let i = matches.length - 1; i >= 0; i--) {
+    if (matches[i] < selectionStart) return i
+  }
+  return wrap ? matches.length - 1 : -1
+}
+
+export function findInitialMatchIndex(
+  matches: number[],
+  cursor: number,
+): number {
+  if (matches.length === 0) return -1
+  const idx = matches.findIndex((start) => start >= cursor)
+  return idx >= 0 ? idx : 0
+}
+
+export type FindHighlightPartKind = "plain" | "match" | "active"
+
+export interface FindHighlightPart {
+  text: string
+  kind: FindHighlightPartKind
+}
+
+export function buildFindHighlightParts(
+  text: string,
+  matches: number[],
+  queryLength: number,
+  activeMatchIndex: number,
+): FindHighlightPart[] {
+  if (matches.length === 0 || queryLength <= 0) {
+    return text ? [{ text, kind: "plain" }] : []
+  }
+
+  const parts: FindHighlightPart[] = []
+  let last = 0
+  for (let i = 0; i < matches.length; i++) {
+    const start = matches[i]
+    if (start > last) {
+      parts.push({ text: text.slice(last, start), kind: "plain" })
+    }
+    parts.push({
+      text: text.slice(start, start + queryLength),
+      kind: i === activeMatchIndex ? "active" : "match",
+    })
+    last = start + queryLength
+  }
+  if (last < text.length) {
+    parts.push({ text: text.slice(last), kind: "plain" })
+  }
+  return parts
+}
+
+export function findScrollContainer(element: HTMLElement): HTMLElement | null {
+  let parent: HTMLElement | null = element.parentElement
+  while (parent) {
+    const overflowY = window.getComputedStyle(parent).overflowY
+    if (overflowY === "auto" || overflowY === "scroll") {
+      return parent
+    }
+    parent = parent.parentElement
+  }
+  return null
+}
+
+const MIRRORED_TEXTAREA_STYLES = [
+  "boxSizing",
+  "width",
+  "height",
+  "overflowX",
+  "overflowY",
+  "borderTopWidth",
+  "borderRightWidth",
+  "borderBottomWidth",
+  "borderLeftWidth",
+  "paddingTop",
+  "paddingRight",
+  "paddingBottom",
+  "paddingLeft",
+  "fontStyle",
+  "fontVariant",
+  "fontWeight",
+  "fontStretch",
+  "fontSize",
+  "fontFamily",
+  "lineHeight",
+  "letterSpacing",
+  "textTransform",
+  "textIndent",
+  "whiteSpace",
+  "wordSpacing",
+  "wordBreak",
+] as const
+
+function measureMatchOffsetInTextarea(
+  textarea: HTMLTextAreaElement,
+  start: number,
+  end: number,
+): number {
+  const style = window.getComputedStyle(textarea)
+  const mirror = document.createElement("div")
+  const marker = document.createElement("span")
+
+  mirror.style.position = "absolute"
+  mirror.style.visibility = "hidden"
+  mirror.style.top = "0"
+  mirror.style.left = "-9999px"
+  mirror.style.whiteSpace = "pre-wrap"
+  mirror.style.wordWrap = "break-word"
+  mirror.style.overflowWrap = "break-word"
+
+  for (const key of MIRRORED_TEXTAREA_STYLES) {
+    mirror.style[key] = style[key]
+  }
+
+  mirror.textContent = textarea.value.slice(0, start)
+  marker.textContent = textarea.value.slice(start, end) || "\u200b"
+  mirror.appendChild(marker)
+  document.body.appendChild(mirror)
+
+  const mirrorRect = mirror.getBoundingClientRect()
+  const markerRect = marker.getBoundingClientRect()
+  const offsetTop = markerRect.top - mirrorRect.top
+
+  document.body.removeChild(mirror)
+  return offsetTop
+}
+
+export function scrollTextareaMatchIntoView(
+  textarea: HTMLTextAreaElement,
+  start: number,
+  end: number,
+): void {
+  const scrollContainer = findScrollContainer(textarea)
+  const matchTopInTextarea = measureMatchOffsetInTextarea(textarea, start, end)
+
+  if (scrollContainer) {
+    const containerRect = scrollContainer.getBoundingClientRect()
+    const textareaRect = textarea.getBoundingClientRect()
+    const textareaOffsetInContainer = textareaRect.top - containerRect.top + scrollContainer.scrollTop
+    const targetScrollTop = textareaOffsetInContainer + matchTopInTextarea - scrollContainer.clientHeight / 3
+    scrollContainer.scrollTop = Math.max(0, targetScrollTop)
+    return
+  }
+
+  const lineHeight = Number.parseFloat(window.getComputedStyle(textarea).lineHeight || "32") || 32
+  const lineCount = textarea.value.slice(0, start).split("\n").length
+  textarea.scrollTop = Math.max(0, lineCount * lineHeight - textarea.clientHeight / 3)
+}