import { computed, markRaw, reactive, ref, shallowRef, watch } from 'vue' import { defineStore } from 'pinia' import type { ColorGroup, MosaicRegion, MosaicSettings, ProjectFile, ToastMessage, } from '@/types/mosaic' import { floodFillRegion, getFloodFillReferenceColor } from '@/utils/floodFill' import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups' import { colorDistance, rgbToLab } from '@/utils/colors' import { buildOccupancy, createRegionMask, maskContains, subtractRegionMask, summarizeRegionMask, } from '@/utils/regionMask' import { createProjectFile, isProjectFile } from '@/utils/projectExport' import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage' interface HistorySnapshot { regions: MosaicRegion[] groups: ColorGroup[] } const DEFAULT_SETTINGS: MosaicSettings = { floodTolerance: 34, colorTolerance: 12, darkBoundaryThreshold: 0.045, connectivity: 4, exportOverlay: false, otherGroupsMode: 'dim', useUserClickPositionForLabels: false, } const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({ ...region, color: { ...region.color }, labColor: { ...region.labColor }, boundingBox: { ...region.boundingBox }, labelPosition: { ...region.labelPosition }, userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined, // Masks are immutable; sharing their typed arrays keeps the 30-step history compact. mask: region.mask, })) const cloneGroups = (groups: ColorGroup[]): ColorGroup[] => groups.map((group) => ({ ...group, color: { ...group.color }, labColor: { ...group.labColor }, regionIds: [...group.regionIds], })) export const useMosaicStore = defineStore('mosaic', () => { const imageCanvas = shallowRef(null) const imageData = shallowRef(null) const imageBlob = shallowRef(null) const imageName = ref('') const imageWidth = ref(0) const imageHeight = ref(0) const regions = shallowRef([]) const groups = ref([]) const selectedGroupId = ref(null) const selectedRegionId = ref(null) const settings = reactive({ ...DEFAULT_SETTINGS }) const toasts = ref([]) const isAnalyzing = ref(false) const hasRecovery = ref(false) const recoveryTimestamp = ref('') const undoStack = shallowRef([]) const redoStack = shallowRef([]) let recoveryProject: ProjectFile | null = null let recoveryBlob: Blob | null = null let autosaveTimer = 0 const hasImage = computed(() => Boolean(imageCanvas.value && imageData.value)) const canUndo = computed(() => undoStack.value.length > 0) const canRedo = computed(() => redoStack.value.length > 0) function notify(text: string, type: ToastMessage['type'] = 'info'): void { const id = crypto.randomUUID() toasts.value.push({ id, text, type }) window.setTimeout(() => dismissToast(id), 5000) } function dismissToast(id: string): void { toasts.value = toasts.value.filter((toast) => toast.id !== id) } function snapshot(): HistorySnapshot { return { regions: cloneRegions(regions.value), groups: cloneGroups(groups.value) } } function recordHistory(): void { undoStack.value = [...undoStack.value.slice(-29), snapshot()] redoStack.value = [] } function restoreSnapshot(value: HistorySnapshot): void { regions.value = cloneRegions(value.regions) groups.value = cloneGroups(value.groups) selectedGroupId.value = null selectedRegionId.value = null scheduleAutosave() } function undo(): void { const previous = undoStack.value.at(-1) if (!previous) return redoStack.value = [...redoStack.value, snapshot()].slice(-30) undoStack.value = undoStack.value.slice(0, -1) restoreSnapshot(previous) } function redo(): void { const next = redoStack.value.at(-1) if (!next) return undoStack.value = [...undoStack.value, snapshot()].slice(-30) redoStack.value = redoStack.value.slice(0, -1) restoreSnapshot(next) } async function decodeImage(blob: Blob): Promise { const bitmap = await createImageBitmap(blob) if (bitmap.width * bitmap.height > 80_000_000) { bitmap.close() throw new Error('Obrázok je príliš veľký (limit je 80 miliónov pixelov). Skúste menšiu verziu.') } const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height const context = canvas.getContext('2d', { willReadFrequently: true }) if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.') context.drawImage(bitmap, 0, 0) bitmap.close() return canvas } async function loadImage(file: Blob, filename = 'obrazok'): Promise { if (file instanceof File && !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { notify('Podporované sú iba obrázky PNG, JPEG a WebP.', 'error') return } try { const canvas = await decodeImage(file) const context = canvas.getContext('2d', { willReadFrequently: true }) if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.') const pendingImportedProject = regions.value.length > 0 && !imageCanvas.value if (pendingImportedProject && (imageWidth.value !== canvas.width || imageHeight.value !== canvas.height)) { notify(`Projekt očakáva obrázok ${imageWidth.value} × ${imageHeight.value} px. Vybraný obrázok má ${canvas.width} × ${canvas.height} px.`, 'error') return } const replacingMatchingImage = pendingImportedProject imageCanvas.value = markRaw(canvas) imageData.value = markRaw(context.getImageData(0, 0, canvas.width, canvas.height)) imageBlob.value = markRaw(file) imageName.value = filename imageWidth.value = canvas.width imageHeight.value = canvas.height if (!replacingMatchingImage) { regions.value = [] groups.value = [] undoStack.value = [] redoStack.value = [] } if (canvas.width * canvas.height > 24_000_000) { notify('Obrázok je veľmi veľký. Analýza rozsiahlych plôch môže chvíľu trvať.', 'warning') } else { notify('Obrázok bol načítaný iba lokálne v prehliadači.', 'success') } scheduleAutosave() } catch (error) { notify(error instanceof Error ? error.message : 'Obrázok je poškodený alebo ho nemožno načítať.', 'error') } } function addRegionAt(x: number, y: number): void { if (!imageData.value || isAnalyzing.value) return const floodTolerance = settings.floodTolerance const minimumRegionPixelCount = floodTolerance <= 1 ? 1 : 12 const referenceColor = getFloodFillReferenceColor(imageData.value, x, y, floodTolerance) const existingRegion = regions.value.find((region) => maskContains(region.mask, x, y)) if (existingRegion && colorDistance(existingRegion.color, referenceColor) <= floodTolerance) { notify('Táto plocha už bola označená.', 'warning') return } const occupancy = buildOccupancy( imageWidth.value, imageHeight.value, regions.value.filter((region) => region.id !== existingRegion?.id).map((region) => region.mask), ) isAnalyzing.value = true window.setTimeout(() => { try { const result = floodFillRegion(imageData.value as ImageData, x, y, { tolerance: floodTolerance, darkBoundaryThreshold: settings.darkBoundaryThreshold, connectivity: settings.connectivity, occupancy, }) if (!result || result.pixelCount < minimumRegionPixelCount) { notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning') return } if (result.pixelCount > imageWidth.value * imageHeight.value * 0.92) { notify('Výber pokrýva takmer celý obrázok. Znížte toleranciu alebo kliknite dovnútra ohraničenej plochy.', 'warning') return } recordHistory() const region: MosaicRegion = markRaw({ id: crypto.randomUUID(), color: result.color, labColor: rgbToLab(result.color), pixelCount: result.pixelCount, boundingBox: result.boundingBox, labelPosition: result.labelPosition, userClickPosition: { x, y }, groupId: '', mask: result.mask, }) const nextRegions = regions.value.flatMap((item) => { if (item.id !== existingRegion?.id) return [item] const remainingMask = subtractRegionMask(item.mask, result.mask) const summary = remainingMask ? summarizeRegionMask(remainingMask) : null if (!remainingMask || !summary || summary.pixelCount < minimumRegionPixelCount) return [] return [markRaw({ ...item, mask: remainingMask, pixelCount: summary.pixelCount, boundingBox: summary.boundingBox, labelPosition: summary.labelPosition, userClickPosition: item.userClickPosition && maskContains(remainingMask, item.userClickPosition.x, item.userClickPosition.y) ? item.userClickPosition : undefined, })] }) regions.value = [...nextRegions, region] regroup(false) selectedRegionId.value = region.id selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null scheduleAutosave() } finally { isAnalyzing.value = false } }, 20) } function regroup(withHistory = true): void { if (withHistory && regions.value.length) recordHistory() groups.value = groupRegionsByColor(regions.value, settings.colorTolerance) const regionToGroup = new Map(groups.value.flatMap((group) => group.regionIds.map((id) => [id, group.id]))) regions.value = regions.value.map((region) => markRaw({ ...region, groupId: regionToGroup.get(region.id) ?? '' })) selectedGroupId.value = null scheduleAutosave() } function setColorTolerance(value: number): void { settings.colorTolerance = value regroup() } function removeRegion(id: string): void { if (!regions.value.some((region) => region.id === id)) return recordHistory() regions.value = regions.value.filter((region) => region.id !== id) groups.value = refreshGroupColors(groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((regionId) => regionId !== id) })), regions.value) selectedRegionId.value = null scheduleAutosave() } function moveRegion(regionId: string, targetGroupId: string): void { const region = regions.value.find((item) => item.id === regionId) if (!region || region.groupId === targetGroupId || !groups.value.some((group) => group.id === targetGroupId)) return recordHistory() regions.value = regions.value.map((item) => item.id === regionId ? markRaw({ ...item, groupId: targetGroupId }) : item) groups.value = refreshGroupColors(groups.value.map((group) => ({ ...group, regionIds: group.id === targetGroupId ? [...group.regionIds, regionId] : group.regionIds.filter((id) => id !== regionId), })), regions.value) scheduleAutosave() } function splitRegion(regionId: string): void { const region = regions.value.find((item) => item.id === regionId) const oldGroup = groups.value.find((group) => group.id === region?.groupId) if (!region || !oldGroup || oldGroup.regionIds.length <= 1) return recordHistory() const newGroup: ColorGroup = { id: crypto.randomUUID(), number: groups.value.length + 1, color: { ...region.color }, labColor: { ...region.labColor }, regionIds: [region.id], } regions.value = regions.value.map((item) => item.id === region.id ? markRaw({ ...item, groupId: newGroup.id }) : item) groups.value = refreshGroupColors([...groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((id) => id !== region.id) })), newGroup], regions.value) selectedGroupId.value = newGroup.id scheduleAutosave() } function mergeGroups(sourceId: string, targetId: string): void { if (sourceId === targetId) return const source = groups.value.find((group) => group.id === sourceId) const target = groups.value.find((group) => group.id === targetId) if (!source || !target) return recordHistory() regions.value = regions.value.map((region) => source.regionIds.includes(region.id) ? markRaw({ ...region, groupId: targetId }) : region) groups.value = refreshGroupColors(groups.value .filter((group) => group.id !== sourceId) .map((group) => group.id === targetId ? { ...group, regionIds: [...group.regionIds, ...source.regionIds] } : group), regions.value) selectedGroupId.value = targetId scheduleAutosave() } function setGroupColor(groupId: string, hex: string): void { const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex) if (!match) return recordHistory() const color = { r: Number.parseInt(match[1] ?? '0', 16), g: Number.parseInt(match[2] ?? '0', 16), b: Number.parseInt(match[3] ?? '0', 16) } groups.value = sortGroupsByLightness(groups.value.map((group) => group.id === groupId ? { ...group, color, labColor: rgbToLab(color), customColor: true } : group)) scheduleAutosave() } function clearResults(): void { if (!regions.value.length) return recordHistory() regions.value = [] groups.value = [] selectedGroupId.value = null selectedRegionId.value = null scheduleAutosave() } function serializeProject(includeSmallImage = false): Promise { return createProjectFile({ imageName: imageName.value, imageBlob: imageBlob.value, dimensions: { width: imageWidth.value, height: imageHeight.value }, settings: { ...settings }, regions: regions.value, groups: groups.value, includeSmallImage, }) } async function applyProject(project: ProjectFile, blob: Blob | null = null): Promise { Object.assign(settings, DEFAULT_SETTINGS, project.settings, { useUserClickPositionForLabels: project.settings.useUserClickPositionForLabels ?? false, }) imageName.value = project.imageName imageWidth.value = project.dimensions.width imageHeight.value = project.dimensions.height regions.value = project.regions.map((region) => markRaw({ ...region, userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined, mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs), })) groups.value = cloneGroups(project.groups) undoStack.value = [] redoStack.value = [] imageCanvas.value = null imageData.value = null imageBlob.value = null if (blob) await loadImage(blob, project.imageName) else if (project.imageDataUrl) { const response = await fetch(project.imageDataUrl) await loadImage(await response.blob(), project.imageName) } else notify('Projekt je načítaný. Vyberte pôvodný obrázok s rovnakými rozmermi.', 'warning') } async function importProjectFile(file: File): Promise { try { const parsed: unknown = JSON.parse(await file.text()) if (!isProjectFile(parsed)) throw new Error('Súbor nemá platný formát projektu Mozaic.') await applyProject(parsed) notify('Projekt bol úspešne importovaný.', 'success') } catch (error) { notify(error instanceof Error ? error.message : 'JSON projekt je poškodený.', 'error') } } function scheduleAutosave(): void { window.clearTimeout(autosaveTimer) autosaveTimer = window.setTimeout(async () => { if (!imageWidth.value) return try { await saveLastProject(await serializeProject(false), imageBlob.value) } catch { /* Private mode may block IndexedDB. */ } }, 800) } async function checkRecovery(): Promise { try { const stored = await loadLastProject() if (!stored) return recoveryProject = stored.project recoveryBlob = stored.imageBlob recoveryTimestamp.value = stored.savedAt hasRecovery.value = true } catch { /* IndexedDB is optional. */ } } async function restoreRecovery(): Promise { if (!recoveryProject) return await applyProject(recoveryProject, recoveryBlob) hasRecovery.value = false notify('Posledná rozpracovaná práca bola obnovená.', 'success') } async function dismissRecovery(): Promise { hasRecovery.value = false recoveryProject = null recoveryBlob = null await clearLastProject().catch(() => undefined) } watch(settings, scheduleAutosave) return { imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups, selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp, hasImage, canUndo, canRedo, loadImage, addRegionAt, regroup, setColorTolerance, removeRegion, moveRegion, splitRegion, mergeGroups, setGroupColor, clearResults, undo, redo, notify, dismissToast, serializeProject, importProjectFile, checkRecovery, restoreRecovery, dismissRecovery, } })