1import { Controller } from '@hotwired/stimulus'; 2import jspreadsheet from 'jspreadsheet-ce'; 3 4// NOTE: no formula engine is wired. jspreadsheet-ce's built-in evaluator and the 5// @jspreadsheet/formula extension both compute via `new Function()`, which the 6// prod CSP (script-src without 'unsafe-eval') blocks â so we run with 7// parseFormulas:false (see _buildSpreadsheet). See EVO-BUG-6974. 8 9export default class extends Controller { 10 static targets = ['container', 'statusBar', 'sheetTabs']; 11 static values = { 12 sheets: { type: String, default: '[]' }, 13 saveUrl: String, 14 csrfToken: String, 15 readOnly: { type: Boolean, default: false }, 16 }; 17 18 connect() { 19 this._worksheets = []; 20 this._activeSheet = 0; 21 this._saveTimer = null; 22 this._dirty = false; 23 24 this._loadSheets(); 25 this._bindKeyboard(); 26 } 27 28 disconnect() { 29 document.removeEventListener('keydown', this._keyHandler); 30 clearTimeout(this._saveTimer); 31 if (this._worksheets.length > 0) { 32 try { jspreadsheet.destroy(this.containerTarget); } catch (_) {} 33 } 34 } 35 36 // ââ Init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 37 38 _loadSheets() { 39 let sheets = []; 40 try { 41 const parsed = JSON.parse(this.sheetsValue); 42 sheets = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.sheets) ? parsed.sheets : []); 43 } catch (_) {} 44 if (sheets.length === 0) { 45 sheets = [{ name: 'Sheet1', data: this._emptyData(20, 10) }]; 46 } 47 this._buildSpreadsheet(sheets); 48 } 49 50 _emptyData(rows, cols) { 51 return Array.from({ length: rows }, () => Array(cols).fill('')); 52 } 53 54 _buildSpreadsheet(sheets) { 55 if (!this.hasContainerTarget) return; 56 57 try { jspreadsheet.destroy(this.containerTarget); } catch (_) {} 58 this.containerTarget.innerHTML = ''; 59 60 const worksheetConfigs = sheets.map((sheet) => { 61 const data = Array.isArray(sheet.data) && sheet.data.length > 0 62 ? sheet.data 63 : this._emptyData(20, 10); 64 const config = { 65 data, 66 worksheetName: sheet.name || 'Sheet1', 67 minDimensions: [10, 20], 68 tableOverflow: true, 69 tableWidth: '100%', 70 tableHeight: this._sheetHeight(), 71 editable: !this.readOnlyValue, 72 columnResize: true, 73 rowResize: true, 74 allowComments: true, 75 search: false, 76 pagination: false, 77 }; 78 if (Array.isArray(sheet.columns) && sheet.columns.length > 0) { 79 config.columns = sheet.columns; 80 } 81 if (sheet.rows && Object.keys(sheet.rows).length > 0) { 82 config.rows = sheet.rows; 83 } 84 if (sheet.mergeCells && Object.keys(sheet.mergeCells).length > 0) { 85 config.mergeCells = sheet.mergeCells; 86 } 87 if (sheet.style && Object.keys(sheet.style).length > 0) { 88 config.style = sheet.style; 89 } 90 if (sheet.freezeColumns > 0) { 91 config.freezeColumns = sheet.freezeColumns; 92 } 93 return config; 94 }); 95 96 this._worksheets = jspreadsheet(this.containerTarget, { 97 worksheets: worksheetConfigs, 98 // Formula evaluation is OFF: jspreadsheet-ce's internal executor AND 99 // @jspreadsheet/formula both compute via `new Function()`, which our 100 // prod CSP (script-src without 'unsafe-eval') blocks â throwing an 101 // uncaught EvalError from createWorksheets on any '=...' cell and 102 // aborting the whole render (EVO-BUG-6974). With it off, '=' cells 103 // display their literal formula text instead of crashing the page. 104 parseFormulas: false, 105 toolbar: !this.readOnlyValue, 106 onchange: () => this._scheduleSave(), 107 oninsertrow: () => this._scheduleSave(), 108 ondeleterow: () => this._scheduleSave(), 109 oninsertcolumn: () => this._scheduleSave(), 110 ondeletecolumn: () => this._scheduleSave(), 111 onmoverow: () => this._scheduleSave(), 112 onmovecolumn: () => this._scheduleSave(), 113 onresizecolumn: () => this._scheduleSave(), 114 onresizerow: () => this._scheduleSave(), 115 oncomments: () => this._scheduleSave(), 116 }); 117 118 // Comments must be applied post-init via setComments (not an init-time option)
119 sheets.forEach((sheet, i) => { 120 if (sheet.comments && Object.keys(sheet.comments).length > 0) { 121 try { this._worksheets[i].setComments(sheet.comments); } catch (_) {} 122 } 123 }); 124 125 // jspreadsheet renders its own native worksheet tab strip (jsuites 126 // .jtabs-headers). We render a branded bar into the sheetTabs target, so 127 // hide the native one to avoid a confusing duplicate row of tabs. 128 this.containerTarget.querySelectorAll('.jtabs-headers').forEach(el => { el.style.display = 'none'; }); 129 130 this._syncTabBar(sheets); 131 } 132 133 _sheetHeight() { 134 return 'calc(100vh - 280px)'; 135 } 136 137 // ââ Tab bar âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 138 139 _syncTabBar(sheets) { 140 if (!this.hasSheetTabsTarget) return; 141 this.sheetTabsTarget.innerHTML = ''; 142 const multiSheet = sheets.length > 1; 143 sheets.forEach((s, i) => { 144 const btn = document.createElement('button'); 145 btn.type = 'button'; 146 btn.className = 'flex items-center gap-1 px-3 py-1 text-sm border-r border-base-300 hover:bg-base-200 transition-colors'; 147 btn.dataset.sheetIndex = i; 148 149 const label = document.createElement('span'); 150 label.textContent = s.name || `Sheet${i + 1}`; 151 btn.appendChild(label); 152 153 if (multiSheet && !this.readOnlyValue) { 154 const del = document.createElement('span'); 155 del.textContent = 'Ã'; 156 del.className = 'text-base-content/30 hover:text-error text-xs ml-0.5 leading-none'; 157 del.title = 'Delete sheet'; 158 del.addEventListener('click', e => { e.stopPropagation(); this._deleteSheet(i); }); 159 btn.appendChild(del); 160 } 161 162 btn.addEventListener('click', () => this._showSheet(i)); 163 if (!this.readOnlyValue) { 164 btn.addEventListener('dblclick', e => { e.stopPropagation(); this._startRenameTab(label, i); }); 165 } 166 this.sheetTabsTarget.appendChild(btn); 167 }); 168 this._highlightTab(this._activeSheet); 169 } 170 171 // ââ Sheet switching âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 172 173 _showSheet(index) { 174 this._activeSheet = index; 175 try { 176 const ws = this._worksheets[index]; 177 // openWorksheet is a WORKSHEET method (internally it calls 178 // this.parent.element.tabs.open). Calling it on ws.parent is 179 // undefined and throws â silently swallowed here â so the tab never 180 // switches. Call it on the worksheet instance itself. 181 if (ws && typeof ws.openWorksheet === 'function') { 182 ws.openWorksheet(index); 183 } 184 } catch (_) {} 185 this._highlightTab(index); 186 } 187 188 _highlightTab(index) { 189 if (!this.hasSheetTabsTarget) return; 190 this.sheetTabsTarget.querySelectorAll('button').forEach((btn, i) => { 191 btn.classList.toggle('bg-base-100', i === index); 192 btn.classList.toggle('font-medium', i === index); 193 btn.classList.toggle('border-b-2', i === index); 194 btn.classList.toggle('border-primary', i === index); 195 }); 196 } 197 198 // ââ Sheet rename (double-click tab label) âââââââââââââââââââââââââââââââââ 199 200 _startRenameTab(labelEl, index) { 201 const currentName = labelEl.textContent; 202 const input = document.createElement('input'); 203 input.type = 'text'; 204 input.value = currentName; 205 input.className = 'px-1 text-sm bg-base-100 border border-primary rounded outline-none w-24'; 206 labelEl.replaceWith(input); 207 input.focus(); 208 input.select(); 209 210 const commit = () => { 211 const newName = input.value.trim() || currentName; 212 this._renameSheet(index, newName); 213 }; 214 input.addEventListener('blur', commit); 215 input.addEventListener('keydown', e => { 216 if (e.key === 'Enter') { e.preventDefault(); input.blur(); } 217 if (e.key === 'Escape') { input.value = currentName; input.blur(); } 218 }); 219 } 220 221 _renameSheet(index, newName) { 222 const sheets = this._collectSheets(); 223 sheets[index].name = newName; 224 const active = this._activeSheet; 225 this._buildSpreadsheet(sheets); 226 this._showSheet(active); 227 this._scheduleSave(); 228 } 229 230 // ââ Sheet delete ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 231 232 async _deleteSheet(index) { 233 if (this._worksheets.length <= 1) return; 234 const sheetName = this._worksheets[index]?.options?.worksheetName || `Sheet${index + 1}`; 235 const confirmed = await (window.eiqConfirm?.(`Delete sheet "${sheetName}"?`) ?? Promise.resolve(true)); 236 if (!confirmed) return; 237 const sheets = this._collectSheets(); 238 sheets.splice(index, 1); 239 const newActive = Math.min(this._activeSheet, sheets.length - 1); 240 this._buildSpreadsheet(sheets); 241 this._activeSheet = newActive; 242 this._showSheet(newActive); 243 this._scheduleSave(); 244 } 245 246 // ââ Add sheet âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 247 248 addSheet() { 249 const existing = this._collectSheets(); 250 const name = `Sheet${existing.length + 1}`; 251 existing.push({ name, data: this._emptyData(20, 10) }); 252 this._buildSpreadsheet(existing); 253 this._showSheet(existing.length - 1); 254 this._scheduleSave(); 255 } 256 257 // ââ CSV download ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 258 259 downloadCsv() { 260 const ws = this._worksheets[this._activeSheet]; 261 if (ws?.download) { 262 ws.download(); 263 } 264 } 265 266 // ââ Persistence âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 267 268 _scheduleSave() { 269 this._dirty = true;
270 this._setStatus('Unsaved changesâ¦', 'warning'); 271 clearTimeout(this._saveTimer); 272 this._saveTimer = setTimeout(() => this.save(), 1500); 273 } 274 275 async save() { 276 if (!this.saveUrlValue) return; 277 clearTimeout(this._saveTimer); 278 279 const sheets = this._collectSheets(); 280 this._setStatus('Savingâ¦', 'info'); 281 282 try { 283 const resp = await fetch(this.saveUrlValue, { 284 method: 'POST', 285 headers: { 286 'Content-Type': 'application/json', 287 'X-Requested-With': 'XMLHttpRequest', 288 }, 289 body: JSON.stringify({ 290 _token: this.csrfTokenValue, 291 content: JSON.stringify({ sheets }), 292 }), 293 }); 294 295 if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 296 this._dirty = false; 297 this._setStatus('Saved', 'success'); 298 setTimeout(() => this._setStatus('', ''), 2000); 299 } catch (e) { 300 console.error('[SpreadsheetEditor] save failed', e); 301 this._setStatus('Save failed', 'error'); 302 } 303 } 304 305 _collectSheets() { 306 return this._worksheets.map((ws, i) => { 307 const sheet = { 308 name: ws.options?.worksheetName || `Sheet${i + 1}`, 309 data: ws.getData ? ws.getData() : [], 310 }; 311 // Column widths 312 if (Array.isArray(ws.options?.columns)) { 313 sheet.columns = ws.options.columns.map(c => ({ width: c.width ?? null })); 314 } 315 // Row heights 316 if (ws.options?.rows && Object.keys(ws.options.rows).length > 0) { 317 sheet.rows = ws.options.rows; 318 } 319 // Merged cells 320 if (ws.options?.mergeCells && Object.keys(ws.options.mergeCells).length > 0) { 321 sheet.mergeCells = ws.options.mergeCells; 322 } 323 // Cell styles 324 if (typeof ws.getStyle === 'function') { 325 const style = ws.getStyle(); 326 if (style && Object.keys(style).length > 0) { 327 sheet.style = style; 328 } 329 } 330 // Freeze columns 331 if (ws.options?.freezeColumns > 0) { 332 sheet.freezeColumns = ws.options.freezeColumns; 333 } 334 // Cell comments 335 if (typeof ws.getComments === 'function') { 336 const comments = ws.getComments(); 337 if (comments && Object.keys(comments).length > 0) { 338 sheet.comments = comments; 339 } 340 } 341 return sheet; 342 }); 343 } 344 345 _setStatus(msg, level) { 346 if (!this.hasStatusBarTarget) return; 347 const map = { success: 'text-success', warning: 'text-warning', error: 'text-error', info: 'text-base-content/60', '': '' }; 348 this.statusBarTarget.textContent = msg; 349 this.statusBarTarget.className = `text-xs px-3 ${map[level] ?? ''}`; 350 } 351 352 // ââ Keyboard shortcuts ââââââââââââââââââââââââââââââââââââââââââââââââââââ 353 354 _bindKeyboard() { 355 this._keyHandler = (e) => { 356 if ((e.ctrlKey || e.metaKey) && e.key === 's') { 357 e.preventDefault(); 358 this.save(); 359 } 360 }; 361 document.addEventListener('keydown', this._keyHandler); 362 } 363}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.