//============================================================================= // TLInspector.js // Idea by Sakura · Plugin by Kao_SSS //============================================================================= /*: * @plugindesc Translation Source Inspector - shows which source file/line every * on-screen line of text and image comes from, and jumps to it in VSCode. * @author Kao_SSS * * @help * ---------------------------------------------------------------------------- * TLInspector (RPGMaker MV / MZ, NW.js) * Credits: Idea by Sakura · Plugin by Kao_SSS * ---------------------------------------------------------------------------- * A drop-in proofreading aid for translators. While the game runs, it tracks * every message / choice / scrolling-text line and every on-screen image, and * maps each one back to its EXACT source file and line number: * * - Dialogue / choices / scroll text -> www/data/MapXXX.json, * CommonEvents.json, Troops.json (by identity-matching the running event * list, so duplicate strings are never confused). * - Scenario-plugin dialogue -> scenario/Scenario.json and similar * (plugin scenario engines such as Nore/"Tes" that keep command-lists in one * big JSON dictionary keyed by scene name, loaded into $dataScenario). Both * in-game location and "Locate in Files" cover these. * - Pictures and other images -> img/... path + the event command * (or JS call stack) that loaded them. * - Plugin / menu UI text (Bitmap.drawText / drawTextEx) -> where the VALUE * itself lives whenever it can be resolved: a TextManager vocab assignment * in a plugin .js, a System.json term, a database name/description, a * notetag , or a plugins.js parameter. Strings built with * String.prototype.format ("%1 remaining") are traced back to their * template. The draw call site is kept as secondary info. * * Press the hotkey (default F10) to open the inspector overlay. Each row has an * "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to * change the text and save it back to the source file without leaving the game. * * Live refresh (NW.js playtest only): * In-game overlay "save to file" reloads that JSON into memory immediately. * VSCode / external saves: we watch data/*.json; once the file mtime is stable * (save finished), reload instantly. The game cannot hook Ctrl+S in VSCode itself. * * Limitations: * - NW.js desktop playtest only (not browser) * - Text already shown by a running event won't update until re-triggered * (in-game overlay edit still updates the current message box) * - Plugin .js / plugins.js changes still need F5 * * ---------------------------------------------------------------------------- * Install: drop this file in www/js/plugins/ and add one line to plugins.js: * { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} } * Place it LAST in the list. Remove that line to fully disable for a release. * * Config: edit the CFG object a few lines below. * ---------------------------------------------------------------------------- */ (function () { 'use strict'; //========================================================================= // Config //========================================================================= var CFG = { enabled: true, hotkey: 'F9', // key (event.key) that toggles the overlay editorCmd: 'auto', // 'auto' = auto-detect installed editors (VS Code, Cursor, // VSCodium, Insiders, Windsurf). Or set an absolute path to // force a specific editor exe. editor: 'auto', // which editor click-to-source opens files in: // 'auto' = VSCode if it's installed, else the built-in // in-game editor (also the fallback if a // VSCode launch fails). // 'builtin' = always the built-in in-game editor (view & // edit the source file right inside the game). // 'vscode' = always VSCode (uses editorCmd above). workspaceFolder: 'auto',// 'auto' = open files inside the game ROOT folder as the // VSCode workspace (so they don't land in whatever window // happened to be open last). Or set an absolute folder. editorReuseWindow: true,// pass -r so VSCode reuses its current window historySize: 80, // how many past text lines to keep captureUiText: true, // capture plugin / menu / UI text drawn via Bitmap.drawText // (so e.g. status-window strings from plugins.js are locatable) dataDirOverride: null, // set an absolute path to force the data dir uiScale: 'auto' // overlay scale: 'auto' from game width, or a number (1.5, 2, …) }; if (!CFG.enabled) { return; } function resolveUiScale(v) { if (v !== 'auto' && v != null && String(v).trim() !== '') { var n = parseFloat(v); if (!isNaN(n) && n > 0) { return Math.max(0.75, Math.min(3, n)); } } var base = 816; var viewW = window.innerWidth || document.documentElement.clientWidth || base; // The overlay lives in WINDOW pixels, so scale from the window size only. // Using the game's internal resolution made a 1920px-wide game in a ~1366px // window zoom 2.35x, with the 460px panel swallowing >80% of the screen. var scale = viewW / base; var dpr = window.devicePixelRatio || 1; if (dpr > 1.15) { scale *= Math.min(2, 0.75 + dpr * 0.35); } // Whatever the math says, keep the panel under ~45% of the window // (the >=1 floor below still wins for small windows). scale = Math.min(scale, (viewW * 0.45) / 460); return Math.max(1, Math.min(2.75, scale)); } function currentUiScale() { return resolveUiScale(CFG.uiScale); } function applyOverlayScale() { var s = currentUiScale(); var z = (s === 1) ? '' : String(s); if (ui.root) { ui.root.style.zoom = z; ui.root.style.transformOrigin = (ui.side === 'left') ? 'top left' : 'top right'; } if (editor.root) { editor.root.style.zoom = z; editor.root.style.transformOrigin = 'top center'; } if (ui.pickLabel) { ui.pickLabel.style.zoom = z; } } //========================================================================= // Node / environment bootstrap (NW.js exposes require) //========================================================================= var nodeOk = false, fs = null, path = null, cp = null; try { if (typeof require === 'function') { fs = require('fs'); path = require('path'); cp = require('child_process'); nodeOk = true; } } catch (e) { nodeOk = false; } var ENGINE = (typeof Utils !== 'undefined' && Utils.RPGMAKER_NAME) || 'MV'; // Resolve the www root (where index.html lives) -> data dir. // Primary method = exactly how the engine itself resolves paths // (StorageManager.localFileDirectoryPath uses process.mainModule.filename), // which is reliable regardless of the page URL scheme NW.js uses. function resolveWwwRoot() { try { if (nodeOk && process.mainModule && process.mainModule.filename) { return path.dirname(process.mainModule.filename); } } catch (e) { /* fall through */ } try { var href = window.location.href; // file:///C:/.../www/index.html var p = decodeURIComponent(new URL(href).pathname); if (/^\/[A-Za-z]:\//.test(p)) { p = p.slice(1); } // strip leading slash on Windows return path ? path.dirname(p) : p.replace(/\/[^\/]*$/, ''); } catch (e2) { return nodeOk ? process.cwd() : ''; } } var WWW_ROOT = resolveWwwRoot(); var DATA_DIR = CFG.dataDirOverride || (path ? path.join(WWW_ROOT, 'data') : WWW_ROOT + '/data'); // The game root = folder holding the .exe. On MV that's the PARENT of www/; on MZ // there is no www/, so it IS the resolved root. Used as the VSCode workspace folder // so opened files land in the game's own project window, not the last-used one. var GAME_ROOT = (function () { try { if (path && WWW_ROOT && path.basename(WWW_ROOT).toLowerCase() === 'www') { return path.dirname(WWW_ROOT); } } catch (e) { /* ignore */ } return WWW_ROOT; })(); function mapFile(mapId) { var n = ('000' + mapId).slice(-3); return path ? path.join(DATA_DIR, 'Map' + n + '.json') : DATA_DIR + '/Map' + n + '.json'; } function dataFile(name) { return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name; } // Scenario systems (e.g. the Nore / "Tes" engine) keep event command-lists OUTSIDE // Map/CommonEvents/Troops: one big JSON dictionary under scenario/, keyed by scene // name, loaded into a global ($dataScenario) and run via Game_Interpreter.setupChild. // Each value is a plain MZ command list, so once we know the file + key it resolves // to a line just like any other data file. var SCENARIO_DIR = 'scenario'; var SCENARIO_SETS = [ { global: '$dataScenario', file: 'Scenario.json' }, { global: '$dataScenario_en', file: 'Scenario_en.json' } ]; function scenarioPath(fileName) { return path ? path.join(WWW_ROOT, SCENARIO_DIR, fileName) : WWW_ROOT + '/' + SCENARIO_DIR + '/' + fileName; } // Every *.json actually present in the scenario/ folder (empty for normal games). function scenarioFiles() { var out = []; if (!nodeOk) { return out; } try { fs.readdirSync(scenarioPath('')).forEach(function (f) { if (/\.json$/i.test(f)) { out.push(scenarioPath(f)); } }); } catch (e) { /* no scenario/ folder -> normal for most games */ } return out; } // If a scenario file's dictionary is already parsed into a known global, return the // global name so callers can reuse it instead of re-parsing a (often huge) file. function scenarioGlobalForFile(file) { var base = baseName(file).toLowerCase(); for (var i = 0; i < SCENARIO_SETS.length; i++) { if (SCENARIO_SETS[i].file.toLowerCase() === base) { return SCENARIO_SETS[i].global; } } return null; } function log() { if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); } } log('init', { engine: ENGINE, node: nodeOk, dataDir: DATA_DIR, credits: 'Idea by Sakura · Plugin by Kao_SSS' }); //========================================================================= // File cache + JSON value locator (path -> char offset -> line:col) //========================================================================= var fileCache = {}; // path -> { mtime, text, lineStarts } function readFileCached(file) { if (!nodeOk) { return null; } try { var st = fs.statSync(file); var mt = st.mtimeMs; var c = fileCache[file]; if (c && c.mtime === mt) { return c; } var text = fs.readFileSync(file, 'utf8'); var lineStarts = [0]; for (var i = 0; i < text.length; i++) { if (text.charCodeAt(i) === 10) { lineStarts.push(i + 1); } } c = { mtime: mt, text: text, lineStarts: lineStarts }; fileCache[file] = c; return c; } catch (e) { return null; } } function offsetToLineCol(c, off) { var ls = c.lineStarts, lo = 0, hi = ls.length - 1, mid; while (lo < hi) { mid = (lo + hi + 1) >> 1; if (ls[mid] <= off) { lo = mid; } else { hi = mid - 1; } } return { line: lo + 1, col: off - ls[lo] + 1 }; } // --- minimal structural JSON scanner ------------------------------------ function skipWs(t, i) { while (i < t.length) { var ch = t.charCodeAt(i); if (ch === 32 || ch === 9 || ch === 10 || ch === 13) { i++; } else { break; } } return i; } function scanString(t, i) { // i at opening quote -> returns index after closing quote i++; // past " while (i < t.length) { var ch = t[i]; if (ch === '\\') { i += 2; continue; } if (ch === '"') { return i + 1; } i++; } return i; } function readKey(t, i) { // i at opening quote -> { end, value } var start = i + 1, j = i + 1, out = ''; while (j < t.length) { var ch = t[j]; if (ch === '\\') { out += t[j + 1]; j += 2; continue; } if (ch === '"') { return { end: j + 1, value: out }; } out += ch; j++; } return { end: j, value: out }; } function skipValue(t, i) { i = skipWs(t, i); var ch = t[i]; if (ch === '"') { return scanString(t, i); } if (ch === '{' || ch === '[') { var open = ch, close = ch === '{' ? '}' : ']', depth = 0; for (; i < t.length; i++) { var c = t[i]; if (c === '"') { i = scanString(t, i) - 1; continue; } if (c === open) { depth++; } else if (c === close) { depth--; if (depth === 0) { return i + 1; } } } return i; } // number / true / false / null while (i < t.length && ',}] \t\r\n'.indexOf(t[i]) === -1) { i++; } return i; } // Walk `path` (array of string keys / number indices) -> start offset of value, or -1. function locateOffset(t, pathArr) { var i = skipWs(t, 0); for (var s = 0; s < pathArr.length; s++) { var step = pathArr[s]; i = skipWs(t, i); if (typeof step === 'number') { if (t[i] !== '[') { return -1; } i = skipWs(t, i + 1); if (t[i] === ']') { return -1; } for (var k = 0; k < step; k++) { i = skipValue(t, i); i = skipWs(t, i); if (t[i] !== ',') { return -1; } i = skipWs(t, i + 1); } // i now at target element value start } else { if (t[i] !== '{') { return -1; } i = skipWs(t, i + 1); var found = false; while (i < t.length && t[i] !== '}') { if (t[i] !== '"') { return -1; } var key = readKey(t, i); i = skipWs(t, key.end); if (t[i] !== ':') { return -1; } i = skipWs(t, i + 1); if (key.value === step) { found = true; break; } i = skipValue(t, i); i = skipWs(t, i); if (t[i] === ',') { i = skipWs(t, i + 1); } } if (!found) { return -1; } } } return skipWs(t, i); } // File drifted under a structural path (e.g. the scenario JSON was re-exported while // the game held an older parse, shifting command indices): find the exact quoted // value instead, preferring the occurrence nearest the path's top-level container so // duplicate lines in other scenes/events don't win. function reanchorByText(c, pathArr, quoted) { var hits = [], p = c.text.indexOf(quoted), CAP = 5000; while (p >= 0 && hits.length < CAP) { hits.push(p); p = c.text.indexOf(quoted, p + quoted.length); } if (!hits.length) { return null; } if (hits.length === 1) { return offsetToLineCol(c, hits[0]); } var anchor = locateOffset(c.text, pathArr.slice(0, 1)); // start of the scene / list var best = hits[0]; if (anchor >= 0) { var bd = Math.abs(hits[0] - anchor); for (var k = 1; k < hits.length; k++) { var dd = Math.abs(hits[k] - anchor); if (dd < bd) { bd = dd; best = hits[k]; } } } return offsetToLineCol(c, best); } var lineCache = {}; // file::pathKey::mtime -> {line,col} function locateLine(file, pathArr, expectedText) { if (!file || !pathArr || !nodeOk) { return null; } var c = readFileCached(file); if (!c) { return null; } var key = file + '::' + pathArr.join('/') + '::' + c.mtime; if (lineCache.hasOwnProperty(key)) { return lineCache[key]; } var off = locateOffset(c.text, pathArr); var quoted = (expectedText != null && expectedText !== '') ? JSON.stringify(String(expectedText)) : null; var res; if (off >= 0 && (!quoted || c.text.substr(off, quoted.length) === quoted)) { res = offsetToLineCol(c, off); // path still valid (verified against the text) } else if (quoted) { // Path drifted (or the text isn't where the path points) -> re-anchor by exact // text; if that finds nothing, still fall back to the structural offset. res = reanchorByText(c, pathArr, quoted) || (off >= 0 ? offsetToLineCol(c, off) : null); } else { res = off >= 0 ? offsetToLineCol(c, off) : null; } lineCache[key] = res; return res; } //========================================================================= // Provenance: map a running event command-list to its source file + path //========================================================================= function resolveList(list) { try { if (typeof $dataMap !== 'undefined' && $dataMap && $dataMap.events && typeof $gameMap !== 'undefined' && $gameMap) { var evs = $dataMap.events; for (var e = 0; e < evs.length; e++) { var ev = evs[e]; if (!ev || !ev.pages) { continue; } for (var p = 0; p < ev.pages.length; p++) { if (ev.pages[p].list === list) { return { file: mapFile($gameMap.mapId()), prefix: ['events', e, 'pages', p, 'list'], label: 'Map' + ('000' + $gameMap.mapId()).slice(-3) + ' ev' + e }; } } } } if (typeof $dataCommonEvents !== 'undefined' && $dataCommonEvents) { for (var c = 0; c < $dataCommonEvents.length; c++) { var ce = $dataCommonEvents[c]; if (ce && ce.list === list) { return { file: dataFile('CommonEvents.json'), prefix: [c, 'list'], label: 'CommonEvent ' + c }; } } } if (typeof $gameTroop !== 'undefined' && $gameTroop && $gameTroop._troopId && typeof $dataTroops !== 'undefined' && $dataTroops) { var tr = $dataTroops[$gameTroop._troopId]; if (tr && tr.pages) { for (var tp = 0; tp < tr.pages.length; tp++) { if (tr.pages[tp].list === list) { return { file: dataFile('Troops.json'), prefix: [$gameTroop._troopId, 'pages', tp, 'list'], label: 'Troop ' + $gameTroop._troopId }; } } } } // Scenario dictionaries: the running list is a value of $dataScenario keyed // by scene name (setupChild runs the list by reference, so identity holds). for (var si = 0; si < SCENARIO_SETS.length; si++) { var sg = SCENARIO_SETS[si], dict = window[sg.global]; if (!dict || typeof dict !== 'object') { continue; } for (var sk in dict) { if (dict.hasOwnProperty(sk) && dict[sk] === list) { return { file: scenarioPath(sg.file), prefix: [sk], label: sg.file.replace(/\.json$/i, '') + ':' + sk }; } } } } catch (e) { log('resolveList error', e); } return null; } function pathToString(arr) { var s = ''; for (var i = 0; i < arr.length; i++) { s += typeof arr[i] === 'number' ? '[' + arr[i] + ']' : (i ? '.' : '') + arr[i]; } return s; } //========================================================================= // Record store //========================================================================= var groupSeq = 0; var currentGroup = -1; var textRecords = []; // {kind,text,file,prefix,tail,group,ts,_key} var imageTriggers = {}; // picture filename -> {file,prefix,tail,label} var BUMP_GUARD_MS = 1000; function findByKey(key) { for (var i = 0; i < textRecords.length; i++) { if (textRecords[i]._key === key) { return i; } } return -1; } // Text seen again -> move it to the top (newest) so it never feels "undetected" // when it was buried in history. Guard against churn from per-frame UI redraws. function seenExisting(i, group) { var r = textRecords[i], now = Date.now(); if (now - r.ts < BUMP_GUARD_MS) { r.ts = now; if (group != null && group !== -1) { r.group = group; } return; // recently seen: keep its position } textRecords.splice(i, 1); r.ts = now; if (group != null && group !== -1) { r.group = group; } textRecords.push(r); scheduleRefresh(); } function pushText(kind, prov, tail, text, group, srcIndex, srcEventId) { if (text == null || text === '') { return; } var file = prov ? prov.file : null; var key = 't\u0000' + kind + '\u0000' + text + '\u0000' + (file || '') + '\u0000' + (tail ? JSON.stringify(tail) : ''); var i = findByKey(key); if (i >= 0) { seenExisting(i, group); return; } textRecords.push({ kind: kind, text: text, file: file, prefix: prov ? prov.prefix : null, tail: tail, label: prov ? prov.label : '(unresolved)', group: group, ts: Date.now(), srcIndex: (srcIndex == null ? null : srcIndex), srcEventId: (srcEventId == null ? 0 : srcEventId), _key: key }); while (textRecords.length > CFG.historySize) { textRecords.shift(); } scheduleRefresh(); } function captureFromList(list, startIdx, textCode, eventId) { var prov = resolveList(list); var group = ++groupSeq; currentGroup = group; var i = startIdx + 1; while (i < list.length && list[i] && list[i].code === textCode) { pushText('text', prov, prov ? prov.prefix.concat([i, 'parameters', 0]) : null, list[i].parameters[0], group, i, eventId); i++; } // inline "Show Choices" immediately following the text block if (list[i] && list[i].code === 102) { captureChoices(list, i, prov, group, eventId); } } function captureChoices(list, idx, prov, group, eventId) { if (prov === undefined) { prov = resolveList(list); } if (group === undefined) { group = ++groupSeq; currentGroup = group; } var choices = list[idx] && list[idx].parameters && list[idx].parameters[0]; if (!Array.isArray(choices)) { return; } for (var n = 0; n < choices.length; n++) { pushText('choice', prov, prov ? prov.prefix.concat([idx, 'parameters', 0, n]) : null, choices[n], group, idx, eventId); } } //========================================================================= // Hooks: text //========================================================================= if (typeof Game_Interpreter !== 'undefined') { var GI = Game_Interpreter.prototype; var _c101 = GI.command101; GI.command101 = function () { var list = this._list, start = this._index; var r = _c101.apply(this, arguments); try { if (this._index !== start) { captureFromList(list, start, 401, this._eventId); } } catch (e) { log('c101', e); } return r; }; var _c105 = GI.command105; GI.command105 = function () { var list = this._list, start = this._index; var r = _c105.apply(this, arguments); try { if (this._index !== start) { captureFromList(list, start, 405, this._eventId); } } catch (e) { log('c105', e); } return r; }; var _c102 = GI.command102; GI.command102 = function () { // A STANDALONE "Show Choices" (not following a message — those are consumed // inside command101) doesn't advance this._index within the method: MV bumps // it later in executeCommand, MZ takes params as an arg. So gating on an index // change missed standalone choice menus entirely. Instead capture exactly when // the choices get set up: this call, while the message box wasn't already busy. var list = this._list, idx = this._index; var wasBusy = (typeof $gameMessage !== 'undefined') && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy(); var r = _c102.apply(this, arguments); try { if (!wasBusy && list && list[idx] && list[idx].code === 102) { captureChoices(list, idx, undefined, undefined, this._eventId); } } catch (e) { log('c102', e); } return r; }; // Show Picture -> remember which command triggered each picture file var _c231 = GI.command231; GI.command231 = function () { try { var list = this._list, idx = this._index; var name = this._params && this._params[1]; if (name) { var prov = resolveList(list); imageTriggers['img/pictures/' + name + '.png'] = { file: prov ? prov.file : null, path: prov ? prov.prefix.concat([idx, 'parameters', 1]) : null, label: prov ? prov.label : '(unresolved)' }; } } catch (e) { log('c231', e); } return _c231.apply(this, arguments); }; } // Clear "live" marker when the message box closes if (typeof Game_Message !== 'undefined') { var _clear = Game_Message.prototype.clear; Game_Message.prototype.clear = function () { currentGroup = -1; scheduleRefresh(); return _clear.apply(this, arguments); }; } // Capture plugin / menu / UI text. The true chokepoint is Bitmap.drawText: // Window_Base.drawText/drawTextEx AND direct `this.contents.drawText(...)` calls // inside plugins all funnel through it. We skip while a message box is showing // (that text is captured precisely via the interpreter hooks) and de-duplicate by // a digit-normalized key so a ticking value ("Money: 12"/"Money: 13") logs once. if (CFG.captureUiText && typeof Bitmap !== 'undefined' && Bitmap.prototype.drawText) { // Direct draws (Window_Base.drawText, plugin `this.contents.drawText(...)`). // drawTextEx renders one character per call here, so single chars are dropped. var _bmDrawText = Bitmap.prototype.drawText; Bitmap.prototype.drawText = function (text) { try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } return _bmDrawText.apply(this, arguments); }; } if (CFG.captureUiText && typeof Window_Base !== 'undefined' && Window_Base.prototype.drawTextEx) { // Escape-processed text (item descriptions, help text, names) arrives here as a // whole string before being drawn character-by-character. var _drawTextEx = Window_Base.prototype.drawTextEx; Window_Base.prototype.drawTextEx = function (text) { try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } return _drawTextEx.apply(this, arguments); }; } function maybeCaptureUiText(text) { if (typeof text !== 'string') { return; } if (text.replace(/\s/g, '').length < 2) { return; } // drop single chars (per-char draws) if (!/[^\s\d.,:%/+\-()]/.test(text)) { return; } // require a real letter if (typeof $gameMessage !== 'undefined' && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy()) { return; } var key = 'ui ' + text.replace(/\d+/g, '#'); // digit-normalized: "Money: 12/13" -> one entry var i = findByKey(key); if (i >= 0) { seenExisting(i, -1); return; } var site = firstUserFrame(new Error().stack); var rec = { kind: 'ui', text: text, file: site.file, prefix: null, tail: null, label: site.label, uiLine: site.line, uiCol: null, group: -1, ts: Date.now(), _key: key }; // Prefer the VALUE's own home (vocab assignment / term / notetag / parameter) // over the draw call site; keep the site as secondary info. try { var src = resolveUiSource(text); if (src) { rec.srcKind = src.kind; rec.label = src.label; rec.expText = src.expText; if (src.file) { rec.file = src.file; rec.uiLine = src.line || null; rec.uiCol = src.col || null; rec.tail = src.tail || null; rec.siteFile = site.file; rec.siteLine = site.line; rec.siteLabel = site.label; } } } catch (e) { log('uiSource', e); } textRecords.push(rec); while (textRecords.length > CFG.historySize) { textRecords.shift(); } scheduleRefresh(); } function firstUserFrame(stack) { var lines = (stack || '').split('\n'); for (var i = 1; i < lines.length; i++) { var m = lines[i].match(/(js\/plugins\/[^):]+|js\/[^):]+):(\d+):(\d+)/); if (m && m[1].indexOf('TLInspector') === -1) { var f = m[1]; return { file: path ? path.join(WWW_ROOT, f) : WWW_ROOT + '/' + f, line: parseInt(m[2], 10), label: f + ':' + m[2] }; } } return { file: null, line: null, label: '(unknown)' }; } //========================================================================= // UI text provenance: map a drawn string back to the VALUE's own home // (TextManager vocab assignment, System.json term, database field, notetag // meta value, or plugins.js parameter) instead of just the draw call site. // Index is built lazily from live game state; invalidated on F9 reload. //========================================================================= var uiValueIndex = null, uiIndexBuiltAt = 0; function uiIndexAdd(map, value, cand) { if (typeof value !== 'string') { return; } if (value.replace(/\s/g, '').length < 2) { return; } // same floor as capture (map[value] || (map[value] = [])).push(cand); } function buildUiValueIndex() { // null prototype: game text can legitimately be 'constructor'/'toString'… var map = Object.create(null); // TextManager vocab: plugin-assigned own DATA properties. (The engine's own // terms are accessor properties reading $dataSystem.terms -> indexed below.) try { if (typeof TextManager !== 'undefined') { Object.getOwnPropertyNames(TextManager).forEach(function (p) { var d = Object.getOwnPropertyDescriptor(TextManager, p); if (d && !d.get && typeof d.value === 'string') { uiIndexAdd(map, d.value, { t: 'tm', prop: p }); } }); } } catch (e) { log('uiIndex tm', e); } // System.json: terms, type-name lists, and a few top-level strings. try { if (typeof $dataSystem !== 'undefined' && $dataSystem) { var sysFile = dataFile('System.json'); var terms = $dataSystem.terms || {}; ['basic', 'commands', 'params'].forEach(function (g) { (terms[g] || []).forEach(function (v, i) { uiIndexAdd(map, v, { t: 'json', file: sysFile, path: ['terms', g, i], label: 'System terms.' + g + '[' + i + ']' }); }); }); var msgs = terms.messages || {}; Object.keys(msgs).forEach(function (k) { uiIndexAdd(map, msgs[k], { t: 'json', file: sysFile, path: ['terms', 'messages', k], label: 'System terms.messages.' + k }); }); ['elements', 'skillTypes', 'weaponTypes', 'armorTypes', 'equipTypes'].forEach(function (g) { ($dataSystem[g] || []).forEach(function (v, i) { uiIndexAdd(map, v, { t: 'json', file: sysFile, path: [g, i], label: 'System ' + g + '[' + i + ']' }); }); }); uiIndexAdd(map, $dataSystem.gameTitle, { t: 'json', file: sysFile, path: ['gameTitle'], label: 'System gameTitle' }); uiIndexAdd(map, $dataSystem.currencyUnit, { t: 'json', file: sysFile, path: ['currencyUnit'], label: 'System currencyUnit' }); } } catch (e) { log('uiIndex system', e); } // Database display fields + notetag meta values. A meta value resolves to the // object's "note" line; locateMetaValue narrows to the value inside the tag. [ ['$dataActors', 'Actors.json', ['name', 'nickname', 'profile']], ['$dataClasses', 'Classes.json', ['name']], ['$dataSkills', 'Skills.json', ['name', 'description', 'message1', 'message2']], ['$dataItems', 'Items.json', ['name', 'description']], ['$dataWeapons', 'Weapons.json', ['name', 'description']], ['$dataArmors', 'Armors.json', ['name', 'description']], ['$dataEnemies', 'Enemies.json', ['name']], ['$dataStates', 'States.json', ['name', 'message1', 'message2', 'message3', 'message4']] ].forEach(function (spec) { try { var arr = window[spec[0]]; if (!Array.isArray(arr)) { return; } var file = dataFile(spec[1]), short = spec[1].replace(/\.json$/i, ''); for (var id = 1; id < arr.length; id++) { var o = arr[id]; if (!o) { continue; } spec[2].forEach(function (f) { uiIndexAdd(map, o[f], { t: 'json', file: file, path: [id, f], label: short + '[' + id + '].' + f }); }); if (o.meta) { Object.keys(o.meta).forEach(function (mk) { if (typeof o.meta[mk] === 'string') { uiIndexAdd(map, o.meta[mk], { t: 'meta', file: file, path: [id, 'note'], label: short + '[' + id + '] note <' + mk + ':…>' }); } }); } } } catch (e) { log('uiIndex ' + spec[0], e); } }); // Plugin parameters (plugins.js). try { if (window.$plugins) { window.$plugins.forEach(function (pl) { if (!pl || pl.status === false || !pl.parameters) { return; } Object.keys(pl.parameters).forEach(function (pk) { uiIndexAdd(map, pl.parameters[pk], { t: 'param', plugin: pl.name, param: pk }); }); }); } } catch (e) { log('uiIndex params', e); } return map; } function uiValueCandidates(text) { if (!uiValueIndex) { uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now(); } if (!jsLitIndex) { jsLitIndex = buildJsLitIndex(); } var c = uiValueIndex[text] || null; var lits = jsLitIndex[text] || null; if (!c && !lits && Date.now() - uiIndexBuiltAt > 5000) { // Vocab tables can be rewritten at runtime (language toggles): on a miss, // rebuild against current state, at most once every 5s. uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now(); c = uiValueIndex[text] || null; } if (lits) { c = (c || []).concat(lits); } return c; } // Parse a JS string literal starting at t[i] (the quote) -> {value, end} or null. function parseJsString(t, i) { var q = t[i]; if (q !== '"' && q !== "'" && q !== '`') { return null; } var out = '', j = i + 1; while (j < t.length) { var ch = t[j]; if (ch === '\\') { var n = t[j + 1]; if (n === 'n') { out += '\n'; } else if (n === 't') { out += '\t'; } else if (n === 'r') { out += '\r'; } else if (n === 'u') { out += String.fromCharCode(parseInt(t.substr(j + 2, 4), 16) || 0); j += 4; } else if (n === 'x') { out += String.fromCharCode(parseInt(t.substr(j + 2, 2), 16) || 0); j += 2; } else { out += n; } j += 2; continue; } if (ch === q) { return { value: out, end: j + 1 }; } if (ch === '\n' && q !== '`') { return null; } // unterminated out += ch; j++; } return null; } // Plugin .js files in LOAD order (later plugins overwrite earlier assignments, so // "last literal match" below = the assignment that actually won at runtime). function pluginFilesInLoadOrder() { var out = []; if (!nodeOk) { return out; } var pdir = path.join(WWW_ROOT, 'js', 'plugins'); try { if (window.$plugins) { window.$plugins.forEach(function (pl) { if (pl && pl.status !== false && pl.name && pl.name !== 'TLInspector') { out.push(path.join(pdir, pl.name + '.js')); } }); } } catch (e) { /* ignore */ } if (!out.length) { try { fs.readdirSync(pdir).forEach(function (f) { if (/\.js$/.test(f) && f !== 'TLInspector.js') { out.push(path.join(pdir, f)); } }); } catch (e) { /* ignore */ } } return out; } // Find the plugin-file assignment `TextManager. = ''` whose literal // equals the property's CURRENT runtime value — this is what distinguishes an // English vocab file from the Japanese one when both assign the same property. // Falls back to the last assignment site in load order (value built at runtime). var tmAssignCache = {}; // prop \0 value -> {file,line,col,exact} | null function locateTextManagerAssignment(prop, value) { var key = prop + '\u0000' + value; if (tmAssignCache.hasOwnProperty(key)) { return tmAssignCache[key]; } var safe = String(prop).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var re = new RegExp('TextManager\\s*(?:\\.\\s*' + safe + '|\\[\\s*[\'"]' + safe + '[\'"]\\s*\\])\\s*=(?![=>])', 'g'); var literalHit = null, lastSite = null; pluginFilesInLoadOrder().forEach(function (file) { var c = readFileCached(file); if (!c) { return; } re.lastIndex = 0; var m; while ((m = re.exec(c.text))) { if (inComment(c, m.index)) { continue; } var vi = skipWs(c.text, m.index + m[0].length); var site = { file: file, off: vi }; lastSite = site; var lit = parseJsString(c.text, vi); if (lit && lit.value === value) { literalHit = site; } } }); var hit = literalHit || lastSite, res = null; if (hit) { var lc = offsetToLineCol(readFileCached(hit.file), hit.off); res = { file: hit.file, line: lc.line, col: lc.col, off: hit.off, exact: !!literalHit }; } tmAssignCache[key] = res; return res; } // Locate a plugin parameter value inside plugins.js (bounded to that plugin's entry). function locatePluginParam(plugin, param, value) { if (!nodeOk) { return null; } var file = path.join(WWW_ROOT, 'js', 'plugins.js'); var c = readFileCached(file); if (!c) { return null; } var anchor = c.text.indexOf('"name":"' + plugin + '"'); if (anchor < 0) { anchor = c.text.search(new RegExp('"name"\\s*:\\s*"' + String(plugin).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"')); } if (anchor < 0) { return null; } var next = c.text.indexOf('"name":', anchor + 7); // start of the NEXT plugin entry var p = c.text.indexOf(JSON.stringify(String(value)), anchor); var lc = offsetToLineCol(c, (p >= 0 && (next < 0 || p < next)) ? p : anchor); return { file: file, line: lc.line, col: lc.col }; } // Line/col of a notetag VALUE inside an object's "note" string. The JSON path // points at the whole note; narrow to the meta value's escaped text within it. function locateMetaValue(file, pathArr, value) { if (!file || !pathArr || !nodeOk) { return null; } var c = readFileCached(file); if (!c) { return null; } var key = file + '::meta:' + pathArr.join('/') + '::' + value + '::' + c.mtime; if (lineCache.hasOwnProperty(key)) { return lineCache[key]; } var res = null; var off = locateOffset(c.text, pathArr); if (off >= 0 && c.text[off] === '"') { var end = scanString(c.text, off); var esc = JSON.stringify(String(value)).slice(1, -1); var p = esc ? c.text.indexOf(esc, off) : -1; res = offsetToLineCol(c, (p >= 0 && p < end) ? p : off); } else if (off >= 0) { res = offsetToLineCol(c, off); } lineCache[key] = res; return res; } // String literals hardcoded in plugin .js code — e.g. a medal/skill table defined // as arrays right in a plugin ("Great Sage" in Nore_MedalParam.js). Lowest-priority // source: a drawn string can coincidentally equal an unrelated code literal, so // vocab/data/notetag/param sources always win over this. Built once, lazily // (plugin files can't change without F5); dropped on F9 in case one was edited // through the built-in editor. var jsLitIndex = null; var JSLIT_MAX_PER_VALUE = 8; function buildJsLitIndex() { // null prototype: plugin code is full of literals like 'constructor'/'toString' var map = Object.create(null); pluginFilesInLoadOrder().forEach(function (file) { var c = readFileCached(file); if (!c) { return; } var t = c.text, i = 0, n = t.length; while (i < n) { var ch = t[i], nx = t[i + 1]; if (ch === '/' && nx === '/') { var e1 = t.indexOf('\n', i); i = e1 < 0 ? n : e1 + 1; continue; } if (ch === '/' && nx === '*') { var e2 = t.indexOf('*/', i + 2); i = e2 < 0 ? n : e2 + 2; continue; } if (ch === '"' || ch === "'" || ch === '`') { var lit = parseJsString(t, i); if (lit) { var v = lit.value; // same floors as maybeCaptureUiText: >=2 non-space chars + a real letter if (v.replace(/\s/g, '').length >= 2 && /[^\s\d.,:%/+\-()]/.test(v)) { var arr = map[v] || (map[v] = []); if (arr.length < JSLIT_MAX_PER_VALUE) { arr.push({ t: 'jslit', file: file, off: i }); } } i = lit.end; continue; } } i++; } }); return map; } // Recent String.prototype.format calls (template -> output), so a drawn string // like "2 turn(s) remaining" resolves to its '%1 turn(s) remaining' template even // though the drawn form never exists in any file. var FORMAT_LOG = [], FORMAT_LOG_MAX = 40; if (CFG.captureUiText && String.prototype.format) { var _strFormat = String.prototype.format; String.prototype.format = function () { var out = _strFormat.apply(this, arguments); try { if (arguments.length && typeof out === 'string' && out.length >= 2) { var tpl = String(this); if (tpl !== out) { var last = FORMAT_LOG[FORMAT_LOG.length - 1]; if (!last || last.out !== out || last.tpl !== tpl) { FORMAT_LOG.push({ tpl: tpl, out: out }); if (FORMAT_LOG.length > FORMAT_LOG_MAX) { FORMAT_LOG.shift(); } } } } } catch (e) { /* ignore */ } return out; }; } // Drawn string -> best value-source descriptor, or null if unknown. function resolveUiSource(text) { var val = text, via = '', cands = uiValueCandidates(text); if (!cands) { for (var i = FORMAT_LOG.length - 1; i >= 0; i--) { if (FORMAT_LOG[i].out === text) { cands = uiValueCandidates(FORMAT_LOG[i].tpl); if (cands) { val = FORMAT_LOG[i].tpl; via = ' ← format()'; } break; } } } if (!cands || !cands.length) { return null; } var ORDER = { tm: 0, json: 1, meta: 2, param: 3, jslit: 4 }; var best = cands[0]; for (var k = 1; k < cands.length; k++) { if (ORDER[cands[k].t] < ORDER[best.t]) { best = cands[k]; } } var loc = null; if (best.t === 'tm') { loc = locateTextManagerAssignment(best.prop, val); // The assignment's own RHS literal is also in the jslit index — that's the // SAME location, not an "other match": drop it before counting extras. if (loc) { cands = cands.filter(function (x) { return !(x.t === 'jslit' && x.file === loc.file && x.off === loc.off); }); } } var extra = cands.length - 1; var suffix = via + (extra > 0 ? ' (+' + extra + ' other match' + (extra > 1 ? 'es' : '') + ')' : ''); if (best.t === 'tm') { if (loc) { return { kind: 'tm', file: loc.file, line: loc.line, col: loc.col, label: 'TextManager.' + best.prop + suffix, expText: val }; } return { kind: 'tm', file: null, expText: val, label: 'TextManager.' + best.prop + ' (assignment not in plugin files)' + suffix }; } if (best.t === 'param') { var pp = locatePluginParam(best.plugin, best.param, val); return { kind: 'param', file: pp && pp.file, line: pp && pp.line, col: pp && pp.col, label: 'plugins.js ' + best.plugin + ' → ' + best.param + suffix, expText: val }; } if (best.t === 'jslit') { var cf = readFileCached(best.file); if (!cf) { return null; } var ll = offsetToLineCol(cf, best.off); return { kind: 'jslit', file: best.file, line: ll.line, col: ll.col, label: baseName(best.file) + ' string literal' + suffix, expText: val }; } return { kind: best.t, file: best.file, tail: best.path, label: best.label + suffix, expText: val }; } //========================================================================= // Hooks: images (load log with call-site) //========================================================================= var imageLog = []; // {url, label, ts, bitmap} if (typeof ImageManager !== 'undefined') { var _loadBitmap = ImageManager.loadBitmap; ImageManager.loadBitmap = function (folder, filename) { var bm = _loadBitmap.apply(this, arguments); try { if (filename) { var url = folder + filename + '.png'; var last = imageLog[imageLog.length - 1]; if (!last || last.url !== url) { var site = firstUserFrame(new Error().stack); imageLog.push({ url: url, label: site.label, ts: Date.now(), bitmap: bm }); while (imageLog.length > CFG.historySize) { imageLog.shift(); } } else if (last && !last.bitmap) { last.bitmap = bm; } } } catch (e) { /* ignore */ } return bm; }; } //========================================================================= // Live on-screen image collection (walk the sprite tree) //========================================================================= function collectOnScreenImages() { var out = [], seen = {}; var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; if (!scene) { return out; } var canvas = getGameCanvas(); var rect = canvas ? canvas.getBoundingClientRect() : null; var scale = rect && typeof Graphics !== 'undefined' && Graphics.width ? rect.width / Graphics.width : 1; (function walk(obj) { if (!obj) { return; } if (obj.visible === false) { return; } var bmp = obj.bitmap; if (bmp && bmp._url) { var vis = ('worldVisible' in obj) ? obj.worldVisible : true; if (vis) { var b = null; try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; } if (b && b.width > 0 && b.height > 0) { if (!seen[bmp._url]) { seen[bmp._url] = true; } out.push({ url: decodeUrl(bmp._url), bitmap: bmp, screen: rect ? { x: rect.left + b.x * scale, y: rect.top + b.y * scale, w: b.width * scale, h: b.height * scale } : null }); } } } var kids = obj.children; if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } })(scene); return out; } function getGameCanvas() { if (typeof Graphics !== 'undefined') { if (Graphics._canvas) { return Graphics._canvas; } if (Graphics._renderer && Graphics._renderer.view) { return Graphics._renderer.view; } } return document.querySelector('canvas'); } // Bitmap URLs are percent-encoded (ImageManager uses encodeURIComponent on the // filename), e.g. "img/pictures/%E3%83%90%E3%82%B9.png" for "バス". Decode so the // path matches the real file on disk. function decodeUrl(u) { if (!u) { return u; } try { return decodeURIComponent(u); } catch (e) { return u; } } function urlToAbsPath(url) { if (!url) { return null; } var rel = decodeUrl(url).replace(/^\.?\//, ''); return path ? path.join(WWW_ROOT, rel) : WWW_ROOT + '/' + rel; } //========================================================================= // External actions (VSCode / file explorer) //========================================================================= // Auto-locate installed editors so the user never has to touch the system PATH. // We support the whole VS Code family (VS Code, Insiders, Cursor, VSCodium, // Windsurf): they share an identical CLI (" -g file:line:col"), so the // same launch path works for all of them. The user picks which one to use from a // dropdown in the panel (see the editor ' + 'Aa' + 'W' + '.*' + '' + '' + '' + '' + '' + '
' + '' + 'Replace' + 'Replace All' + '
'; root.appendChild(find); var main = document.createElement('div'); main.style.cssText = 'flex:1 1 auto;display:flex;overflow:hidden;background:#1b1e26'; var gutter = document.createElement('div'); gutter.className = 'tl-ed-gutter'; gutter.style.cssText = 'flex:0 0 auto;min-width:42px;overflow:hidden;text-align:right;' + 'padding:8px 6px 8px 10px;color:#566;background:#161922;white-space:pre;' + 'user-select:none;-webkit-user-select:none;font:12px/18px Consolas,Menlo,monospace'; var area = document.createElement('textarea'); area.className = 'tl-ed-area'; area.setAttribute('wrap', 'off'); // one logical line == one visual row area.setAttribute('spellcheck', 'false'); area.style.cssText = 'flex:1 1 auto;resize:none;border:0;outline:0;padding:8px 10px;' + 'color:#e6e6e6;background:#1b1e26;white-space:pre;overflow:auto;' + 'font:12px/18px Consolas,Menlo,monospace;tab-size:4;-moz-tab-size:4'; main.appendChild(gutter); main.appendChild(area); root.appendChild(main); var foot = document.createElement('div'); foot.style.cssText = 'padding:5px 12px;background:#11131a;border-top:1px solid #333;color:#8aa;flex:0 0 auto;display:flex;gap:14px;border-radius:0 0 8px 8px'; foot.innerHTML = 'Ctrl+S save · Ctrl+F find · Ctrl+H replace · Esc close · Tab indent' + ''; root.appendChild(foot); var st = document.createElement('style'); st.textContent = '#tl-editor .tl-ed-btn{cursor:pointer;padding:3px 9px;border-radius:4px;background:#333;color:#ddd}' + '#tl-editor .tl-ed-btn:hover{filter:brightness(1.35)}' + '#tl-editor .tl-ed-tabs{display:flex;overflow-x:auto;background:#0e1016;border-bottom:1px solid #333;flex:0 0 auto}' + '#tl-editor .tl-ed-tab{display:flex;align-items:center;gap:6px;padding:5px 8px;border-right:1px solid #222;color:#9ab;cursor:pointer;white-space:nowrap;max-width:220px;flex:0 0 auto}' + '#tl-editor .tl-ed-tab.on{background:#1b1e26;color:#fff}' + '#tl-editor .tl-ed-tab .nm{overflow:hidden;text-overflow:ellipsis}' + '#tl-editor .tl-ed-tab .dot{color:#fd6}' + '#tl-editor .tl-ed-tab .x{color:#889;border-radius:3px;padding:0 4px}' + '#tl-editor .tl-ed-tab .x:hover{background:#555;color:#fff}' + '#tl-editor .tl-ed-find{background:#12141b;border-bottom:1px solid #333;padding:5px 8px;flex:0 0 auto}' + '#tl-editor .tl-fr{display:flex;align-items:center;gap:6px;margin:2px 0}' + '#tl-editor .tl-fq,#tl-editor .tl-fr-in{flex:0 0 240px;width:240px;min-width:0;background:#1b1e26;border:1px solid #344;color:#eee;padding:3px 6px;font:12px Consolas,Menlo,monospace;outline:none}' + '#tl-editor .tl-fopt{cursor:pointer;padding:2px 6px;border-radius:3px;background:#222;color:#9ab;font:11px monospace}' + '#tl-editor .tl-fopt.on{background:#2a5db0;color:#fff}' + '#tl-editor .tl-fcount{color:#9ab;min-width:62px;text-align:center;font-size:11px}' + '#tl-editor .tl-fcount.err{color:#f88}' + '#tl-editor .tl-fbtn{cursor:pointer;padding:2px 8px;border-radius:3px;background:#333;color:#ddd;white-space:nowrap}' + '#tl-editor .tl-fbtn:hover{filter:brightness(1.3)}'; root.appendChild(st); head.addEventListener('click', function (e) { var a = e.target.getAttribute('data-act'); if (a === 'save') { saveEditor(); } else if (a === 'close') { closeEditor(); } else if (a === 'find') { openFind(false); } }); // tab strip: click selects, click the x closes tabs.addEventListener('click', function (e) { var t = e.target; while (t && t !== tabs && !t.hasAttribute('data-ti')) { t = t.parentNode; } if (!t || t === tabs) { return; } var i = parseInt(t.getAttribute('data-ti'), 10); if (e.target.classList.contains('x')) { closeTab(i); } else { selectTab(i); } }); // find widget wiring editor.fq = find.querySelector('.tl-fq'); editor.frin = find.querySelector('.tl-fr-in'); editor.fcount = find.querySelector('.tl-fcount'); editor.fq.addEventListener('input', function () { editor.find.q = this.value; recomputeMatches(); showMatch(editor.find.idx < 0 ? 0 : editor.find.idx); }); editor.fq.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); showMatch(editor.find.idx + (e.shiftKey ? -1 : 1)); } else if (e.key === 'Escape') { e.preventDefault(); closeFind(); } }); editor.frin.addEventListener('input', function () { editor.find.r = this.value; }); editor.frin.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); replaceCurrent(); } else if (e.key === 'Escape') { e.preventDefault(); closeFind(); } }); find.addEventListener('click', function (e) { var opt = e.target.getAttribute('data-opt'); var fa = e.target.getAttribute('data-fa'); if (opt) { var f = editor.find; f[opt] = !f[opt]; e.target.classList.toggle('on', f[opt]); recomputeMatches(); showMatch(f.idx < 0 ? 0 : f.idx); } else if (fa === 'next') { showMatch(editor.find.idx + 1); } else if (fa === 'prev') { showMatch(editor.find.idx - 1); } else if (fa === 'close') { closeFind(); } else if (fa === 'rep') { replaceCurrent(); } else if (fa === 'repall') { replaceAll(); } }); area.addEventListener('input', function () { markDirty(); refreshGutter(); if (editor.find.open) { recomputeMatches(); updateFindCount(); } }); area.addEventListener('scroll', syncGutter); area.addEventListener('keydown', onEditorKey, false); // Keep the game from reacting to clicks/keys aimed at the editor (mirrors the // inspector panel's isolation). With "freeze" off this is what protects typing. ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'contextmenu', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'pointerup', 'keydown', 'keyup', 'keypress'].forEach(function (t) { root.addEventListener(t, function (ev) { ev.stopPropagation(); }, false); }); document.body.appendChild(root); editor.root = root; editor.area = area; editor.gutter = gutter; editor.tabsEl = tabs; editor.findEl = find; editor.statusEl = foot.querySelector('.tl-ed-status'); applyOverlayScale(); } //--- documents / tabs ---------------------------------------------------- function docIndexByFile(file) { for (var i = 0; i < editor.docs.length; i++) { if (editor.docs[i].file === file) { return i; } } return -1; } // Files above this size are NOT loaded whole into the