zakladna implementacia by Codex

This commit is contained in:
2026-08-01 07:28:21 +02:00
parent 1b71803407
commit a5973a5168
21 changed files with 1629 additions and 46 deletions

View File

@ -0,0 +1,383 @@
import { computed, markRaw, reactive, ref, shallowRef } from 'vue'
import { defineStore } from 'pinia'
import type {
ColorGroup,
MosaicRegion,
MosaicSettings,
ProjectFile,
ToastMessage,
} from '@/types/mosaic'
import { floodFillRegion } from '@/utils/floodFill'
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
import { rgbToLab } from '@/utils/colors'
import { buildOccupancy, createRegionMask } 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',
}
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
...region,
color: { ...region.color },
labColor: { ...region.labColor },
boundingBox: { ...region.boundingBox },
labelPosition: { ...region.labelPosition },
// 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<HTMLCanvasElement | null>(null)
const imageData = shallowRef<ImageData | null>(null)
const imageBlob = shallowRef<Blob | null>(null)
const imageName = ref('')
const imageWidth = ref(0)
const imageHeight = ref(0)
const regions = shallowRef<MosaicRegion[]>([])
const groups = ref<ColorGroup[]>([])
const selectedGroupId = ref<string | null>(null)
const selectedRegionId = ref<string | null>(null)
const settings = reactive<MosaicSettings>({ ...DEFAULT_SETTINGS })
const toasts = ref<ToastMessage[]>([])
const isAnalyzing = ref(false)
const hasRecovery = ref(false)
const recoveryTimestamp = ref('')
const undoStack = shallowRef<HistorySnapshot[]>([])
const redoStack = shallowRef<HistorySnapshot[]>([])
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<HTMLCanvasElement> {
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<void> {
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 occupancy = buildOccupancy(imageWidth.value, imageHeight.value, regions.value.map((region) => region.mask))
if (occupancy[y * imageWidth.value + x]) {
notify('Táto plocha už bola označená.', 'warning')
return
}
isAnalyzing.value = true
window.setTimeout(() => {
try {
const result = floodFillRegion(imageData.value as ImageData, x, y, {
tolerance: settings.floodTolerance,
darkBoundaryThreshold: settings.darkBoundaryThreshold,
connectivity: settings.connectivity,
occupancy,
})
if (!result || result.pixelCount < 12) {
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,
groupId: '',
mask: result.mask,
})
regions.value = [...regions.value, 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<ProjectFile> {
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<void> {
Object.assign(settings, project.settings)
imageName.value = project.imageName
imageWidth.value = project.dimensions.width
imageHeight.value = project.dimensions.height
regions.value = project.regions.map((region) => markRaw({
...region,
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<void> {
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<void> {
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<void> {
if (!recoveryProject) return
await applyProject(recoveryProject, recoveryBlob)
hasRecovery.value = false
notify('Posledná rozpracovaná práca bola obnovená.', 'success')
}
async function dismissRecovery(): Promise<void> {
hasRecovery.value = false
recoveryProject = null
recoveryBlob = null
await clearLastProject().catch(() => undefined)
}
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,
}
})