// --- Data Init & Colors --- let projects = JSON.parse(localStorage.getItem('crochetCounters')) || []; let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || []; if (!Array.isArray(patterns)) patterns = []; let saveFlashTimer = null; const crochetAbbrev = [ { code: 'alt', desc: 'alternate' }, { code: 'approx', desc: 'approximately' }, { code: 'beg', desc: 'begin/beginning' }, { code: 'bet', desc: 'between' }, { code: 'bl/blo', desc: 'back loop/back loop only' }, { code: 'bo', desc: 'bobble' }, { code: 'bp', desc: 'back post' }, { code: 'bpdc', desc: 'back post double crochet' }, { code: 'bpdtr', desc: 'back post double treble crochet' }, { code: 'bphdc', desc: 'back post half double crochet' }, { code: 'bpsc', desc: 'back post single crochet' }, { code: 'bptr', desc: 'back post treble crochet' }, { code: 'cc', desc: 'contrasting color' }, { code: 'ch', desc: 'chain stitch' }, { code: 'ch-sp', desc: 'chain space' }, { code: 'cl', desc: 'cluster' }, { code: 'cont', desc: 'continue' }, { code: 'dc', desc: 'double crochet' }, { code: 'dc2tog', desc: 'double crochet 2 stitches together' }, { code: 'dec', desc: 'decrease' }, { code: 'dtr', desc: 'double treble crochet' }, { code: 'edc', desc: 'extended double crochet' }, { code: 'ehdc', desc: 'extended half double crochet' }, { code: 'esc', desc: 'extended single crochet' }, { code: 'etr', desc: 'extended treble crochet' }, { code: 'fl/flo', desc: 'front loop/front loop only' }, { code: 'foll', desc: 'following' }, { code: 'fp', desc: 'front post' }, { code: 'fpdc', desc: 'front post double crochet' }, { code: 'fpdtr', desc: 'front post double treble crochet' }, { code: 'fphdc', desc: 'front post half double crochet' }, { code: 'fpsc', desc: 'front post single crochet' }, { code: 'fptr', desc: 'front post treble crochet' }, { code: 'hdc', desc: 'half double crochet' }, { code: 'hdc2tog', desc: 'half double crochet 2 stitches together' }, { code: 'inc', desc: 'increase' }, { code: 'lp', desc: 'loop' }, { code: 'm', desc: 'marker' }, { code: 'mc', desc: 'main color' }, { code: 'pat', desc: 'pattern' }, { code: 'pc', desc: 'popcorn stitch' }, { code: 'pm', desc: 'place marker' }, { code: 'prev', desc: 'previous' }, { code: 'ps/puff', desc: 'puff stitch' }, { code: 'rem', desc: 'remaining' }, { code: 'rep', desc: 'repeat' }, { code: 'rnd', desc: 'round' }, { code: 'rs', desc: 'right side' }, { code: 'sc', desc: 'single crochet' }, { code: 'sc2tog', desc: 'single crochet 2 stitches together' }, { code: 'sh', desc: 'shell' }, { code: 'sk', desc: 'skip' }, { code: 'sl st', desc: 'slip stitch' }, { code: 'sm/sl m', desc: 'slip marker' }, { code: 'sp', desc: 'space' }, { code: 'st', desc: 'stitch' }, { code: 'tbl', desc: 'through back loop' }, { code: 'tch/t-ch', desc: 'turning chain' }, { code: 'tog', desc: 'together' }, { code: 'tr', desc: 'treble crochet' }, { code: 'tr2tog', desc: 'treble crochet 2 stitches together' }, { code: 'trtr', desc: 'triple treble crochet' }, { code: 'ws', desc: 'wrong side' }, { code: 'yo', desc: 'yarn over' }, { code: 'yoh', desc: 'yarn over hook' }, // Tunisian { code: 'etss', desc: 'extended Tunisian simple stitch' }, { code: 'fwp', desc: 'forward pass' }, { code: 'retp', desc: 'return pass' }, { code: 'tdc', desc: 'Tunisian double crochet' }, { code: 'tfs', desc: 'Tunisian full stitch' }, { code: 'thdc', desc: 'Tunisian half double crochet' }, { code: 'tks', desc: 'Tunisian knit stitch' }, { code: 'tps', desc: 'Tunisian purl stitch' }, { code: 'trs', desc: 'Tunisian reverse stitch' }, { code: 'tsc', desc: 'Tunisian single crochet' }, { code: 'tss', desc: 'Tunisian simple stitch' }, { code: 'tslst', desc: 'Tunisian slip stitch' }, { code: 'ttr', desc: 'Tunisian treble crochet' }, { code: 'ttw', desc: 'Tunisian twisted' } ]; const knitAbbrev = [ { code: 'alt', desc: 'alternate' }, { code: 'approx', desc: 'approximately' }, { code: 'beg', desc: 'beginning' }, { code: 'bet', desc: 'between' }, { code: 'BO', desc: 'bind off' }, { code: 'byo', desc: 'backward yarn over' }, { code: 'CC', desc: 'contrasting color' }, { code: 'cn', desc: 'cable needle' }, { code: 'CO', desc: 'cast on' }, { code: 'cont', desc: 'continue' }, { code: 'dec', desc: 'decrease' }, { code: 'dpn', desc: 'double-pointed needles' }, { code: 'foll', desc: 'follow' }, { code: 'inc', desc: 'increase' }, { code: 'k', desc: 'knit' }, { code: 'k1B', desc: 'knit stitch in row below' }, { code: 'kfb', desc: 'knit 1 front and back (inc)' }, { code: 'ksp', desc: 'knit 1, slip back, pass over (dec)' }, { code: 'k2tog', desc: 'knit 2 together (dec)' }, { code: 'kwise', desc: 'knitwise' }, { code: 'LH', desc: 'left hand' }, { code: 'lp', desc: 'loop' }, { code: 'm', desc: 'marker' }, { code: 'M1', desc: 'make one knitwise (inc)' }, { code: 'M1R', desc: 'make one right (inc)' }, { code: 'M1L', desc: 'make one left (inc)' }, { code: 'M1p', desc: 'make one purlwise (inc)' }, { code: 'M1rp', desc: 'make one right purlwise (inc)' }, { code: 'M1lp', desc: 'make one left purlwise (inc)' }, { code: 'MC', desc: 'main color' }, { code: 'p', desc: 'purl' }, { code: 'pat', desc: 'pattern' }, { code: 'pfb', desc: 'purl front and back (inc)' }, { code: 'pm', desc: 'place marker' }, { code: 'p2tog', desc: 'purl 2 together (dec)' }, { code: 'prev', desc: 'previous' }, { code: 'psso', desc: 'pass slipped stitch over' }, { code: 'p2sso', desc: 'pass 2 slipped stitches over' }, { code: 'pwise', desc: 'purlwise' }, { code: 'rem', desc: 'remaining' }, { code: 'rep', desc: 'repeat' }, { code: 'rev St st', desc: 'reverse stockinette stitch' }, { code: 'RH', desc: 'right hand' }, { code: 'rnd', desc: 'round' }, { code: 'RS', desc: 'right side' }, { code: 'SKP', desc: 'slip 1 k-wise, k1, pass slipped over (dec)' }, { code: 'SK2P', desc: 'slip 1 k-wise, k2tog, pass slipped over (dec)' }, { code: 'sl', desc: 'slip' }, { code: 'sl1k', desc: 'slip 1 knitwise' }, { code: 'sl1p', desc: 'slip 1 purlwise' }, { code: 'sl st', desc: 'slip stitch' }, { code: 'sm', desc: 'slip marker' }, { code: 'ssk', desc: 'slip 2 k-wise, k through back loops (dec)' }, { code: 'ssp', desc: 'slip 2 k-wise, return, p through back loops (dec)' }, { code: 'sssk', desc: 'slip 3 k-wise, k through back loops (double dec)' }, { code: 'sssp', desc: 'slip 3 k-wise, return, p through back loops (double dec)' }, { code: 'S2KP2', desc: 'slip 2 as if to k2tog, k1, pass 2 over (double dec)' }, { code: 'SSPP2', desc: 'slip 2 k-wise, return, p2tog tbl, p1, pass 2 over (double dec)' }, { code: 'st', desc: 'stitch' }, { code: 'St st', desc: 'stockinette stitch' }, { code: 'tbl', desc: 'through back loop' }, { code: 'tfl', desc: 'through front loop' }, { code: 'tog', desc: 'together' }, { code: 'WS', desc: 'wrong side' }, { code: 'w&t', desc: 'wrap and turn' }, { code: 'wyib', desc: 'with yarn in back' }, { code: 'wyif', desc: 'with yarn in front' }, { code: 'yb', desc: 'yarn back' }, { code: 'yfwd/yf', desc: 'yarn forward' }, { code: 'yo', desc: 'yarn over' }, { code: 'yon', desc: 'yarn over needle' }, { code: 'yrn', desc: 'yarn round needle' } ]; const crochetBuckets = [ { label: 'Core stitches', codes: ['ch', 'sc', 'hdc', 'dc', 'tr', 'sl st'] }, { label: 'Shaping & repeats', codes: ['inc', 'dec', 'tog', 'rep', 'rem', 'rnd', 'rs', 'ws', 'tch/t-ch'] }, { label: 'Placement & loops', codes: ['fl/flo', 'bl/blo', 'tbl', 'lp', 'sp', 'ch-sp', 'prev'] }, { label: 'Post stitches', codes: ['fp', 'fpdc', 'fptr', 'fpsc', 'fphdc', 'fpdtr', 'bp', 'bpdc', 'bptr', 'bpsc', 'bphdc', 'bpdtr'] }, { label: 'Extended & special', codes: ['pat', 'pc', 'ps/puff', 'cl', 'sh', 'dtr', 'edc', 'ehdc', 'esc', 'etr', 'tr2tog', 'trtr', 'yo', 'yoh'] }, { label: 'Markers & color', codes: ['mc', 'cc', 'm', 'pm', 'sm/sl m'] }, { label: 'Tunisian', codes: ['etss', 'fwp', 'retp', 'tdc', 'tfs', 'thdc', 'tks', 'tps', 'trs', 'tsc', 'tss', 'tslst', 'ttr', 'ttw'] }, { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont', 'foll', 'pat', 'prev'] } ]; const knitBuckets = [ { label: 'Core stitches', codes: ['k', 'p', 'yo', 'sl st', 'St st', 'rev St st'] }, { label: 'Increases', codes: ['inc', 'M1', 'M1R', 'M1L', 'M1p', 'M1rp', 'M1lp', 'kfb', 'pfb'] }, { label: 'Decreases', codes: ['dec', 'k2tog', 'ssk', 'SKP', 'SK2P', 'ssp', 'sssk', 'sssp', 'S2KP2', 'SSPP2', 'p2tog', 'psso', 'p2sso', 'ksp', 'tog'] }, { label: 'Slips & markers', codes: ['sl', 'sl1k', 'sl1p', 'm', 'pm', 'sm', 'cn', 'dpn'] }, { label: 'Yarn moves', codes: ['wyib', 'wyif', 'yfwd/yf', 'yb', 'yon', 'yrn', 'w&t'] }, { label: 'Cast on / bind off', codes: ['CO', 'BO'] }, { label: 'Color & pattern', codes: ['MC', 'CC', 'pat'] }, { label: 'Positioning', codes: ['k1B', 'tbl', 'tfl', 'lp', 'prev'] }, { label: 'Rows & sides', codes: ['rep', 'rem', 'RS', 'WS', 'rnd', 'foll'] }, { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont'] } ]; const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end']; const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']); function getActiveAbbrevLibrary() { return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev; } function getAbbrevByCode(code) { return crochetAbbrev.find(a => a.code === code) || knitAbbrev.find(a => a.code === code); } function buildAbbrevGroups(library, buckets) { const index = new Map(library.map(item => [item.code, item])); const used = new Set(); const groups = []; buckets.forEach(bucket => { const items = []; bucket.codes.forEach(code => { const item = index.get(code); if (item && !used.has(code)) { items.push(item); used.add(code); } }); if (items.length) groups.push({ label: bucket.label, items }); }); const leftover = library.filter(item => !used.has(item.code)); if (leftover.length) groups.push({ label: 'Other', items: leftover }); return groups; } function getAbbrevGroups() { const library = getActiveAbbrevLibrary(); const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; return buildAbbrevGroups(library, buckets); } function getCoreCodes() { const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; const core = buckets.find(b => b.label === 'Core stitches'); return core ? core.codes : []; } function getPatternButtonCodes() { const base = (patternDraft.abbrevSelection && patternDraft.abbrevSelection.length) ? patternDraft.abbrevSelection : (patternDraft.mode === 'knit' ? ['k', 'p', 'yo', 'k2tog', 'ssk', 'sl st'] : ['ch', 'sc', 'hdc', 'dc', 'tr', 'inc', 'dec', 'sl st', 'sk', 'rep']); const merged = [...base, ...repeatTokens]; return Array.from(new Set(merged)); } function normalizePatternDraft(d = {}) { const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' }; const base = { mode: 'crochet', currentRow: 1, line: '', output: '', meta: { title: '', designer: '' }, materials: '', gauge: '', gaugeSts: '', gaugeRows: '', gaugeHook: '', size: '', abbrev: '', abbrevSelection: [], stitches: '', notes: '', steps: [] }; const merged = { ...base, ...d }; merged.meta = { ...base.meta, ...(d.meta || {}) }; merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : []; merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : []; merged.materials = merged.materials || ''; merged.gauge = merged.gauge || ''; merged.gaugeSts = merged.gaugeSts || ''; merged.gaugeRows = merged.gaugeRows || ''; merged.gaugeHook = merged.gaugeHook || ''; merged.size = merged.size || ''; merged.abbrev = merged.abbrev || ''; merged.stitches = merged.stitches || ''; merged.notes = merged.notes || ''; if (!merged.mode) merged.mode = 'crochet'; if (!merged.currentRow) merged.currentRow = 1; if (merged.line === undefined) merged.line = ''; if (merged.output === undefined) merged.output = ''; return merged; } let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {}); // New Earthy/Woodland Palette extracted from image vibes const colors = [ '#a17d63', // Soft oak '#7a8c6a', // Moss sage '#c7a272', // Warm amber '#b88b8a', // Rose clay '#7b9189', // Sage teal '#aa9a7a', // Wheat linen '#5f6d57', // Forest dusk '#b07d6f', // Terra blush '#6d5947', // Walnut '#c4b08a', // Oat '#7c7565', // Stone '#8ca58c' // Meadow ]; const oldColors = [ '#b56b54', // Rust/Mushroom '#7a8b4f', // Olive Green '#cba052', // Mustard/Daisy center '#5f8a8b', // Muted Teal '#8c6246', // Warm Wood tone '#a87b8c', // Dusty Rose '#4a5d43', // Deep Forest '#9c7e63' // Taupe ]; // --- State Variables --- let modalState = { type: null, projectId: null, partId: null }; const app = document.getElementById('app'); const modal = document.getElementById('modalOverlay'); const modalInput = document.getElementById('modalInput'); const modalTitle = document.getElementById('modalTitle'); const hapticTick = () => { if ('vibrate' in navigator) navigator.vibrate(12); }; const installBtn = document.getElementById('installBtn'); const importInput = document.getElementById('importFile'); const motionBtn = document.getElementById('motionBtn'); const colorOverlay = document.getElementById('colorOverlay'); const colorGrid = document.getElementById('colorGrid'); const customColorInput = document.getElementById('customColorInput'); const saveOverlay = document.getElementById('saveOverlay'); const saveList = document.getElementById('saveList'); const patternPicker = document.getElementById('patternPicker'); const patternSelect = document.getElementById('patternSelect'); const patternOverlay = document.getElementById('patternOverlay'); const patternRowNumber = null; const patternLine = null; const patternOutput = document.getElementById('patternOutput'); const patternButtonsWrap = null; if (patternPicker && patternSelect) populatePatternSelect(); if (patternOverlay) { patternOverlay.addEventListener('click', (e) => { if (e.target === patternOverlay) closePatternComposer(); }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && patternOverlay && patternOverlay.classList.contains('active')) { closePatternComposer(); } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && patternOverlay && patternOverlay.classList.contains('active')) { e.preventDefault(); addStep(); } }); function persistPatternDraft() { localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft)); flashSave(); } function setPatternMode(mode) { patternDraft.mode = mode; persistPatternDraft(); if (!document.querySelector('.pattern-overlay')) return; document.querySelectorAll('.pattern-mode').forEach(btn => { btn.classList.toggle('is-active', btn.dataset.mode === mode); }); renderAbbrevChecklist(); renderSteps(); } function loadDefaultAbbrev() { patternDraft.abbrevSelection = getCoreCodes(); updateAbbrevFromSelection(); renderAbbrevChecklist(); } function bindPatternInputs() { const titleEl = document.getElementById('patternTitle'); const designerEl = document.getElementById('patternDesigner'); const materialsEl = document.getElementById('patternMaterials'); const gaugeEl = document.getElementById('patternGauge'); const gaugeStsEl = document.getElementById('patternGaugeSts'); const gaugeRowsEl = document.getElementById('patternGaugeRows'); const gaugeHookEl = document.getElementById('patternGaugeHook'); const sizeEl = document.getElementById('patternSize'); const abbrevEl = document.getElementById('patternAbbrev'); const stitchesEl = document.getElementById('patternStitches'); const notesEl = document.getElementById('patternNotes'); const outputEl = document.getElementById('patternOutput'); if (titleEl) titleEl.addEventListener('input', e => { updateMetaField('title', e.target.value); }); if (designerEl) designerEl.addEventListener('input', e => { updateMetaField('designer', e.target.value); }); if (materialsEl) materialsEl.addEventListener('input', e => { updatePatternField('materials', e.target.value); }); if (gaugeEl) gaugeEl.addEventListener('input', e => { updatePatternField('gauge', e.target.value); }); if (gaugeStsEl) gaugeStsEl.addEventListener('input', e => { updatePatternField('gaugeSts', e.target.value); }); if (gaugeRowsEl) gaugeRowsEl.addEventListener('input', e => { updatePatternField('gaugeRows', e.target.value); }); if (gaugeHookEl) gaugeHookEl.addEventListener('input', e => { updatePatternField('gaugeHook', e.target.value); }); if (sizeEl) sizeEl.addEventListener('input', e => { updatePatternField('size', e.target.value); }); if (abbrevEl) abbrevEl.addEventListener('input', e => { updatePatternField('abbrev', e.target.value); }); if (stitchesEl) stitchesEl.addEventListener('input', e => { updatePatternField('stitches', e.target.value); }); if (notesEl) notesEl.addEventListener('input', e => { updatePatternField('notes', e.target.value); }); if (outputEl) outputEl.addEventListener('input', e => { patternDraft.output = e.target.value; const lines = patternDraft.output.split('\n').filter(l => l.trim() !== ''); patternDraft.currentRow = Math.max(1, lines.length + 1); persistPatternDraft(); }); } function syncPatternUI() { setPatternMode(patternDraft.mode); const titleEl = document.getElementById('patternTitle'); const designerEl = document.getElementById('patternDesigner'); const materialsEl = document.getElementById('patternMaterials'); const gaugeEl = document.getElementById('patternGauge'); const gaugeStsEl = document.getElementById('patternGaugeSts'); const gaugeRowsEl = document.getElementById('patternGaugeRows'); const gaugeHookEl = document.getElementById('patternGaugeHook'); const sizeEl = document.getElementById('patternSize'); const abbrevEl = document.getElementById('patternAbbrev'); const stitchesEl = document.getElementById('patternStitches'); const notesEl = document.getElementById('patternNotes'); if (titleEl) titleEl.value = patternDraft.meta.title; if (designerEl) designerEl.value = patternDraft.meta.designer; if (materialsEl) materialsEl.value = patternDraft.materials; if (gaugeEl) gaugeEl.value = patternDraft.gauge; if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts; if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows; if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook; if (sizeEl) sizeEl.value = patternDraft.size; if (abbrevEl) abbrevEl.value = patternDraft.abbrev; if (stitchesEl) stitchesEl.value = patternDraft.stitches; if (notesEl) notesEl.value = patternDraft.notes; renderSteps(); renderAbbrevChecklist(); } function addPatternToken(tok) { if (!patternLine) return; patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok; patternLine.value = patternDraft.line; persistPatternDraft(); } function clearPatternLine() { if (!patternLine) return; patternDraft.line = ''; patternLine.value = ''; persistPatternDraft(); } function addPatternRow() { if (!patternLine || !patternOutput) return; const line = patternDraft.line.trim(); if (!line) return; const rowLabel = patternDraft.mode === 'knit' ? 'Row' : 'Rnd'; const rowText = `${rowLabel} ${patternDraft.currentRow}: ${line}`; patternDraft.output = patternDraft.output ? `${patternDraft.output}\n${rowText}` : rowText; patternDraft.currentRow += 1; patternDraft.line = ''; persistPatternDraft(); syncPatternUI(); } function removePatternRow() { if (!patternOutput) return; const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== ''); if (lines.length === 0) return; lines.pop(); patternDraft.output = lines.join('\n'); patternDraft.currentRow = Math.max(1, lines.length + 1); persistPatternDraft(); syncPatternUI(); } function clearPatternOutput() { if (!patternOutput) return; patternDraft.output = ''; patternDraft.currentRow = 1; patternDraft.line = ''; patternDraft.meta = { title: '', designer: '' }; patternDraft.materials = ''; patternDraft.gauge = ''; patternDraft.gaugeSts = ''; patternDraft.gaugeRows = ''; patternDraft.gaugeHook = ''; patternDraft.size = ''; patternDraft.abbrev = ''; patternDraft.stitches = ''; patternDraft.notes = ''; patternDraft.steps = []; if (patternOutput) patternOutput.value = ''; persistPatternDraft(); syncPatternUI(); } function exportPatternJSON() { const payload = { patternDraft }; const name = patternDraft.meta.title || 'pattern'; const filename = `${name.replace(/\s+/g,'_').toLowerCase()}_draft.json`; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function importPatternJSON() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!data.patternDraft) throw new Error('Invalid file'); patternDraft = normalizePatternDraft(data.patternDraft); persistPatternDraft(); syncPatternUI(); } catch (err) { showAlert({ title: 'Import failed', text: err.message }); } }; input.click(); } function exportPatternPDF() { const w = window.open('', '_blank'); if (!w) return; const styles = getComputedStyle(document.body); const themeBg = styles.getPropertyValue('--bg') || '#f4f0e8'; const themeText = styles.getPropertyValue('--text') || '#2f2b28'; const accent = styles.getPropertyValue('--project-color') || '#7a8c6a'; const gaugeBlock = `${patternDraft.gaugeSts || ''}${patternDraft.gaugeRows ? ' • ' + patternDraft.gaugeRows : ''}${patternDraft.gaugeHook ? ' • ' + patternDraft.gaugeHook : ''}`; const fontLink = ''; const html = ` ${patternDraft.meta.title || 'Pattern'} - PDF ${fontLink}

