sozsoft-platform/ui/src/views/public/designer/useDesignerState.ts
2026-08-10 16:53:24 +03:00

272 lines
7.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { normalizeLanguageKey } from './localization'
import { setByPath } from './paths'
const DESIGNER_QUERY_KEY = 'design'
const HISTORY_LIMIT = 50
interface UseDesignerStateOptions {
currentLanguage?: string
supportedLanguages?: string[]
/** Tasarim modunun acilabilmesi icin yetki kontrolu. Varsayilan: acik. */
enabled?: boolean
}
interface LanguageEntry<T> {
current: T
/** Sunucudan gelen son icerigin serilestirilmis hali; "kirli mi" karsilastirmasi icin. */
baseline: string
past: T[]
future: T[]
}
function serialize(value: unknown) {
try {
return JSON.stringify(value) ?? '__undefined__'
} catch {
return '__unserializable__'
}
}
function clone<T>(value: T): T {
try {
return JSON.parse(JSON.stringify(value)) as T
} catch {
return value
}
}
function createEntry<T>(content: T): LanguageEntry<T> {
return { current: clone(content), baseline: serialize(content), past: [], future: [] }
}
export function useDesignerState<T>(
pageKey: string,
initialContent: T | null,
options?: UseDesignerStateOptions,
) {
const location = useLocation()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const isDesignerRequested =
searchParams.get(DESIGNER_QUERY_KEY) === '1' || location.pathname.endsWith('/designer')
const isDesignMode = isDesignerRequested && options?.enabled !== false
const currentLanguage = normalizeLanguageKey(options?.currentLanguage)
const supportedLanguages = useMemo(() => {
const base = (options?.supportedLanguages ?? [currentLanguage]).map(normalizeLanguageKey)
return Array.from(new Set(base.includes(currentLanguage) ? base : [currentLanguage, ...base]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLanguage, serialize(options?.supportedLanguages)])
const [entries, setEntries] = useState<Record<string, LanguageEntry<T>>>({})
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
const entriesRef = useRef(entries)
entriesRef.current = entries
const serializedInitial = serialize(initialContent)
// Sunucu icerigi degistiginde (ilk yukleme veya dil degisimi) o dilin taslagini tohumla.
// Kullanici o dilde duzenleme yaptiysa taslak korunur, uzerine yazilmaz.
useEffect(() => {
if (!initialContent) {
return
}
setEntries((previous) => {
const entry = previous[currentLanguage]
if (entry && serialize(entry.current) !== entry.baseline) {
return previous
}
if (entry && entry.baseline === serializedInitial) {
return previous
}
return { ...previous, [currentLanguage]: createEntry(initialContent) }
})
// initialContent yerine serilestirilmis hali izlenir: her render'da yeni referans gelse bile
// efekt yalnizca icerik gercekten degistiginde calisir.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLanguage, serializedInitial])
useEffect(() => {
if (!isDesignMode) {
setSelectedBlockId(null)
}
}, [isDesignMode])
const entry = entries[currentLanguage]
const content = entry?.current ?? initialContent ?? null
const isDirty = Boolean(entry) && serialize(entry.current) !== entry.baseline
const mutate = useCallback(
(updater: (current: T) => T | null) => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing) {
return previous
}
const next = updater(existing.current)
if (!next || serialize(next) === serialize(existing.current)) {
return previous
}
return {
...previous,
[currentLanguage]: {
...existing,
current: next,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future: [],
},
}
})
},
[currentLanguage],
)
/** Nokta yolu ile tek bir alani gunceller. */
const setFieldValue = useCallback(
(path: string, value: unknown) => {
mutate((current) => setByPath(current, path, value))
},
[mutate],
)
const undo = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing?.past.length) {
return previous
}
const past = [...existing.past]
const restored = past.pop() as T
return {
...previous,
[currentLanguage]: {
...existing,
current: restored,
past,
future: [existing.current, ...existing.future].slice(0, HISTORY_LIMIT),
},
}
})
}, [currentLanguage])
const redo = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing?.future.length) {
return previous
}
const [restored, ...future] = existing.future
return {
...previous,
[currentLanguage]: {
...existing,
current: restored,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future,
},
}
})
}, [currentLanguage])
/** Kaydedilmemis degisiklikleri atip sunucudan gelen son icerige doner. */
const resetContent = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing) {
return previous
}
return {
...previous,
[currentLanguage]: {
...existing,
current: JSON.parse(existing.baseline) as T,
past: [...existing.past, existing.current].slice(-HISTORY_LIMIT),
future: [],
},
}
})
}, [currentLanguage])
/** Kaydetme basarili olduktan sonra mevcut icerigi yeni referans nokta yapar. */
const commitContent = useCallback(() => {
setEntries((previous) => {
const existing = previous[currentLanguage]
if (!existing) {
return previous
}
return {
...previous,
[currentLanguage]: { ...existing, baseline: serialize(existing.current) },
}
})
}, [currentLanguage])
const exitDesignMode = useCallback(() => {
// Tasarim modu iki sekilde acilabiliyor: `?design=1` veya `/<sayfa>/designer` yolu.
if (location.pathname.endsWith('/designer')) {
navigate(location.pathname.replace(/\/designer$/, '') || '/', { replace: true })
return
}
const next = new URLSearchParams(searchParams)
next.delete(DESIGNER_QUERY_KEY)
setSearchParams(next, { replace: true })
}, [location.pathname, navigate, searchParams, setSearchParams])
// Tasarim modunda sekme kapatilirken kaydedilmemis degisiklikler icin uyari.
useEffect(() => {
if (!isDesignMode || !isDirty) {
return
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDesignMode, isDirty])
return {
pageKey,
content,
isDesignMode,
isDirty,
canUndo: Boolean(entry?.past.length),
canRedo: Boolean(entry?.future.length),
selectedBlockId,
selectedLanguage: currentLanguage,
supportedLanguages,
setContent: mutate,
setFieldValue,
setSelectedBlockId,
resetContent,
commitContent,
exitDesignMode,
undo,
redo,
}
}