272 lines
7.5 KiB
TypeScript
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,
|
|
}
|
|
}
|