feat(web): global word-wrap toggle for code and diff views (#985)

* refactor(web): add per-line shiki line-splitting helper

* feat(web): add global word-wrap toggle for code, markdown, and diff views

* feat(web): add a shaded gutter background behind line numbers

* fix(web): make DiffView compact rows follow the global wrap setting
This commit is contained in:
Junmo Kim
2026-07-27 19:09:59 +08:00
committed by GitHub
parent d53e25700d
commit e52443f4cf
21 changed files with 1113 additions and 116 deletions
+114
View File
@@ -0,0 +1,114 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { getInitialCodeWrap, useCodeWrap } from '@/hooks/useCodeWrap'
const STORAGE_KEY = 'hapi-code-wrap'
describe('useCodeWrap helpers', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('defaults to off when nothing is stored', () => {
expect(getInitialCodeWrap()).toBe(false)
})
it('reads a stored "1" as on', () => {
window.localStorage.setItem(STORAGE_KEY, '1')
expect(getInitialCodeWrap()).toBe(true)
})
it('treats any non-"1" stored value as off', () => {
window.localStorage.setItem(STORAGE_KEY, 'garbage')
expect(getInitialCodeWrap()).toBe(false)
})
})
describe('useCodeWrap', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('starts off by default', () => {
const { result } = renderHook(() => useCodeWrap())
expect(result.current.codeWrap).toBe(false)
})
it('turning on writes "1" to localStorage and updates state', () => {
const { result } = renderHook(() => useCodeWrap())
act(() => {
result.current.setCodeWrap(true)
})
expect(result.current.codeWrap).toBe(true)
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('1')
})
it('turning off removes the localStorage key and updates state', () => {
window.localStorage.setItem(STORAGE_KEY, '1')
const { result } = renderHook(() => useCodeWrap())
expect(result.current.codeWrap).toBe(true)
act(() => {
result.current.setCodeWrap(false)
})
expect(result.current.codeWrap).toBe(false)
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('syncs across instances via the storage event', () => {
const { result } = renderHook(() => useCodeWrap())
expect(result.current.codeWrap).toBe(false)
act(() => {
window.localStorage.setItem(STORAGE_KEY, '1')
window.dispatchEvent(new StorageEvent('storage', {
key: STORAGE_KEY,
newValue: '1',
}))
})
expect(result.current.codeWrap).toBe(true)
})
it('ignores storage events for unrelated keys', () => {
const { result } = renderHook(() => useCodeWrap())
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: 'hapi-some-other-key',
newValue: '1',
}))
})
expect(result.current.codeWrap).toBe(false)
})
it('syncs across multiple hook instances in the same tab (e.g. two CodeBlocks)', () => {
// Same-tab localStorage writes do NOT fire a `storage` event in the
// same document (the spec only fires it in *other* browsing
// contexts), so this instance must learn about the toggle through
// an in-memory channel, not just the storage listener.
const a = renderHook(() => useCodeWrap())
const b = renderHook(() => useCodeWrap())
act(() => {
a.result.current.setCodeWrap(true)
})
expect(a.result.current.codeWrap).toBe(true)
expect(b.result.current.codeWrap).toBe(true)
act(() => {
b.result.current.setCodeWrap(false)
})
expect(a.result.current.codeWrap).toBe(false)
expect(b.result.current.codeWrap).toBe(false)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from 'react'
function getCodeWrapStorageKey(): string {
return 'hapi-code-wrap'
}
// `storage` events only fire in *other* browsing contexts (tabs/windows),
// never in the document that made the write. Code blocks render many
// independent `useCodeWrap()` instances in the same tab (CodeBlock,
// markdown Pre, DiffView all read the same preference), so a same-tab
// in-memory broadcast is required to keep every instance in sync when one
// of them toggles the value.
const sameTabListeners = new Set<(wrap: boolean) => void>()
function broadcastSameTab(wrap: boolean): void {
for (const listener of sameTabListeners) {
listener(wrap)
}
}
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
function parseCodeWrap(raw: string | null): boolean {
return raw === '1'
}
export function getInitialCodeWrap(): boolean {
return parseCodeWrap(safeGetItem(getCodeWrapStorageKey()))
}
export function useCodeWrap(): {
codeWrap: boolean
setCodeWrap: (wrap: boolean) => void
} {
const [codeWrap, setCodeWrapState] = useState<boolean>(getInitialCodeWrap)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getCodeWrapStorageKey()) {
return
}
setCodeWrapState(parseCodeWrap(event.newValue))
}
sameTabListeners.add(setCodeWrapState)
window.addEventListener('storage', onStorage)
return () => {
sameTabListeners.delete(setCodeWrapState)
window.removeEventListener('storage', onStorage)
}
}, [])
const setCodeWrap = useCallback((wrap: boolean) => {
// Broadcast only: every mounted instance (including this one, which
// registered its own `setCodeWrapState` as a listener on mount)
// updates through the same path, so the initiating instance is not
// double-set. Toggle buttons fire from onClick handlers, which React
// guarantees run after the mount effect, so this instance's listener
// is always registered by the time this runs.
broadcastSameTab(wrap)
if (wrap) {
safeSetItem(getCodeWrapStorageKey(), '1')
} else {
safeRemoveItem(getCodeWrapStorageKey())
}
}, [])
return { codeWrap, setCodeWrap }
}