${patternDraft.meta.title || 'Pattern'}

${patternDraft.meta.designer || ''}

Materials
${patternDraft.materials || ''}
Gauge / Size
${gaugeBlock}\n${patternDraft.gauge || ''}\n${patternDraft.size || ''}
Abbreviations
${patternDraft.abbrev || ''}
Stitch Guide
${patternDraft.stitches || ''}
Steps
${patternDraft.steps.map((s, i) => { const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('
'); const note = s.note ? `
${s.note}` : ''; return `
Step ${i + 1}${s.title ? ': ' + s.title : ''}
${rows}${note}
`; }).join('
')}
Rows
${patternDraft.output || ''}
Notes
${patternDraft.notes || ''}
`; w.document.write(html); w.document.close(); w.focus(); w.print(); } function showPatternTab(tab) { document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab)); document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab)); } function updateMetaField(field, value) { patternDraft.meta[field] = value; persistPatternDraft(); } function updatePatternField(field, value) { patternDraft[field] = value; persistPatternDraft(); } function renderSteps() { const container = document.getElementById('patternSteps'); if (!container) return; container.innerHTML = ''; patternDraft.steps.forEach((step, idx) => { const card = document.createElement('div'); card.className = 'pattern-step-card card-pop'; const rows = step.rows || []; const rowList = rows.map((r, i) => `
`).join(''); card.innerHTML = `
Step ${idx + 1}
${getPatternButtonCodes().map(tok => ``).join('')}
${rowList}
`; card.querySelectorAll('input, textarea').forEach(el => { el.addEventListener('input', (e) => { const i = Number(e.target.dataset.idx); const field = e.target.dataset.field; if (!patternDraft.steps[i]) return; patternDraft.steps[i][field] = e.target.value; persistPatternDraft(); }); }); card.querySelectorAll('.pattern-buttons button').forEach(btn => { btn.addEventListener('click', () => { const tok = btn.dataset.tok; addPatternTokenToStep(idx, tok); }); }); card.querySelectorAll('input[data-row]').forEach(inp => { inp.addEventListener('input', (e) => { const rowIdx = Number(e.target.dataset.row); updateStepRow(idx, rowIdx, e.target.value); }); }); const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]'); if (rowTextarea) { rowTextarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); addStepRow(idx); } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addStep(); } }); } requestAnimationFrame(() => { card.classList.remove('card-pop'); }); container.appendChild(card); }); const addRow = document.createElement('div'); addRow.className = 'pattern-step-card add-step-row'; addRow.innerHTML = ` `; container.appendChild(addRow); } function addStep() { patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' }); persistPatternDraft(); renderSteps(); } function addStepRow(idx) { const step = patternDraft.steps[idx]; if (!step) return; const line = (step.rowDraft || '').trim(); if (!line) return; step.rows.push(line); step.rowDraft = ''; persistPatternDraft(); renderSteps(); } function removeStepLastRow(idx) { // no-op: last-row removal handled per-row delete } function removeStepRow(idx, rowIdx) { const step = patternDraft.steps[idx]; if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; step.rows.splice(rowIdx, 1); persistPatternDraft(); renderSteps(); } function clearStepRow(idx) { const step = patternDraft.steps[idx]; if (!step) return; step.rowDraft = ''; persistPatternDraft(); renderSteps(); } function updateStepRow(idx, rowIdx, value) { const step = patternDraft.steps[idx]; if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; step.rows[rowIdx] = value; persistPatternDraft(); } function renderAbbrevChecklist() { const listEl = document.getElementById('patternAbbrevList'); if (!listEl) return; listEl.innerHTML = ''; const selected = new Set(patternDraft.abbrevSelection); getAbbrevGroups().forEach(bucket => { const wrap = document.createElement('details'); wrap.className = 'abbrev-group'; wrap.open = true; const summary = document.createElement('summary'); summary.textContent = `${bucket.label} (${bucket.items.length})`; wrap.appendChild(summary); const grid = document.createElement('div'); grid.className = 'abbrev-grid'; bucket.items.forEach(item => { const pill = document.createElement('div'); pill.className = 'abbrev-pill'; if (selected.has(item.code)) pill.classList.add('is-selected'); pill.dataset.code = item.code; pill.innerHTML = ` ${item.code} ${item.desc} `; pill.addEventListener('click', () => { const code = pill.dataset.code; if (pill.classList.contains('is-selected')) { pill.classList.remove('is-selected'); patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code); } else { pill.classList.add('is-selected'); if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code); } updateAbbrevFromSelection(); }); grid.appendChild(pill); }); wrap.appendChild(grid); listEl.appendChild(wrap); }); renderSelectedAbbrev(); } function updateAbbrevFromSelection() { const lines = patternDraft.abbrevSelection.map(code => { const found = getAbbrevByCode(code); return found ? `${found.code} – ${found.desc}` : code; }); patternDraft.abbrev = lines.join('\n'); persistPatternDraft(); const abbrevEl = document.getElementById('patternAbbrev'); if (abbrevEl) abbrevEl.value = patternDraft.abbrev; renderSelectedAbbrev(); renderSteps(); } function renderSelectedAbbrev() { renderPatternButtons(); } function renderPatternButtons() { // Buttons are rendered per-step; no global buttons. } function deleteStep(idx) { patternDraft.steps.splice(idx, 1); persistPatternDraft(); renderSteps(); } function moveStep(idx, dir) { const target = idx + dir; if (target < 0 || target >= patternDraft.steps.length) return; const tmp = patternDraft.steps[idx]; patternDraft.steps[idx] = patternDraft.steps[target]; patternDraft.steps[target] = tmp; persistPatternDraft(); renderSteps(); } function addPatternTokenToStep(idx, tok) { const step = patternDraft.steps[idx]; if (!step) return; // Append with comma/space if (!step.rowDraft) { step.rowDraft = tok; } else { // If last token matches, increment count const parts = step.rowDraft.split(',').map(s => s.trim()).filter(Boolean); if (parts.length && !nonMergeTokens.has(tok)) { const last = parts[parts.length - 1]; const escapedTok = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = last.match(new RegExp(`^${escapedTok}(\\d+)$`, 'i')); if (last.toLowerCase() === tok.toLowerCase()) { parts[parts.length - 1] = `${tok}2`; } else if (match) { const n = parseInt(match[1], 10) + 1; parts[parts.length - 1] = `${tok}${n}`; } else { parts.push(tok); } step.rowDraft = parts.join(', '); } else { parts.push(tok); step.rowDraft = parts.join(', '); } } persistPatternDraft(); renderSteps(); } function addStepRow(idx) { const step = patternDraft.steps[idx]; if (!step) return; const line = (step.rowDraft || '').trim(); if (!line) return; step.rows.push(line); step.rowDraft = ''; persistPatternDraft(); renderSteps(); } function removeStepRow(idx) { const step = patternDraft.steps[idx]; if (!step || !step.rows || step.rows.length === 0) return; step.rows.pop(); persistPatternDraft(); renderSteps(); } function clearStepRow(idx) { const step = patternDraft.steps[idx]; if (!step) return; step.rowDraft = ''; persistPatternDraft(); renderSteps(); } function openPatternComposer() { if (!patternOverlay) return; patternOverlay.classList.add('active'); syncPatternUI(); showPatternTab('steps'); } function closePatternComposer() { if (!patternOverlay) return; patternOverlay.classList.remove('active'); } syncPatternUI(); bindPatternInputs(); renderAbbrevChecklist(); let pendingSaveSelection = []; let lastCountPulse = null; let lastFinishedId = null; let fireflyTimer = null; let fireflyActive = false; let titleClicks = []; let easterEggCooling = false; // --- Service Worker --- if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => { // fail silently; optional log }); }); } // --- Install Prompt --- let deferredInstallPrompt = null; const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; function hideInstall() { if (installBtn) installBtn.classList.add('hidden'); } function showInstall() { if (installBtn) installBtn.classList.remove('hidden'); } window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredInstallPrompt = e; if (!isStandalone()) showInstall(); }); window.addEventListener('appinstalled', () => { deferredInstallPrompt = null; hideInstall(); }); if (installBtn) { installBtn.addEventListener('click', async () => { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); const choice = await deferredInstallPrompt.userChoice; if (choice.outcome === 'accepted') hideInstall(); deferredInstallPrompt = null; }); } if (isStandalone()) hideInstall(); // --- Sweet-ish Alerts --- function removeSwal() { const existing = document.querySelector('.swal-overlay'); if (existing) existing.remove(); } function showConfirm({ title = 'Are you sure?', text = '', confirmText = 'Yes', cancelText = 'Cancel', danger = false } = {}) { return new Promise(resolve => { removeSwal(); const overlay = document.createElement('div'); overlay.className = 'swal-overlay'; overlay.innerHTML = `
${title}
${text}
`; const cancelBtn = overlay.querySelector('.swal-cancel'); const confirmBtn = overlay.querySelector('.swal-confirm, .swal-danger'); cancelBtn.onclick = () => { removeSwal(); resolve(false); }; confirmBtn.onclick = () => { removeSwal(); resolve(true); }; overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(false); } }); document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape') { removeSwal(); resolve(false); document.removeEventListener('keydown', onKey); } }); document.body.appendChild(overlay); }); } function showAlert({ title = 'Notice', text = '' } = {}) { return new Promise(resolve => { removeSwal(); const overlay = document.createElement('div'); overlay.className = 'swal-overlay'; overlay.innerHTML = `
${title}
${text}
`; const okBtn = overlay.querySelector('.swal-confirm'); okBtn.onclick = () => { removeSwal(); resolve(); }; overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(); } }); document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape') { removeSwal(); resolve(); document.removeEventListener('keydown', onKey); } }); document.body.appendChild(overlay); }); } // --- Theme Logic --- let isDarkMode = JSON.parse(localStorage.getItem('crochetDarkMode')); if (isDarkMode === null) { if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { isDarkMode = true; } else { isDarkMode = false; } } let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations')); if (animationsEnabled === null) animationsEnabled = true; function applyTheme() { if (isDarkMode) { document.body.classList.add('dark-mode'); document.getElementById('themeBtn').innerHTML = ''; } else { document.body.classList.remove('dark-mode'); document.getElementById('themeBtn').innerHTML = ''; } handleAmbientDrift(); updateMotionBtn(); } function toggleTheme() { isDarkMode = !isDarkMode; localStorage.setItem('crochetDarkMode', isDarkMode); applyTheme(); if (animationsEnabled) { document.body.classList.add('theme-animating'); setTimeout(() => document.body.classList.remove('theme-animating'), 750); } } applyTheme(); function updateMotionBtn() { if (!motionBtn) return; motionBtn.innerHTML = animationsEnabled ? '' : ''; motionBtn.title = animationsEnabled ? 'Toggle Animations' : 'Animations disabled'; } function toggleAnimations() { animationsEnabled = !animationsEnabled; localStorage.setItem('crochetAnimations', animationsEnabled); updateMotionBtn(); if (!animationsEnabled) { stopAmbientDrift(); document.body.classList.remove('theme-animating'); } else { handleAmbientDrift(); } } function openColorPicker(pId, partId) { if (!colorOverlay || !colorGrid) return; const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); colorGrid.innerHTML = colors.map(c => ` `).join(''); if (customColorInput) { customColorInput.value = part.color || project.color || colors[0]; customColorInput.oninput = (e) => { setPartColor(pId, partId, e.target.value); }; } colorOverlay.classList.add('active'); colorOverlay.dataset.projectId = pId; colorOverlay.dataset.partId = partId; } function closeColorPicker() { if (!colorOverlay) return; colorOverlay.classList.remove('active'); colorGrid.innerHTML = ''; colorOverlay.dataset.projectId = ''; colorOverlay.dataset.partId = ''; } function savePatterns() { localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); } function populatePatternSelect() { if (!patternPicker || !patternSelect) return; const hasPatterns = patterns.length > 0; patternPicker.style.display = hasPatterns ? 'block' : 'none'; patternSelect.innerHTML = '' + patterns.map(p => ``).join(''); } function exportData(selectedProjects = projects) { const payload = { projects: selectedProjects, isDarkMode, animationsEnabled, patterns }; const names = selectedProjects.map(p => p.name || 'Project').join('_').replace(/\s+/g, '-').slice(0, 50) || 'projects'; const filename = `toadstool_${names}.json`; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function triggerImport() { if (!importInput) return; importInput.value = ''; importInput.click(); } async function handleImport(event) { const file = event.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!data.projects || !Array.isArray(data.projects)) throw new Error('Invalid file'); projects = data.projects; if (typeof data.isDarkMode === 'boolean') { isDarkMode = data.isDarkMode; localStorage.setItem('crochetDarkMode', isDarkMode); } if (typeof data.animationsEnabled === 'boolean') { animationsEnabled = data.animationsEnabled; localStorage.setItem('crochetAnimations', animationsEnabled); } if (Array.isArray(data.patterns)) { patterns = data.patterns; localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); } localStorage.setItem('crochetCounters', JSON.stringify(projects)); applyTheme(); render(); } catch (err) { showAlert({ title: 'Import failed', text: err.message }); } event.target.value = ''; } if (importInput) { importInput.addEventListener('change', handleImport); } function openSaveModal() { if (!saveOverlay || !saveList) return; saveList.innerHTML = ''; pendingSaveSelection = projects.map(p => p.id); projects.forEach(p => { const item = document.createElement('label'); item.className = 'save-item'; item.innerHTML = ` ${p.name} `; saveList.appendChild(item); }); saveOverlay.classList.add('active'); } function closeSaveModal() { if (!saveOverlay) return; saveOverlay.classList.remove('active'); saveList.innerHTML = ''; } function exportSelected() { if (!saveOverlay) return; const inputs = saveList.querySelectorAll('input[type="checkbox"]'); const selectedIds = Array.from(inputs).filter(i => i.checked).map(i => Number(i.dataset.id)); if (selectedIds.length === 0) { closeSaveModal(); return; } const selectedProjects = projects.filter(p => selectedIds.includes(p.id)); exportData(selectedProjects); closeSaveModal(); } // --- Firefly Animation --- function spawnFirefly({ markActive = false, source = 'ambient', side = 'any' } = {}) { const wrap = document.createElement('div'); wrap.className = 'firefly-wrap'; const el = document.createElement('div'); el.className = 'firefly'; const top = Math.random() * 55 + 5; // 5vh–60vh const scale = 0.9 + Math.random() * 0.4; const duration = 12 + Math.random() * 8; // 12–20s const chosenSide = side === 'any' ? ['left','right','top','bottom'][Math.floor(Math.random()*4)] : side; let startX = '-10vw', endX = '110vw', startY = `${top}vh`, endY = `${top + (Math.random()*12 - 6)}vh`; let midX = '25vw', midY = `${top - 6}vh`, mid2X = '65vw', mid2Y = `${top + 6}vh`; if (chosenSide === 'right') { startX = '110vw'; endX = '-10vw'; midX = '-25vw'; mid2X = '-65vw'; } else if (chosenSide === 'top') { const x = Math.random()*80 + 10; startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`; startY = '-12vh'; endY = '110vh'; midX = `${x + 8}vw`; mid2X = `${x - 8}vw`; midY = '25vh'; mid2Y = '65vh'; } else if (chosenSide === 'bottom') { const x = Math.random()*80 + 10; startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`; startY = '110vh'; endY = '-12vh'; midX = `${x - 8}vw`; mid2X = `${x + 8}vw`; midY = '75vh'; mid2Y = '35vh'; } wrap.style.setProperty('--fly-scale', scale); wrap.style.setProperty('--fly-duration', `${duration}s`); wrap.style.setProperty('--fly-start-x', startX); wrap.style.setProperty('--fly-start-y', startY); wrap.style.setProperty('--fly-mid-x', midX); wrap.style.setProperty('--fly-mid-y', midY); wrap.style.setProperty('--fly-mid2-x', mid2X); wrap.style.setProperty('--fly-mid2-y', mid2Y); wrap.style.setProperty('--fly-end-x', endX); wrap.style.setProperty('--fly-end-y', endY); if (markActive) fireflyActive = true; wrap.addEventListener('animationend', (e) => { if (e.animationName !== 'fireflyGlide') return; wrap.remove(); if (markActive) fireflyActive = false; }); wrap.appendChild(el); document.body.appendChild(wrap); } function spawnSeed({ markActive = false, source = 'ambient' } = {}) { const wrap = document.createElement('div'); wrap.className = 'seed-wrap'; const el = document.createElement('div'); el.className = 'seed'; const top = Math.random() * 55 + 5; // 5vh–60vh const scale = 0.85 + Math.random() * 0.4; const duration = 14 + Math.random() * 8; // 14–22s const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-8–24deg const sway = 4 + Math.random() * 6; // px const flipDur = 5 + Math.random() * 4; // 5–9s const dir = ['left','right','top'][Math.floor(Math.random()*3)]; const fromLeft = dir === 'left'; let start = fromLeft ? '-12vw' : '112vw'; let mid = fromLeft ? '30vw' : '-30vw'; let end = fromLeft ? '112vw' : '-12vw'; if (dir === 'top') { const x = Math.random()*80 + 10; start = `${x}vw`; mid = `${x + (Math.random()*10 - 5)}vw`; end = `${x + (Math.random()*20 - 10)}vw`; wrap.style.top = '-12vh'; } else { wrap.style.top = `${top}vh`; } wrap.style.setProperty('--seed-scale', scale); wrap.style.setProperty('--seed-duration', `${duration}s`); wrap.style.setProperty('--seed-tilt', `${tilt}deg`); wrap.style.setProperty('--seed-sway', `${sway}px`); wrap.style.setProperty('--seed-flip-duration', `${flipDur}s`); wrap.style.setProperty('--seed-start', start); wrap.style.setProperty('--seed-mid', mid); wrap.style.setProperty('--seed-end', end); if (markActive) fireflyActive = true; wrap.addEventListener('animationend', (e) => { if (e.animationName !== 'seedGlide') return; wrap.remove(); if (markActive) fireflyActive = false; }); wrap.appendChild(el); document.body.appendChild(wrap); } function stopAmbientDrift() { if (fireflyTimer) { clearTimeout(fireflyTimer); fireflyTimer = null; } document.querySelectorAll('.firefly-wrap').forEach(el => el.remove()); document.querySelectorAll('.seed-wrap').forEach(el => el.remove()); fireflyActive = false; } function scheduleAmbientDrift() { const delay = 10000 + Math.random() * 10000; // 10–20s fireflyTimer = setTimeout(() => { if (!animationsEnabled) { stopAmbientDrift(); return; } const selector = isDarkMode ? '.firefly-wrap' : '.seed-wrap'; let existing = document.querySelectorAll(selector).length; if (existing === 0) { isDarkMode ? spawnFirefly() : spawnSeed(); existing++; } else if (existing < 5) { isDarkMode ? spawnFirefly() : spawnSeed(); } scheduleAmbientDrift(); }, delay); } function handleAmbientDrift() { stopAmbientDrift(); if (!animationsEnabled) return; if (isDarkMode) { spawnFirefly(); } else { spawnSeed(); } scheduleAmbientDrift(); } handleAmbientDrift(); const logoIcon = document.querySelector('.brand-icon'); if (logoIcon) { logoIcon.addEventListener('click', () => { if (!animationsEnabled || fireflyActive) return; if (isDarkMode) { spawnFirefly({ markActive: true, source: 'logo', side: 'any' }); } else { spawnSeed({ markActive: true, source: 'logo' }); } }); } if (colorOverlay) { colorOverlay.addEventListener('click', (e) => { if (e.target === colorOverlay) closeColorPicker(); }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && colorOverlay && colorOverlay.classList.contains('active')) { closeColorPicker(); } }); const importBtn = document.getElementById('importBtn'); if (importBtn) { importBtn.addEventListener('click', triggerImport); } const titleEl = document.getElementById('appTitle'); if (titleEl) { titleEl.addEventListener('click', () => { const now = Date.now(); titleClicks = titleClicks.filter(ts => now - ts < 7000); titleClicks.push(now); if (titleClicks.length >= 5 && !easterEggCooling) { easterEggCooling = true; triggerBurst(); setTimeout(() => { easterEggCooling = false; titleClicks = []; }, 8000); } }); } function triggerBurst() { if (!animationsEnabled) return; const burstCount = isDarkMode ? 24 : 18; const spawner = isDarkMode ? (opts) => spawnFirefly({ ...opts, side: 'any' }) : spawnSeed; for (let i = 0; i < burstCount; i++) { const jitter = Math.random() * 200; setTimeout(() => spawner({ source: 'burst' }), i * 140 + jitter); } } // --- Focus Mode Logic --- let wakeLock = null; let isFocusMode = false; const focusBtn = document.getElementById('focusBtn'); // --- Migration Check --- if (projects.length > 0) { let changed = false; projects.forEach((p, index) => { if (!p.parts) { p.parts = []; changed = true; } if (!p.color) { p.color = colors[index % colors.length]; changed = true; } const oldIdx = oldColors.indexOf(p.color); if (oldIdx !== -1) { p.color = colors[oldIdx % colors.length]; changed = true; } if (p.note === undefined) { p.note = ''; changed = true; } p.parts.forEach(pt => { if (pt.max === undefined) { pt.max = null; changed = true; } if (pt.note === undefined) { pt.note = ''; changed = true; } if (!pt.color) { pt.color = p.color; changed = true; } const oldPartIdx = oldColors.indexOf(pt.color); if (oldPartIdx !== -1) { pt.color = colors[oldPartIdx % colors.length]; changed = true; } }); }); if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); } } // --- Core Functions --- function save() { localStorage.setItem('crochetCounters', JSON.stringify(projects)); render(); } async function toggleFocusMode() { if (!isFocusMode) { try { if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen(); if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); } isFocusMode = true; focusBtn.classList.add('is-active'); } catch (err) { showAlert({ title: 'Focus Mode failed', text: err.message }); } } else { if (document.fullscreenElement) document.exitFullscreen(); if (wakeLock !== null) { wakeLock.release(); wakeLock = null; } isFocusMode = false; focusBtn.classList.remove('is-active'); } } document.addEventListener('visibilitychange', async () => { if (wakeLock !== null && document.visibilityState === 'visible') { wakeLock = await navigator.wakeLock.request('screen'); } }); // --- Interaction Logic --- async function deleteProject(pId) { const ok = await showConfirm({ title: 'Delete project?', text: 'This will remove the entire project.', confirmText: 'Delete', danger: true }); if (ok) { projects = projects.filter(p => p.id !== pId); save(); } } function toggleProjectCollapse(pId) { const project = projects.find(p => p.id === pId); project.collapsed = !project.collapsed; save(); } function renameProject(pId) { modalState = { type: 'renameProject', pId, partId: null }; const project = projects.find(p => p.id === pId); modalTitle.innerText = "Rename Project"; modalInput.value = project.name; modalInput.type = "text"; modalInput.placeholder = "Project name"; modal.classList.add('active'); setTimeout(() => modalInput.focus(), 100); } async function deletePart(pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); if (part.locked) return; const ok = await showConfirm({ title: 'Delete part?', text: 'This part will be removed.', confirmText: 'Delete', danger: true }); if (ok) { project.parts = project.parts.filter(pt => pt.id !== partId); save(); } } function togglePartMinimize(pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); part.minimized = !part.minimized; save(); } function togglePartLock(pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); part.locked = !part.locked; save(); } function setPartColor(pId, partId, color) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); part.color = color; save(); } function togglePartFinish(pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); part.finished = !part.finished; if(part.finished) { part.locked = false; lastFinishedId = part.id; } else { lastFinishedId = null; } save(); } function updateCount(pId, partId, change) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); if (part.locked || part.finished) return; part.count += change; if (part.max !== null && part.count > part.max) part.count = part.max; if (part.count < 0) part.count = 0; hapticTick(); lastCountPulse = { partId, dir: change > 0 ? 'up' : 'down' }; save(); } async function resetCount(pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); if (part.locked || part.finished) return; const ok = await showConfirm({ title: 'Reset count?', text: 'Set this count back to zero.', confirmText: 'Reset', danger: true }); if(ok) { part.count = 0; save(); } } function saveProjectAsPattern(pId) { const project = projects.find(p => p.id === pId); if (!project) return; openModal('savePattern', pId); } // --- Modal Logic --- function openModal(type, pId = null, partId = null) { modalState = { type, pId, partId }; modalInput.value = ""; if (type === 'addProject') { modalTitle.innerText = "New Project Name"; modalInput.type = "text"; modalInput.placeholder = "e.g., Amigurumi Bear"; if (patternPicker && patternSelect) { populatePatternSelect(); patternSelect.value = ''; } } else if (type === 'addPart') { modalTitle.innerText = "New Part Name"; modalInput.type = "text"; modalInput.placeholder = "e.g., Head"; } else if (type === 'setMax') { const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); modalTitle.innerText = "Set Max Stitches"; modalInput.value = part.max ?? ''; modalInput.type = "number"; modalInput.placeholder = "Leave blank to clear"; } else if (type === 'renamePart') { const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); if(part.locked || part.finished) return; modalTitle.innerText = "Rename Part"; modalInput.value = part.name; modalInput.type = "text"; } else if (type === 'savePattern') { modalTitle.innerText = "Save as Pattern"; const project = projects.find(p => p.id === pId); modalInput.value = project ? `${project.name} pattern` : ''; modalInput.type = "text"; modalInput.placeholder = "Pattern name"; } else if (type === 'manualCount') { const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); if(part.locked || part.finished) return; modalTitle.innerText = "Set Row Count"; modalInput.value = part.count; modalInput.type = "number"; } else if (type === 'setMax') { const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); if (part.locked) return; } if (type !== 'addProject' && patternPicker) { patternPicker.style.display = 'none'; } modal.classList.add('active'); setTimeout(() => modalInput.focus(), 100); } function closeModal() { modal.classList.remove('active'); modalInput.blur(); } function saveModal() { const val = modalInput.value.trim(); if (!val && modalState.type !== 'manualCount' && modalState.type !== 'setMax') return closeModal(); if (modalState.type === 'addProject') { const nextColor = colors[projects.length % colors.length]; const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [] }; const selectedPatternId = patternSelect ? patternSelect.value : ''; const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null; if (chosenPattern && Array.isArray(chosenPattern.parts) && chosenPattern.parts.length) { chosenPattern.parts.forEach((pt, idx) => { newProject.parts.push({ id: Date.now() + idx + 1, name: pt.name || `Part ${idx + 1}`, count: 0, locked: false, finished: false, minimized: false, max: pt.max ?? null, color: pt.color || newProject.color, note: '' }); }); } else { newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '' }); } projects.push(newProject); } else if (modalState.type === 'addPart') { const project = projects.find(p => p.id === modalState.pId); project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '' }); project.collapsed = false; } else if (modalState.type === 'renamePart') { const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); part.name = val; } else if (modalState.type === 'renameProject') { const project = projects.find(p => p.id === modalState.pId); project.name = val; } else if (modalState.type === 'manualCount') { const num = parseInt(val); if (!isNaN(num) && num >= 0) { const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); part.count = num; if (part.max !== null && part.count > part.max) part.count = part.max; } } else if (modalState.type === 'setMax') { const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); if (val === '') { part.max = null; } else { const num = parseInt(val); if (!isNaN(num) && num > 0) { part.max = num; if (part.count > part.max) part.count = part.max; } } } else if (modalState.type === 'savePattern') { const project = projects.find(p => p.id === modalState.pId); if (project) { const template = project.parts.map(pt => ({ name: pt.name, color: pt.color, max: pt.max })); patterns.push({ id: Date.now(), name: val || project.name, color: project.color, parts: template }); savePatterns(); populatePatternSelect(); } } save(); closeModal(); } modalInput.addEventListener("keyup", (e) => { if (e.key === "Enter") saveModal(); }); function toggleNote(id) { const el = document.getElementById(id); if (!el) return; el.classList.toggle('show'); } function updateProjectNote(e, pId) { const project = projects.find(p => p.id === pId); project.note = e.target.value; localStorage.setItem('crochetCounters', JSON.stringify(projects)); } function updatePartNote(e, pId, partId) { const project = projects.find(p => p.id === pId); const part = project.parts.find(pt => pt.id === partId); part.note = e.target.value; localStorage.setItem('crochetCounters', JSON.stringify(projects)); } // --- Render Logic --- function render() { app.innerHTML = ''; if (projects.length === 0) { app.innerHTML = '
Toadstools & twine await...
Tap + to begin a new project.
'; return; } const grid = document.createElement('div'); grid.className = 'projects-grid'; projects.forEach(project => { const sortedParts = [...project.parts].sort((a, b) => a.finished - b.finished); const projectCollapsedClass = project.collapsed ? 'project-collapsed' : ''; let partsHtml = ''; sortedParts.forEach(part => { const accent = part.color || project.color; const isLocked = part.locked ? 'is-locked' : ''; const isFinished = part.finished ? 'is-finished' : ''; const isMinimized = part.minimized ? 'is-minimized' : ''; const lockIcon = part.locked ? '' : ''; const lockBtnClass = part.locked ? 'btn-lock locked-active' : 'btn-lock'; const controlsDimmed = (part.locked || part.finished) ? 'dimmed' : ''; const hideControls = (part.finished || part.minimized) ? 'hidden-controls' : ''; const showSetMax = part.minimized ? 'hidden' : ''; const partNoteId = `part-note-${project.id}-${part.id}`; const countId = `count-${part.id}`; const pulseClass = lastCountPulse && lastCountPulse.partId === part.id ? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down') : ''; const finishPulseClass = part.finished && lastFinishedId === part.id ? 'finish-shimmer' : ''; const partCardId = `part-${part.id}`; const partCardFullClass = `${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass}`; const lockDisabled = part.locked ? 'disabled' : ''; const actionsHtml = part.minimized ? `
` : `
`; const countSubtext = part.minimized ? '' : `
${part.max !== null ? `${part.count} / ${part.max}` : 'No max set'}
`; partsHtml += `
${part.name} ${part.count}
${actionsHtml}
${part.count}
${countSubtext}
`; }); const projectContainer = document.createElement('div'); projectContainer.className = `project-container ${projectCollapsedClass}`; projectContainer.style = `--project-color: ${project.color}`; const projectNoteId = `project-note-${project.id}`; projectContainer.innerHTML = `
${project.name}
${partsHtml}
`; grid.appendChild(projectContainer); }); lastCountPulse = null; lastFinishedId = null; app.appendChild(grid); } render(); function flashSave() { const el = document.getElementById('patternSaveIndicator'); if (!el) return; el.textContent = 'Saved'; el.style.opacity = '1'; if (saveFlashTimer) clearTimeout(saveFlashTimer); saveFlashTimer = setTimeout(() => { el.style.opacity = '0.6'; }, 1200); }