mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user