document.addEventListener('DOMContentLoaded', () => {
const tabButtons = document.querySelectorAll('[data-editor-tab]');
const panels = document.querySelectorAll('[data-editor-panel]');
const panelShell = document.querySelector('.editor-tab-panels');
const contextTabsWrap = document.querySelector('.ctx-tabs');
let activeEditorTab = 'assets';
let editorSyncDepth = 0;
let isRevertingContext = false;
function setPanelOverlay(on, label = 'Syncing') {
if (!panelShell) return;
let overlay = panelShell.querySelector('.editor-panel-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'editor-panel-overlay';
overlay.innerHTML = '
';
panelShell.appendChild(overlay);
}
const clean = String(label || 'Syncing').replace(/\.*\s*$/, '');
const overlayLabel = overlay.querySelector('.label');
if (overlayLabel) {
overlayLabel.textContent = clean;
overlayLabel.setAttribute('data-ellipsis', '');
}
overlay.classList.toggle('show', !!on);
panelShell.classList.toggle('is-sync-locked', !!on);
document.querySelector('.editor-tabs')?.classList.toggle('is-sync-locked', !!on);
}
async function withEditorSync(label, run) {
editorSyncDepth += 1;
setPanelOverlay(true, label || 'Syncing');
try {
return await run();
} finally {
editorSyncDepth = Math.max(0, editorSyncDepth - 1);
if (!editorSyncDepth) setPanelOverlay(false);
}
}
function selectEditorTab(target) {
if (!target) return;
if (editorSyncDepth > 0 && target !== activeEditorTab) return;
activeEditorTab = target;
tabButtons.forEach((b) => {
const isActive = b.getAttribute('data-editor-tab') === target;
b.classList.toggle('is-active', isActive);
b.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
panels.forEach((panel) => {
const match = panel.getAttribute('data-editor-panel') === target;
panel.classList.toggle('is-active', match);
});
updateHeaderActionsForTab?.();
if (window.location.hash !== `#${target}`) {
history.replaceState(null, '', `#${target}`);
}
if (target === 'assets') {
if (!isMemberSignedIn) {
selectEditorTab('sequence');
redirectToSignUp();
return;
}
refreshAssetsForCurrentContext?.();
}
}
if (tabButtons.length && panels.length) {
tabButtons.forEach((btn) => {
btn.addEventListener('click', () => selectEditorTab(btn.getAttribute('data-editor-tab')));
});
}
window.addEventListener('hashchange', () => {
const h = String(window.location.hash || '').replace(/^#/, '').toLowerCase();
if (['assets', 'sequence', 'structure', 'ligand'].includes(h)) selectEditorTab(h);
});
const editorRoot = document.getElementById('vici-editor');
if (editorRoot && editorRoot.dataset.seqEditorInit === '1') return;
if (editorRoot) editorRoot.dataset.seqEditorInit = '1';
const nextFrame = () => new Promise(requestAnimationFrame);
const EDITOR_SAVE_URL = 'https://ayambabu23--workflow-execute-workflow.modal.run/';
const EDITOR_STATUS_URL = 'https://ayambabu23--workflow-check-status.modal.run/';
const DELETE_URL = window.MODAL_DELETE_URL || 'https://ayambabu23--workflow-delete-workflow.modal.run/';
const DOWNLOAD_URL = window.MODAL_DOWNLOAD_URL || 'https://ayambabu23--workflow-download.modal.run';
const EMPTY_RIBBON_URL = 'https://cdn.prod.website-files.com/685009238c2e9a16d785299f/68cd6d37b9d5aee9c675aac9_vici_empty_state_ribbon_animated.svg';
const SEQUENCE_EXTENSIONS = new Set(['fasta', 'fa', 'fna', 'faa', 'txt', 'seq', 'json']);
const STRUCTURE_EXTENSIONS = new Set(['pdb', 'ent', 'cif', 'mmcif']);
const LIGAND_EXTENSIONS = new Set(['sdf', 'mol', 'mol2', 'pdbqt']);
let currentAssetPath = null;
let activeScopeId = null;
let hasUnsavedChanges = false;
let autoSaveTimer = null;
let autoSaveInFlight = null;
let suppressAssetsOverlayOnce = false;
let isApplyingPersistedState = false;
let warnedLegacyFasta = false;
let isMemberSignedIn = false;
const SIGN_UP_PATH = '/sign-up';
function redirectToSignUp() {
window.location.href = SIGN_UP_PATH;
}
async function detectMemberSignIn() {
try {
const ms = window.$memberstackDom;
if (!ms?.getCurrentMember) return false;
const { data } = await ms.getCurrentMember();
return !!data?.id;
} catch (_) {
return false;
}
}
function getFileExtension(name) {
const base = String(name || '').trim();
const idx = base.lastIndexOf('.');
return idx >= 0 ? base.slice(idx + 1).toLowerCase() : '';
}
function currentContextKey(memberId) {
return String(window.ViciContext?.get?.() || sessionStorage.getItem('vici:ctx') || `user:${memberId}`);
}
function resolveEditorUserId(scopePayload, fallback = '') {
const explicit = String(scopePayload?.user_id || '').trim();
const memberId = explicit || String(fallback || '').trim();
const teamId = String(scopePayload?.team_id || scopePayload?.workspace_id || '').trim();
if (!memberId) return '';
if (!teamId) return memberId;
return memberId.includes('+') ? memberId : `${memberId}+${teamId}`;
}
function resolveEditorTeamId(scopePayload) {
return String(scopePayload?.team_id || scopePayload?.workspace_id || '').trim();
}
function stripTrailingExtension(name) {
const base = String(name || '').trim();
return base.replace(/\.[A-Za-z0-9]+$/, '');
}
function toCleanAssetLabel(name) {
const base = stripTrailingExtension(String(name || '').split('/').pop() || '');
const cleaned = base
.replace(/[_\-.]+/g, ' ')
.replace(/\b[0-9a-f]{14,}\b/gi, ' ')
.replace(/\bfasta\d*\b/gi, ' ')
.replace(/\s+/g, ' ')
.replace(/\b([A-Za-z])\s+(?=[A-Za-z]\b)/g, '$1')
.replace(/\b([0-9A-Za-z])\s+(?=[0-9A-Za-z]\b)/g, '$1')
.trim();
return cleaned || 'Untitled';
}
function assetKindFromPath(path) {
const ext = getFileExtension(path);
if (STRUCTURE_EXTENSIONS.has(ext)) return 'structure';
if (LIGAND_EXTENSIONS.has(ext)) return 'ligand';
return 'sequence';
}
function assetTypeLabel(path) {
const ext = getFileExtension(path).toUpperCase();
const kind = assetKindFromPath(path);
if (ext) return ext;
return kind === 'sequence' ? 'DNA' : (kind === 'structure' ? 'PDB' : 'SDF');
}
async function getCurrentMemberData() {
const ms = window.$memberstackDom;
if (!ms?.getCurrentMember) throw new Error('Member session unavailable');
const { data } = await ms.getCurrentMember();
if (!data?.id) throw new Error('Missing member id');
return data;
}
async function getEditorAuthContext() {
const member = await getCurrentMemberData();
const jwt = await window.ViciAuth?.getJwt?.();
if (!jwt) throw new Error('Missing session token. Please sign in again.');
const payload = window.ViciContext?.payloadFor?.(member.id) || { user_id: member.id };
return { member, jwt, payload, contextKey: currentContextKey(member.id) };
}
async function listEditorAssets(scopePayload, jwt) {
const resp = await fetch(EDITOR_STATUS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ ...scopePayload, editor: true })
});
if (!resp.ok) throw new Error(`List failed (${resp.status})`);
const json = await resp.json();
return Array.isArray(json?.paths) ? json.paths : [];
}
async function fetchEditorAsset(scopePayload, path, jwt) {
const resp = await fetch(EDITOR_STATUS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ ...scopePayload, editor: true, path })
});
if (!resp.ok) throw new Error(`Fetch failed (${resp.status})`);
return await resp.json();
}
async function saveEditorAsset(scopePayload, payload, jwt) {
const resp = await fetch(EDITOR_SAVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({ ...scopePayload, ...payload })
});
if (!resp.ok) throw new Error(`Save failed (${resp.status})`);
return await resp.json();
}
async function deleteEditorAsset(scopePayload, path, jwt) {
const fullName = String(path || '').split('/').pop() || '';
const ext = getFileExtension(fullName);
const fileName = stripTrailingExtension(fullName) || fullName || 'asset';
const resp = await fetch(DELETE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
user_id: resolveEditorUserId(scopePayload),
team_id: resolveEditorTeamId(scopePayload),
editor: true,
file_name: fileName,
file_extension: (ext || 'fasta').toLowerCase(),
})
});
if (!resp.ok) throw new Error(`Delete failed (${resp.status})`);
return await resp.json();
}
function buildDuplicatedAssetName(path, existingPaths = []) {
const fullName = String(path || '').split('/').pop() || 'asset.fasta';
const ext = getFileExtension(fullName) || 'fasta';
const base = toCleanAssetLabel(fullName) || 'asset';
const existing = new Set((existingPaths || []).map((p) => String(p || '').split('/').pop() || ''));
let candidate = `${base} copy.${ext}`;
let idx = 2;
while (existing.has(candidate)) {
candidate = `${base} copy ${idx}.${ext}`;
idx += 1;
}
return candidate;
}
async function duplicateEditorAsset(scopePayload, path, jwt, existingPaths = [], preferredName = '') {
const original = await fetchEditorAsset(scopePayload, path, jwt);
const content = original?.editor_content || original;
if (!content || typeof content.file_content !== 'string') throw new Error('Failed to duplicate asset');
const requestedName = String(preferredName || '').trim();
const normalizedName = requestedName
? (requestedName.includes('.') ? requestedName : `${requestedName}.${getFileExtension(path) || 'fasta'}`)
: '';
const newName = normalizedName || buildDuplicatedAssetName(path, existingPaths);
const ext = getFileExtension(newName) || getFileExtension(path) || 'fasta';
const baseName = stripTrailingExtension(newName) || 'asset';
await saveEditorAsset(scopePayload, {
editor_content: {
file_name: newName,
file_extension: ext.toLowerCase(),
file_content: content.file_content,
ui_state: content.ui_state || null,
}
}, jwt);
return { path: newName, label: baseName };
}
async function downloadEditorAsset(scopePayload, path, jwt) {
const fullName = String(path || '').split('/').pop() || '';
const ext = getFileExtension(fullName);
const fileName = stripTrailingExtension(fullName) || fullName || 'asset';
const resp = await fetch(DOWNLOAD_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
user_id: resolveEditorUserId(scopePayload),
team_id: resolveEditorTeamId(scopePayload),
editor: true,
file_name: fileName,
file_extension: (ext || 'fasta').toLowerCase(),
})
});
if (!resp.ok) throw new Error(`Download failed (${resp.status})`);
const contentType = String(resp.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const json = await resp.json();
if (typeof json?.download_url === 'string' && json.download_url) {
return { name: fullName || `${fileName}.${ext || 'fasta'}`, url: json.download_url, source: 'url' };
}
if (typeof json?.file_content === 'string') {
return {
name: fullName || `${fileName}.${ext || 'fasta'}`,
blob: new Blob([json.file_content], { type: 'text/plain;charset=utf-8' }),
source: 'content'
};
}
throw new Error('Download response missing file payload');
}
const blob = await resp.blob();
return { name: fullName || `${fileName}.${ext || 'fasta'}`, blob, source: 'blob' };
}
function triggerEditorAssetDownload(downloadData) {
if (!downloadData) return;
const link = document.createElement('a');
link.rel = 'noopener';
link.download = downloadData.name || 'asset';
if (downloadData.url) {
link.href = downloadData.url;
} else if (downloadData.blob) {
link.href = URL.createObjectURL(downloadData.blob);
} else {
throw new Error('Invalid download payload');
}
document.body.appendChild(link);
link.click();
link.remove();
if (downloadData.blob) {
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}
}
let seqRaw = '';
let translationFrameOffset = 0;
let translationAutoCycle = false;
let translationRanges = null;
let lastChainMeta = null;
const proteinCodonOverrides = new Map();
function entryKey(entry, idx) {
const h = String(entry?.header || '').trim();
return h ? h : `__seq_${idx}`;
}
function getDerivedNucForProtein(entry, idx, protSeq, desiredType) {
const key = entryKey(entry, idx);
const over = proteinCodonOverrides.get(key);
if (over && typeof over.seq === 'string' && over.seq.length === (String(protSeq || '').length * 3)) {
// Normalize stored seq to the requested display type (dna/rna)
const stored = (over.nucType === 'rna') ? toRNA(over.seq) : toDNA(over.seq);
return (desiredType === 'rna') ? toRNA(stored) : toDNA(stored);
}
return backTranslateProtein(protSeq, desiredType);
}
const seqViewer = document.getElementById('seq-viewer');
const lenEl = document.getElementById('seq-length');
const posEl = document.getElementById('seq-pos');
let isEditingRange = false;
const rangeBox = document.querySelector('.seq-meta-box--range');
rangeBox?.addEventListener('click', (e) => {
if (e.target !== posEl) posEl.focus();
});
const lenBox = document.querySelector('.seq-meta-box--len');
lenBox?.addEventListener('mousedown', (e) => {
if (e.shiftKey || e.detail >= 2) return;
const txt = getMetaLengthPlain();
if (!txt) return;
e.preventDefault();
e.stopPropagation();
copyToClipboard(txt, 'Copied length');
});
posEl.setAttribute('contenteditable', 'true');
posEl.setAttribute('spellcheck', 'false');
posEl.setAttribute('role', 'textbox');
posEl.setAttribute('aria-label', 'Range (e.g. 1-100, 150-200)');
const typePill = document.getElementById('seq-type-pill');
const lockBtn = document.getElementById('seq-lock-toggle');
const groupToggleBtn = document.getElementById('seq-group-toggle');
const nameInput = document.getElementById('seq-name-input');
const undoBtn = document.getElementById('seq-undo-btn');
const redoBtn = document.getElementById('seq-redo-btn');
const copyBtn = document.getElementById('seq-copy-btn');
const clearBtn = document.getElementById('seq-clear-btn');
const zoomInBtn = document.getElementById('seq-zoom-in-btn');
const zoomOutBtn = document.getElementById('seq-zoom-out-btn');
const reverseBtn = document.getElementById('seq-reverse-btn');
const translateBtn = document.getElementById('seq-translate');
const proteinColorSelect = document.getElementById('seq-protein-color-mode');
const ntColorSelect = document.getElementById('seq-nt-color-mode');
const newFileBtn = document.getElementById('editor-new-file');
const headerFileInput = document.getElementById('editor-file-input');
const editFileBtn = document.getElementById('editor-edit-file');
const EDIT_PENCIL_SVG = `
`;
const UPLOAD_SVG = `
`;
function viewerHasContent() {
if (!seqViewer) return false;
const t = (seqViewer.textContent || '').trim();
if (t.length) return true;
return !!seqViewer.querySelector?.('.seq-cell, .seq-triplet-cell');
}
function updateEditButtonState() {
if (!editFileBtn) return;
const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent();
const emptyRibbon = document.getElementById('seq-empty-ribbon');
if (emptyRibbon) emptyRibbon.style.display = has ? 'none' : '';
editFileBtn.dataset.mode = has ? 'close' : 'upload';
contextTabsWrap?.classList.toggle('is-context-locked', has);
updateHeaderActionsForTab?.();
}
updateEditButtonState();
if (seqViewer) {
const mo = new MutationObserver(() => updateEditButtonState());
mo.observe(seqViewer, { childList: true, subtree: true, characterData: true });
}
seqViewer?.addEventListener('click', (e) => {
if (viewerHasContent()) return;
openSeqModal('');
});
const newTypeBackdrop = document.getElementById('editor-new-type-backdrop');
const btnRulers = document.getElementById('seq-toggle-rulers-btn');
const btnProtein = document.getElementById('seq-toggle-protein-btn');
const btnDNA = document.getElementById('seq-toggle-dna-btn');
const btnRNA = document.getElementById('seq-toggle-rna-btn');
const btnRevComp = document.getElementById('seq-toggle-revcomp-btn');
const gcMeter = document.getElementById('seq-gc-meter');
const gcLabel = document.getElementById('seq-gc-label');
const atLabel = document.getElementById('seq-at-label');
const gcBar = document.getElementById('seq-gc-bar');
const copyMenu = document.getElementById('seq-copy-menu');
const copyProteinWholeBtn = document.getElementById('seq-copy-protein');
const copyDNAWholeBtn = document.getElementById('seq-copy-dna');
const copyRNAWholeBtn = document.getElementById('seq-copy-rna');
const copyRevWholeBtn = document.getElementById('seq-copy-revcomp');
const highlightNameInput = document.getElementById('seq-highlight-name');
const highlightSwatches = document.getElementById('seq-highlight-swatches');
const highlightTrashBtn = document.getElementById('seq-clear-highlight');
const subtoolbar = document.getElementById('seq-subtoolbar');
const subtoolbarRight = document.getElementById('seq-subtoolbar-right');
let subtoolbarHideTimer = null;
const seqModalBackdrop = document.getElementById('seq-modal-backdrop');
const seqModalClose = document.getElementById('seq-modal-close');
const seqModalApply = document.getElementById('seq-modal-apply');
const seqModalClear = document.getElementById('seq-modal-clear');
const seqModalInput = document.getElementById('seq-modal-input');
const seqModalFileInput = document.getElementById('seq-modal-file-input');
const seqModalDropzone = document.getElementById('seq-modal-dropzone');
const mutModalBackdrop = document.getElementById('mut-modal-backdrop');
const mutModalClose = document.getElementById('mut-modal-close');
const mutModalCancel = document.getElementById('mut-modal-cancel');
const mutModalSave = document.getElementById('mut-modal-save');
const mutMiniViewer = document.getElementById('mut-mini-viewer');
const mutPicked = document.getElementById('mut-picked');
const mutOptions = document.getElementById('mut-options');
const mutUndoBtn = document.getElementById('mut-undo-btn');
const mutRedoBtn = document.getElementById('mut-redo-btn');
const mutCopyBtn = document.getElementById('mut-copy-btn');
const mutInsertLeft = document.getElementById('mut-insert-left');
const mutInsertRight = document.getElementById('mut-insert-right');
const mutDeleteBtn = document.getElementById('mut-delete');
const mutActions = document.getElementById('mut-actions');
const seqBlocksWrap = document.getElementById('seq-blocks');
const seqAddBlockBtn = document.getElementById('seq-add-block');
const AA3 = {
A:'ALA', R:'ARG', N:'ASN', D:'ASP', C:'CYS',
Q:'GLN', E:'GLU', G:'GLY', H:'HIS', I:'ILE',
L:'LEU', K:'LYS', M:'MET', F:'PHE', P:'PRO',
S:'SER', T:'THR', W:'TRP', Y:'TYR', V:'VAL',
U:'SEC', O:'PYL', X:'XAA', '*':'STOP'
};
function aaToThree(aa){
const k = String(aa || '').toUpperCase();
return AA3[k] || 'XAA';
}
function refreshSeqBlockMoveButtons() {
if (!seqBlocksWrap) return;
const blocks = Array.from(seqBlocksWrap.querySelectorAll('.seq-block'));
blocks.forEach((b, i) => {
const up = b.querySelector('.seq-block-move-up');
const dn = b.querySelector('.seq-block-move-down');
if (up) up.disabled = (i === 0);
if (dn) dn.disabled = (i === blocks.length - 1);
});
}
function moveSeqBlock(blockEl, dir) {
if (!seqBlocksWrap || !blockEl) return;
const prev = blockEl.previousElementSibling;
const next = blockEl.nextElementSibling;
if (dir < 0 && prev?.classList.contains('seq-block')) {
seqBlocksWrap.insertBefore(blockEl, prev);
} else if (dir > 0 && next?.classList.contains('seq-block')) {
seqBlocksWrap.insertBefore(next, blockEl);
} else {
return;
}
syncHiddenModalText();
refreshSeqBlockMoveButtons();
}
function animateSeqBlockIn(el) {
if (!el) return;
el.style.overflow = 'hidden';
el.style.maxHeight = '0px';
el.style.opacity = '0';
el.style.transform = 'translateY(-8px)';
el.style.transition = 'max-height .28s ease, opacity .28s ease, transform .28s ease';
const h = el.scrollHeight || 0;
requestAnimationFrame(() => {
el.style.maxHeight = h + 'px';
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
});
const onEnd = (e) => {
if (e.propertyName !== 'max-height') return;
el.removeEventListener('transitionend', onEnd);
el.style.transition = '';
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
el.style.opacity = '';
el.style.transform = '';
};
el.addEventListener('transitionend', onEnd);
}
function animateSeqBlockOut(el, done) {
if (!el) return done?.();
el.style.overflow = 'hidden';
const h = el.scrollHeight || 0;
el.style.maxHeight = h + 'px';
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
el.style.transition = 'max-height .22s ease, opacity .22s ease, transform .22s ease';
requestAnimationFrame(() => {
el.style.maxHeight = '0px';
el.style.opacity = '0';
el.style.transform = 'translateY(-8px)';
});
let called = false;
const finish = () => {
if (called) return;
called = true;
done?.();
};
el.addEventListener('transitionend', (e) => {
if (e.propertyName === 'max-height') finish();
}, { once:true });
setTimeout(finish, 320);
}
function buildSeqBlock({ name = '', seq = '', type = 'protein' } = {}) {
const div = document.createElement('div');
div.className = 'seq-block';
div.innerHTML = `
`;
const ta = div.querySelector('.seq-block-seq');
if (ta) ta.value = String(seq || '');
div.querySelector('.seq-block-move-up')?.addEventListener('click', () => moveSeqBlock(div, -1));
div.querySelector('.seq-block-move-down')?.addEventListener('click', () => moveSeqBlock(div, +1));
const typeSel = div.querySelector('.seq-block-type');
typeSel.value = type || 'protein';
div.querySelector('.seq-block-remove').addEventListener('click', () => {
animateSeqBlockOut(div, () => {
div.remove();
ensureAtLeastOneSeqBlock();
syncHiddenModalText();
refreshSeqBlockMoveButtons();
});
});
typeSel.addEventListener('change', () => {
const ta2 = div.querySelector('.seq-block-seq');
const t = typeSel.value;
if (ta2) {
if (t === 'rna') ta2.value = String(ta2.value || '').toUpperCase().replace(/T/g, 'U');
if (t === 'dna') ta2.value = String(ta2.value || '').toUpperCase().replace(/U/g, 'T');
}
updateSeqBlockLookupMode(div);
syncHiddenModalText();
});
window.ViciLookup?.init?.(div);
updateSeqBlockLookupMode(div);
return div;
}
function updateSeqBlockLookupMode(block) {
const type = block.querySelector('.seq-block-type')?.value || 'protein';
const lookupEl = block.querySelector('.vici-lookup');
if (!lookupEl) return;
window.ViciLookup?.setMode?.(lookupEl, type);
}
function ensureAtLeastOneSeqBlock() {
if (!seqBlocksWrap) return;
if (!seqBlocksWrap.querySelector('.seq-block')) {
addSeqBlock({ name: '', seq: '', type: 'protein' });
}
}
function addSeqBlock(data = {}, opts = {}) {
if (!seqBlocksWrap) return;
const modalOpen = Boolean(seqModalBackdrop && !seqModalBackdrop.hidden);
const animate = Object.prototype.hasOwnProperty.call(opts, 'animate')
? Boolean(opts.animate)
: modalOpen;
const block = buildSeqBlock(data);
seqBlocksWrap.appendChild(block);
if (animate) animateSeqBlockIn(block);
syncHiddenModalText();
refreshSeqBlockMoveButtons();
}
function serializeSeqBlocksToFasta() {
if (!seqBlocksWrap) return '';
const blocks = Array.from(seqBlocksWrap.querySelectorAll('.seq-block'));
const parts = [];
blocks.forEach((b, i) => {
const nameRaw = (b.querySelector('.seq-block-name')?.value || '').trim();
const name = nameRaw || `Sequence ${i + 1}`;
const type = b.querySelector('.seq-block-type')?.value || 'protein';
let seq = (b.querySelector('.seq-block-seq')?.value || '')
.replace(/\s+/g, '')
.toUpperCase();
if (!seq) return;
if (type === 'rna') seq = seq.replace(/T/g, 'U');
if (type === 'dna') seq = seq.replace(/U/g, 'T');
parts.push(`>${name}`);
const wrapped = seq.match(/.{1,80}/g)?.join('\n') || seq;
parts.push(wrapped);
parts.push('');
});
return parts.join('\n').trim();
}
function syncHiddenModalText() {
if (!seqModalInput) return;
seqModalInput.value = serializeSeqBlocksToFasta();
}
function populateSeqBlocksFromRaw(rawText) {
if (!seqBlocksWrap) return;
seqBlocksWrap.innerHTML = '';
const raw = String(rawText || '').trim();
const entries = (typeof parseEntriesFromRaw === 'function')
? parseEntriesFromRaw(raw)
: [];
if (!entries || !entries.length) {
addSeqBlock({ name: '', seq: raw ? raw.replace(/[^A-Za-z]/g, '').toUpperCase() : '', type: 'protein' });
ensureAtLeastOneSeqBlock();
syncHiddenModalText();
return;
}
entries.forEach((e, i) => {
const header = (e.header || '').replace(/^>\s*/, '').trim();
const name = header || `Sequence ${i + 1}`;
const seq = (e.seq || '').toUpperCase();
let t = 'protein';
if (typeof detectSeqType === 'function') {
const dt = detectSeqType(seq);
if (dt === 'dna' || dt === 'rna' || dt === 'protein') t = dt;
else if (dt === 'mixed') t = 'protein';
}
addSeqBlock({ name, seq, type: t }, { animate: false });
});
ensureAtLeastOneSeqBlock();
syncHiddenModalText();
refreshSeqBlockMoveButtons();
}
seqBlocksWrap?.addEventListener('input', (e) => {
const t = e.target;
if (!t) return;
if (t.classList.contains('seq-block-name') || t.classList.contains('seq-block-seq')) {
syncHiddenModalText();
}
});
seqAddBlockBtn?.addEventListener('click', () => addSeqBlock({}));
function escapeHtmlAttr(s){
return String(s || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
function escapeHtmlText(s){
return String(s || '')
.replace(/&/g, '&')
.replace(//g, '>');
}
const deleteBackdrop = document.getElementById('editor-delete-backdrop');
const deleteClose = document.getElementById('editor-delete-close');
const deleteCancel = document.getElementById('editor-delete-cancel');
const deleteConfirm = document.getElementById('editor-delete-confirm');
const deleteCountInput = document.getElementById('editor-delete-count-input');
const deleteCountLabel = document.getElementById('editor-delete-count-label');
const deleteText = document.getElementById('editor-delete-text');
const duplicateBackdrop = document.getElementById('editor-duplicate-backdrop');
const duplicateClose = document.getElementById('editor-duplicate-close');
const duplicateCancel = document.getElementById('editor-duplicate-cancel');
const duplicateConfirm = document.getElementById('editor-duplicate-confirm');
const duplicateInputsWrap = document.getElementById('editor-duplicate-inputs');
const searchInput = document.getElementById('seq-search-input');
const searchCount = document.getElementById('seq-search-count');
const mutateBtn = document.getElementById('seq-mutate-btn');
const insertLeftBtn = document.getElementById('seq-insert-left-btn');
const insertRightBtn = document.getElementById('seq-insert-right-btn');
function entriesToRawFallback(entries){
const parts = [];
const multi = entries.length > 1;
entries.forEach((e, i) => {
if (e.header) parts.push(e.header);
else if (multi) parts.push(`>Sequence ${i + 1}`);
parts.push(String(e.seq || '').toUpperCase());
if (i < entries.length - 1) parts.push('');
});
return parts.join('\n').trim();
}
function mainViewerInsert(where){
if (isLocked?.()) { notify?.('error', 'Unlock to edit'); return; }
let ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : (selectionRanges || []);
let track = selectionTrack || null;
if ((!ranges || !ranges.length || !track) && lastGoodSelection?.ranges?.length) {
ranges = lastGoodSelection.ranges;
track = lastGoodSelection.track;
}
if (!ranges || !ranges.length) { notify?.('error', 'Select a residue/base first'); return; }
const r = ranges[0];
const anchorPos = (where === 'before') ? r.start : r.end;
const entries = parseEntriesFromRaw(seqRaw);
if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; }
let chainStart = 1;
let hit = null;
for (let i=0;i= start && anchorPos <= end){
hit = { i, start, end, seq };
break;
}
chainStart = end + 1;
}
if (!hit) return;
const baseType = detectSeqType(hit.seq);
const local = anchorPos - hit.start + 1; // 1-based
const insertAt = (where === 'before') ? (local - 1) : local; // 0-based slice index
const ins = (baseType === 'dna' || baseType === 'rna') ? 'N' : 'X';
const newGlobalPos = (where === 'before') ? anchorPos : (anchorPos + 1);
const nextSeq = hit.seq.slice(0, insertAt) + ins + hit.seq.slice(insertAt);
entries[hit.i].seq = nextSeq;
const rawOut = (typeof entriesToRaw === 'function')
? entriesToRaw(entries)
: entriesToRawFallback(entries);
seqRaw = sanitizeAndGroup(rawOut).text;
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState?.();
lastState = seqRaw;
lastState = seqRaw;
lastState = seqRaw;
lastState = seqRaw;
if (typeof clearSelection === 'function') clearSelection();
if (typeof setSelection === 'function') {
const t = track || getPrimarySelectableTrack?.() || 'protein';
selectionTrack = t;
setSelection(newGlobalPos, newGlobalPos);
}
notify?.('success', 'Inserted');
}
insertLeftBtn?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
mainViewerInsert('before');
}, true);
insertRightBtn?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
mainViewerInsert('after');
}, true);
let searchMatches = [];
let searchNeedRefresh = false;
if (!seqViewer || !lenEl || !posEl || !typePill) return;
seqViewer.setAttribute('contenteditable', 'false');
let groupSize = 0;
const groupModes = [0, 3, 5, 6, 9, 10, 15];
let groupModeIndex = 0;
let resizeTimer = null;
let lastSeq = '';
let undoStack = [];
let redoStack = [];
let isApplyingHistory = false;
let lastState = '';
async function handleNewFileClick() {
const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent();
if (!has) {
openSeqModal('');
return;
}
try {
await saveCurrentFileBestEffort();
showToast?.('success', 'Saved');
} catch (_) {}
clearSelection();
hardClearSearchUI();
seqRaw = '';
residueScoreMap = [];
highlights.length = 0;
undoStack = [];
redoStack = [];
lastState = '';
await nextFrame();
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState();
}
newFileBtn?.addEventListener('click', async (e) => {
e.preventDefault();
if (!isMemberSignedIn) {
redirectToSignUp();
return;
}
if (activeEditorTab === 'assets') {
newTypeBackdrop.hidden = false;
syncBodyScrollLock();
return;
}
if (activeEditorTab !== 'sequence') {
showToast?.('info', `${activeEditorTab[0].toUpperCase()}${activeEditorTab.slice(1)} tools coming soon`);
return;
}
if (hasSequenceWorkingState()) {
return openSeqModal(seqRaw || '');
}
handleNewFileClick();
});
function scheduleBackgroundAutoSave() {
if (!currentAssetPath) return;
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(async () => {
autoSaveTimer = null;
if (!hasUnsavedChanges || !currentAssetPath || autoSaveInFlight) return;
autoSaveInFlight = saveCurrentSequenceToBackend().catch(() => {}).finally(() => {
autoSaveInFlight = null;
});
await autoSaveInFlight;
}, 1200);
}
function markEditorDirty() {
if (isApplyingPersistedState) return;
hasUnsavedChanges = true;
scheduleBackgroundAutoSave();
}
nameInput?.addEventListener('input', () => {
markEditorDirty();
});
let selectionRange = null;
let selectionTrack = null;
let didDragSelect = false;
let dragAnchorStart = null;
let dragPointer = null;
let dragRaf = null;
let trackCellIndex = {};
let lastSelectedCells = new Set();
document.addEventListener('mousemove', (e) => {
if (dragAnchorStart == null) return;
dragPointer = { x: e.clientX, y: e.clientY };
});
let selectionRanges = [];
let lastGoodSelection = null;
function rememberLastSelection() {
const ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : [];
if (!selectionTrack || !ranges || !ranges.length) return;
lastGoodSelection = {
track: selectionTrack,
ranges: ranges.map(r => ({ start: r.start, end: r.end }))
};
}
let dragAppendIndex = null;
let dragAppending = false;
let suppressNextClick = false;
let suppressSearchInput = false;
let proteinColorMode = 'none';
let ntScheme = 'classic';
let fontSize = 22;
const highlights = [];
let residueScoreMap = [];
let proteinViewMode = 1;
let showRulers = true;
let rulerEvery = 3;
let showProtein = true;
let showDNA = false;
let showRNA = false;
let showRevComp = false;
let activeNucType = null;
let lastNucType = 'dna';
let lastSearchQuery = '';
let lastSearchIndex = -1;
let searchTimer = null;
let selectionFromSearch = false;
let lastAppliedSelectionTrack = null;
let searchAuthoredByUser = false;
let searchSelectionKey = '';
let searchSelectionCustom = false;
let downRange = null;
let downWasSelected = false;
let dragStartPointer = null;
let activeTouchId = null;
function getSequencePersistenceState() {
const ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : (selectionRanges || []);
const codonOverrides = [];
proteinCodonOverrides.forEach((value, key) => codonOverrides.push([key, value]));
return {
seqRaw,
name: nameInput?.value || '',
highlights: Array.isArray(highlights) ? JSON.parse(JSON.stringify(highlights)) : [],
residueScoreMap: Array.isArray(residueScoreMap) ? residueScoreMap.slice() : [],
selectionTrack,
selectionRange: selectionRange ? { ...selectionRange } : null,
selectionRanges: Array.isArray(ranges) ? ranges.map((r) => ({ ...r })) : [],
proteinColorMode,
ntScheme,
proteinViewMode,
showRulers,
rulerEvery,
showProtein,
showDNA,
showRNA,
showRevComp,
activeNucType,
lastNucType,
translationFrameOffset,
translationAutoCycle,
translationRanges,
groupModeIndex,
groupSize,
fontSize,
searchInputValue: searchInput?.value || '',
codonOverrides,
};
}
function serializeSequenceEditorState(fileDisplayName) {
return JSON.stringify({
schema_version: 1,
editor_type: 'sequence',
saved_at: new Date().toISOString(),
file_display_name: fileDisplayName || '',
sequence_state: getSequencePersistenceState(),
});
}
function applySequenceEditorState(state) {
if (!state || typeof state !== 'object') return;
isApplyingPersistedState = true;
try {
seqRaw = String(state.seqRaw || '');
if (nameInput) nameInput.value = String(state.name || '');
highlights.length = 0;
(state.highlights || []).forEach((h) => highlights.push(h));
residueScoreMap = Array.isArray(state.residueScoreMap) ? state.residueScoreMap.slice() : [];
proteinColorMode = String(state.proteinColorMode || 'none');
ntScheme = String(state.ntScheme || 'classic');
proteinViewMode = Number.isFinite(Number(state.proteinViewMode)) ? Number(state.proteinViewMode) : 1;
showRulers = !!state.showRulers;
rulerEvery = Number.isFinite(Number(state.rulerEvery)) ? Number(state.rulerEvery) : 3;
showProtein = state.showProtein !== false;
showDNA = !!state.showDNA;
showRNA = !!state.showRNA;
showRevComp = !!state.showRevComp;
activeNucType = state.activeNucType || null;
lastNucType = state.lastNucType || 'dna';
translationFrameOffset = Number.isFinite(Number(state.translationFrameOffset)) ? Number(state.translationFrameOffset) : 0;
translationAutoCycle = !!state.translationAutoCycle;
translationRanges = state.translationRanges || null;
groupModeIndex = Number.isFinite(Number(state.groupModeIndex)) ? Number(state.groupModeIndex) : 0;
groupSize = Number.isFinite(Number(state.groupSize)) ? Number(state.groupSize) : 0;
fontSize = Number.isFinite(Number(state.fontSize)) ? Number(state.fontSize) : fontSize;
proteinCodonOverrides.clear();
(state.codonOverrides || []).forEach((item) => {
if (Array.isArray(item) && item.length === 2) proteinCodonOverrides.set(item[0], item[1]);
});
selectionTrack = state.selectionTrack || null;
selectionRange = state.selectionRange || null;
selectionRanges = Array.isArray(state.selectionRanges) ? state.selectionRanges.map((r) => ({ ...r })) : [];
if (searchInput) searchInput.value = String(state.searchInputValue || '');
updateSeqViews();
syncViewToggleButtons?.();
updateSelectionStyles?.();
updateSelectionToolbar?.();
updateMeta?.();
updateEditButtonState?.();
lastState = seqRaw;
} finally {
isApplyingPersistedState = false;
}
}
function parseLegacyFasta(text) {
const raw = String(text || '');
return {
seqRaw: sanitizeAndGroup(raw).text,
name: '',
highlights: [],
residueScoreMap: [],
selectionTrack: null,
selectionRange: null,
selectionRanges: [],
};
}
function extractPersistedSequenceState(text) {
const raw = String(text || '').trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.sequence_state && typeof parsed.sequence_state === 'object') {
return parsed.sequence_state;
}
if (typeof parsed?.editor_content?.file_content === 'string') {
return extractPersistedSequenceState(parsed.editor_content.file_content);
}
if (typeof parsed?.file_content === 'string') {
return extractPersistedSequenceState(parsed.file_content);
}
} catch (_) {}
return null;
}
function syncBodyScrollLock() {
const seqOpen = seqModalBackdrop && !seqModalBackdrop.hidden;
const delOpen = deleteBackdrop && !deleteBackdrop.hidden;
const mutOpen = mutModalBackdrop && !mutModalBackdrop.hidden;
const open = seqOpen || delOpen || mutOpen;
document.documentElement.classList.toggle('modal-open', open);
document.body.classList.toggle('modal-open', open);
}
let __lastModalFocusEl = null;
let __focusTrapCleanup = null;
function __getFocusable(root) {
if (!root) return [];
const sel = [
'button:not([disabled])',
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
return Array.from(root.querySelectorAll(sel)).filter(el => {
if (el.hidden) return false;
// ignore elements that aren't actually visible
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
});
}
function __trapFocus(dialogEl) {
const onKeyDown = (e) => {
if (e.key !== 'Tab') return;
const focusables = __getFocusable(dialogEl);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}
function __transitionMs(el) {
if (!el) return 0;
const dur = getComputedStyle(el).transitionDuration || '0s';
const delays = dur.split(',').map(s => s.trim()).map(s => {
if (s.endsWith('ms')) return parseFloat(s);
if (s.endsWith('s')) return parseFloat(s) * 1000;
return 0;
});
return Math.max(0, ...delays, 0);
}
function openModalBackdrop(backdropEl, dialogEl) {
if (!backdropEl) return;
backdropEl.classList.remove('is-closing');
__lastModalFocusEl = document.activeElement;
backdropEl.hidden = false;
backdropEl.setAttribute('aria-hidden', 'false');
syncBodyScrollLock();
requestAnimationFrame(() => {
backdropEl.classList.add('is-open');
});
__focusTrapCleanup?.();
__focusTrapCleanup = dialogEl ? __trapFocus(dialogEl) : null;
requestAnimationFrame(() => {
const focusables = dialogEl ? __getFocusable(dialogEl) : [];
(focusables[0] || dialogEl || backdropEl).focus?.();
});
}
function closeModalBackdrop(backdropEl, dialogEl, onClosed) {
if (!backdropEl || backdropEl.hidden) return;
const active = document.activeElement;
if (dialogEl && active && dialogEl.contains(active)) {
(document.body).focus();
}
backdropEl.classList.remove('is-open');
backdropEl.classList.add('is-closing');
__focusTrapCleanup?.();
__focusTrapCleanup = null;
syncBodyScrollLock();
const ms = __transitionMs(backdropEl);
let done = false;
const finish = () => {
if (done) return;
done = true;
backdropEl.classList.remove('is-closing');
backdropEl.setAttribute('aria-hidden', 'true');
backdropEl.hidden = true;
syncBodyScrollLock();
__lastModalFocusEl?.focus?.();
__lastModalFocusEl = null;
onClosed?.();
};
const onEnd = (e) => {
if (e.target !== backdropEl) return;
backdropEl.removeEventListener('transitionend', onEnd);
finish();
};
backdropEl.addEventListener('transitionend', onEnd);
setTimeout(finish, Math.max(50, ms + 80)); //
}
function openSeqModal(prefillText = '') {
if (!seqModalBackdrop) return;
seqModalBackdrop.hidden = false;
syncBodyScrollLock();
const txt = String(prefillText || '');
if (seqModalInput) seqModalInput.value = txt;
populateSeqBlocksFromRaw(txt);
refreshSeqBlockMoveButtons();
syncHiddenModalText();
ensureAtLeastOneSeqBlock();
requestAnimationFrame(() => {
const first =
seqBlocksWrap?.querySelector('.seq-block-name') ||
seqBlocksWrap?.querySelector('.seq-block-seq');
first?.focus?.();
});
}
function closeSeqModal() {
if (!seqModalBackdrop) return;
seqModalBackdrop.hidden = true;
syncBodyScrollLock();
}
let mutDraft = null;
let mutActive = null;
let mutProtThree = false;
let mutViewNuc = 'dna';
let mutRevEditable = true;
let mutIndelAnchor = null;
const mutViewProtBtn = document.getElementById('mut-view-prot');
const mutViewNucBtn = document.getElementById('mut-view-nuc');
const mutViewRevBtn = document.getElementById('mut-view-rev');
function mutSyncViewButtons(){
if (mutViewNucBtn) mutViewNucBtn.textContent = String(mutViewNuc || 'dna').toUpperCase();
mutViewProtBtn?.classList.toggle('is-active', true);
mutViewNucBtn?.classList.toggle('is-active', true);
mutViewRevBtn?.classList.toggle('is-active', !!mutRevEditable);
}
mutViewProtBtn?.addEventListener('click', () => {
mutProtThree = !mutProtThree;
mutSyncViewButtons();
if (mutDraft) mutRender();
});
mutViewNucBtn?.addEventListener('click', () => {
mutViewNuc = (mutViewNuc === 'rna') ? 'dna' : 'rna';
mutSyncViewButtons();
if (mutDraft) mutRender();
});
mutViewRevBtn?.addEventListener('click', () => {
mutRevEditable = !mutRevEditable;
mutSyncViewButtons();
if (mutDraft) mutRender();
});
let mutMode = 'protein';
const MUT_ARROW_SVG = `
`;
let mutUndoStack = [];
let mutRedoStack = [];
function mutSnapshot(){
if (!mutDraft) return null;
return {
mode: mutMode,
entries: mutDraft.entries.map(e => ({ header: e.header || null, seq: String(e.seq || '') })),
meta: mutDraft.meta.map(m => ({
i: m.i,
header: m.header || null,
baseType: m.baseType,
chainStart: m.chainStart,
chainLen: m.chainLen,
segs: (m.segs || []).map(s => ({ start: s.start, end: s.end })),
updated: Array.from(m.updated || []),
inserted: Array.from(m.inserted || []),
nucType: m.nucType || null,
nucSeq: m.nucSeq || null,
changeLog: Array.isArray(m.changeLog) ? m.changeLog.map(x => ({ ...x })) : []
}))
};
}
function mutRestore(snap){
if (!snap) return;
mutMode = snap.mode || 'protein';
mutDraft = {
entries: snap.entries.map(e => ({ header: e.header, seq: String(e.seq || '').toUpperCase() })),
meta: snap.meta.map(m => ({
i: m.i,
header: m.header,
baseType: m.baseType,
chainStart: m.chainStart,
chainLen: m.chainLen,
segs: (m.segs || []).map(s => ({ start: s.start, end: s.end })),
updated: new Set(m.updated || []),
inserted: new Set(m.inserted || []),
nucType: m.nucType || null,
nucSeq: m.nucSeq || null,
changeLog: Array.isArray(m.changeLog) ? m.changeLog.map(x => ({ ...x })) : []
}))
};
mutApplyModeUI();
}
function mutPushHistory(){
const snap = mutSnapshot();
if (!snap) return;
mutUndoStack.push(snap);
mutRedoStack = [];
}
function mutUndo(){
if (!mutUndoStack.length) return;
const cur = mutSnapshot();
if (cur) mutRedoStack.push(cur);
const prev = mutUndoStack.pop();
mutRestore(prev);
mutRender();
}
function mutRedo(){
if (!mutRedoStack.length) return;
const cur = mutSnapshot();
if (cur) mutUndoStack.push(cur);
const next = mutRedoStack.pop();
mutRestore(next);
mutRender();
}
function mutApplyModeUI(){
const tabs = document.querySelectorAll('.mut-mode-tab');
tabs.forEach(btn => {
const mode = btn.getAttribute('data-mut-mode');
const on = mode === mutMode;
btn.classList.toggle('is-active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
});
}
function mutSetMode(nextMode){
mutMode = nextMode;
mutApplyModeUI();
if (mutDraft) mutRender();
}
document.getElementById('mut-mode-tabs')?.addEventListener('click', (e) => {
const btn = e.target?.closest?.('.mut-mode-tab');
if (!btn) return;
const mode = btn.getAttribute('data-mut-mode');
if (!mode) return;
mutSetMode(mode);
});
async function mutCopyActive(){
if (!mutActive || !mutDraft) return;
const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex);
if (!m) return;
const entry = mutDraft.entries[m.i];
const displayNucType = mutDisplayNucTypeFor(m.baseType);
let txt = '';
if (mutActive.role === 'aa') {
const idx = Number(mutActive.aaIndex || mutActive.codonIndex);
txt = String(entry.seq[idx - 1] || '');
} else if (mutActive.role === 'codon') {
const codonIndex = Number(mutActive.codonIndex);
const frameOffset = Number.isFinite(mutActive.frameOffset)
? mutActive.frameOffset
: mutFrameOffsetFor(m.baseType, m.chainStart);
const nuc = (m.baseType === 'protein' || m.baseType === 'mixed' || m.baseType === 'unknown')
? String(m.nucSeq || backTranslateProtein(entry.seq, displayNucType))
: String((displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq));
const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset);
txt = nuc.slice(off, off + 3) || '';
} else if (mutActive.role === 'nt') {
const codonIndex = Number(mutActive.codonIndex);
const ntIndex = Number(mutActive.ntIndex);
const frameOffset = Number.isFinite(mutActive.frameOffset)
? mutActive.frameOffset
: mutFrameOffsetFor(m.baseType, m.chainStart);
const nuc = (m.baseType === 'protein' || m.baseType === 'mixed' || m.baseType === 'unknown')
? String(m.nucSeq || backTranslateProtein(entry.seq, displayNucType))
: String((displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq));
const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset) + ntIndex;
txt = nuc[off] || '';
}
if (!txt) return;
if (typeof copyToClipboard === 'function') {
copyToClipboard(txt, 'Copied');
return;
}
try {
await navigator.clipboard.writeText(txt);
notify?.('success', 'Copied');
} catch {
notify?.('error', 'Copy failed');
}
}
function mutGetRanges() {
if (typeof getActiveRanges === 'function') {
const r = getActiveRanges();
return Array.isArray(r) ? r : [];
}
if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges;
if (selectionRange) return [selectionRange];
return [];
}
function mutMergeSegsLocal(segs){
const cleaned = (segs || [])
.map(s => ({
start: Math.min(s.start, s.end),
end: Math.max(s.start, s.end),
frameOffset: Number.isFinite(s.frameOffset) ? normFrameOffset(s.frameOffset) : null
}))
.filter(s => Number.isFinite(s.start) && Number.isFinite(s.end))
.sort((a,b) => (a.start - b.start) || ((a.frameOffset ?? -1) - (b.frameOffset ?? -1)));
if (!cleaned.length) return [];
const out = [cleaned[0]];
for (let i=1;i ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
.sort((a,b) => a.start - b.start);
if (!cleaned.length) return [];
const out = [cleaned[0]];
for (let i=1;i ({
header: e.header || null,
seq: String(e.seq || '').toUpperCase()
}));
let chainStart = 1;
const meta = [];
const defaultFrameOffset = normFrameOffset(translationFrameOffset);
const translationRangesByChain = Array.isArray(translationRanges)
? translationRanges
.map(r => ({ ...r }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
: [];
const splitRangeByTranslation = (startGlobal, endGlobal, chainStart) => {
if (startGlobal > endGlobal) return [];
const chainRanges = translationRangesByChain
.filter(r => Number(r.chainStart) === Number(chainStart))
.map(r => ({
start: Math.max(startGlobal, r.start),
end: Math.min(endGlobal, r.end),
frameOffset: normFrameOffset(r.frameOffset)
}))
.filter(r => r.start <= r.end)
.sort((a, b) => a.start - b.start);
if (!chainRanges.length) {
return [{ start: startGlobal, end: endGlobal, frameOffset: defaultFrameOffset }];
}
const out = [];
let cursor = startGlobal;
for (const r of chainRanges) {
if (cursor < r.start) {
out.push({ start: cursor, end: r.start - 1, frameOffset: defaultFrameOffset });
}
out.push({ ...r });
cursor = r.end + 1;
}
if (cursor <= endGlobal) {
out.push({ start: cursor, end: endGlobal, frameOffset: defaultFrameOffset });
}
return mutMergeSegsLocal(out);
};
for (let i = 0; i < draftEntries.length; i++) {
const seq = draftEntries[i].seq;
const baseType = detectSeqType(seq);
const start = chainStart;
const end = start + seq.length - 1;
const segs = [];
for (const r of ranges) {
const s = Math.max(start, r.start);
const e = Math.min(end, r.end);
if (s > e) continue;
if (baseType === 'dna' || baseType === 'rna') {
const splits = splitRangeByTranslation(s, e, start);
for (const seg of splits) {
segs.push({
start: (seg.start - start + 1),
end: (seg.end - start + 1),
frameOffset: normFrameOffset(seg.frameOffset)
});
}
} else {
segs.push({ start: (s - start + 1), end: (e - start + 1) });
}
}
if (segs.length) {
meta.push({
i,
header: draftEntries[i].header,
baseType,
chainStart: start,
chainLen: seq.length,
segs,
updated: new Set(),
inserted: new Set(),
nucType: null,
nucSeq: null,
changeLog: []
});
}
chainStart = end + 1;
}
if (!meta.length) return null;
return { entries: draftEntries, meta };
}
function mutOpen() {
if (!mutModalBackdrop) return;
const sm = mutSelectionMeta();
if (!sm) {
notify?.('error', 'Select something first');
return;
}
mutDraft = sm;
if (activeNucType === 'dna' || activeNucType === 'rna') {
mutViewNuc = activeNucType;
} else {
mutViewNuc = mutDraft.meta.some(x => x.baseType === 'rna') ? 'rna' : 'dna';
}
mutProtThree = (typeof proteinViewMode !== 'undefined' && proteinViewMode === 2);
mutRevEditable = true;
mutSyncViewButtons();
mutActive = null;
mutUndoStack = [];
mutRedoStack = [];
if (mutDraft.meta.some(x => x.baseType === 'dna')) mutMode = 'dna';
else if (mutDraft.meta.some(x => x.baseType === 'rna')) mutMode = 'rna';
else mutMode = 'protein';
mutDraft.meta.forEach(m => { m.changeLog = []; });
openModalBackdrop(mutModalBackdrop, mutModalBackdrop.querySelector('.kb-modal--mut'));
mutApplyModeUI();
mutRender();
}
function mutClose() {
if (!mutModalBackdrop) return;
closeModalBackdrop(
mutModalBackdrop,
mutModalBackdrop.querySelector('.kb-modal--mut'),
() => {
mutDraft = null;
mutActive = null;
if (mutMiniViewer) mutMiniViewer.innerHTML = '';
if (mutOptions) mutOptions.innerHTML = '';
if (mutPicked) mutPicked.textContent = 'Tap a residue / codon base to edit.';
if (mutActions) mutActions.hidden = true;
}
);
}
function mutCommit() {
if (!mutDraft) return;
const out = mutDraft.entries.map((e, entryIndex) => {
const meta = mutDraft.meta?.find(m => m.i === entryIndex) || null;
if (!meta) return { header: e.header, seq: e.seq };
const baseType = meta.baseType;
const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown');
if (isProteinLike) {
const nucType =
(meta.nucType === 'rna' || meta.nucType === 'dna')
? meta.nucType
: (mutViewNuc === 'rna' ? 'rna' : 'dna');
const prot = String(e.seq || '').toUpperCase();
const nucSeqRaw = String(meta.nucSeq || '').toUpperCase();
const key = entryKey({ header: e.header }, entryIndex);
if (nucSeqRaw && nucSeqRaw.length === prot.length * 3) {
const nucSeq = (nucType === 'rna') ? toRNA(nucSeqRaw) : toDNA(nucSeqRaw);
const def = backTranslateProtein(prot, nucType);
const hasOverride = toDNA(nucSeq) !== toDNA(def);
if (hasOverride) proteinCodonOverrides.set(key, { nucType, seq: nucSeq });
else proteinCodonOverrides.delete(key);
} else {
proteinCodonOverrides.delete(key);
}
return { header: e.header, seq: prot };
}
return { header: e.header, seq: String(e.seq || '').toUpperCase() };
});
const raw = entriesToRaw(out);
const grouped = sanitizeAndGroup(raw);
seqRaw = grouped.text;
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState?.();
mutClose();
notify?.('success', 'Saved');
}
function mutMakeGapAA(){
const span = document.createElement('span');
span.className = 'seq-cell mut-gap';
span.textContent = '…';
span.setAttribute('aria-hidden','true');
return span;
}
function mutMakeGapCodon(){
const cell = document.createElement('div');
cell.className = 'seq-triplet-cell mut-gap';
cell.textContent = '…';
cell.setAttribute('aria-hidden','true');
return cell;
}
function mutTitleForHeader(h, idx){
const t = String(h || '').replace(/^>\s*/, '').trim();
return t || `Sequence ${idx + 1}`;
}
function mutApplyActiveHighlight() {
if (!mutMiniViewer) return;
mutMiniViewer
.querySelectorAll('.mut-active, .selected')
.forEach(x => { x.classList.remove('mut-active'); x.classList.remove('selected'); });
if (!mutActive) return;
const entryIndex = String(mutActive.entryIndex);
let sel = null;
if (mutActive.role === 'aa') {
if (mutActive.aaIndex != null) {
sel = `[data-mut-role="aa"][data-entry-index="${entryIndex}"][data-aa-index="${mutActive.aaIndex}"]`;
} else if (mutActive.codonIndex != null) {
sel = `[data-mut-role="aa"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"]`;
}
} else if (mutActive.role === 'codon') {
sel = `[data-mut-role="codon"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"]`;
} else if (mutActive.role === 'nt') {
sel = `[data-mut-role="nt"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"][data-nt-index="${mutActive.ntIndex}"]`;
}
if (!sel) return;
const el = mutMiniViewer.querySelector(sel);
if (!el) return;
el.classList.add('mut-active', 'selected');
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function mutClearActiveSelection() {
mutActive = null;
mutIndelAnchor = null;
mutApplyActiveHighlight();
mutRefreshUI();
if (mutActions) mutActions.hidden = true;
}
function mutRender() {
if (!mutMiniViewer) return;
mutMiniViewer.innerHTML = '';
if (!mutDraft || !mutDraft.meta?.length) return;
mutMiniViewer.classList.toggle('is-prot-3', !!mutProtThree);
const viewer = mutMiniViewer;
const style = getComputedStyle(viewer);
const cellSize = parseFloat(style.getPropertyValue('--seq-cell-size')) || 32;
const gap = parseFloat(style.getPropertyValue('--seq-row-gap')) || 6;
const groupGap = getNumberCssVarPx(viewer, '--seq-group-gap', 10);
const labelW = getNumberCssVarPx(viewer, '--mut-label-w', 56);
const pad = 8;
const widthFor = (n) => {
const spacers = (groupSize > 0) ? Math.floor((n - 1) / groupSize) : 0;
const tracks = n + spacers;
return (n * cellSize) + (spacers * groupGap) + ((tracks - 1) * gap);
};
const viewerWidth = Math.max(
viewer.clientWidth || 0,
viewer.scrollWidth || 0,
viewer.getBoundingClientRect().width || 0
);
const usable = Math.max(200, (viewerWidth || 900) - labelW - pad);
let perLine = 1;
for (let n = 1; n <= 400; n++) {
if (widthFor(n) <= usable) perLine = n;
else break;
}
perLine = Math.max(8, perLine);
const makeGridTemplate = (n) => `${labelW}px ${buildRowTemplate(n)}`;
const colForUnit = (i1) => gridColumnForUnitIndex(i1) + 1;
const mutNucColorForHist = (base) => {
const scheme = ntSchemes[ntScheme] || ntSchemes.classic;
const b = String(base || '').toUpperCase().slice(0,1);
return scheme[b] || scheme.N || '#9fb7d6';
};
const historyInfoFor = (m, pos, baseType, displayNucType) => {
const items = Array.isArray(m.changeLog) ? m.changeLog : [];
if (!items.length) return null;
let insCount = 0, delCount = 0;
let insSide = null, delSide = null;
const tryAAFromFwdCodons = (fwdFrom, fwdTo) => {
if (String(fwdFrom || '').length !== 3 || String(fwdTo || '').length !== 3) return null;
const aaF = translateNuc(String(fwdFrom), displayNucType)[0] || 'X';
const aaT = translateNuc(String(fwdTo), displayNucType)[0] || 'X';
return (aaF !== aaT) ? { from: aaF, to: aaT } : null;
};
let bestAA = null;
let hasDirectAA = false;
for (const c of items) {
if (Number(c.pos) !== Number(pos)) continue;
if (c.kind === 'ins') { insCount += (Number(c.count) || 1); insSide = c.side || insSide; continue; }
if (c.kind === 'del') { delCount += (Number(c.count) || 1); delSide = c.side || delSide; continue; }
if (c.kind === 'aa') {
const from = String(c.from || 'X').toUpperCase().slice(0,1);
const to = String(c.to || 'X').toUpperCase().slice(0,1);
if (from !== to) {
bestAA = { from, to };
hasDirectAA = true;
}
continue;
}
if (!hasDirectAA && (c.kind === 'codon' || c.kind === 'nt')) {
const fwdFrom = c.fromFwdCodon || c.fromFwd;
const fwdTo = c.toFwdCodon || c.toFwd;
const aaMut = tryAAFromFwdCodons(fwdFrom, fwdTo);
if (aaMut) bestAA = aaMut;
}
}
if (!bestAA && !insCount && !delCount) return null;
const mutation = bestAA ? { type: 'aa', from: bestAA.from, to: bestAA.to } : null;
return { mutation, insCount, delCount, insSide, delSide };
};
const makePadAA = () => {
const span = document.createElement('span');
span.className = 'seq-cell seq-cell--pad';
span.textContent = '';
span.setAttribute('aria-hidden','true');
return span;
};
const makePadCodon = () => {
const cell = document.createElement('div');
cell.className = 'seq-triplet-cell seq-cell--pad';
cell.textContent = '';
cell.setAttribute('aria-hidden','true');
return cell;
};
const frag = document.createDocumentFragment();
for (const m of mutDraft.meta) {
const entry = mutDraft.entries[m.i];
const baseType = m.baseType;
const displayNucType = mutDisplayNucTypeFor(baseType);
const section = document.createElement('div');
section.className = 'mut-section';
const title = document.createElement('div');
title.className = 'mut-section-title';
title.textContent = mutTitleForHeader(m.header, m.i);
section.appendChild(title);
const cols = [];
m.segs.forEach((seg, si) => {
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
for (let aa = seg.start; aa <= seg.end; aa++) cols.push({ kind:'pos', aaIndex: aa });
} else {
const frameOffset = Number.isFinite(seg.frameOffset)
? seg.frameOffset
: mutFrameOffsetFor(baseType, m.chainStart);
const adjStart = (seg.start - 1) - frameOffset;
const adjEnd = (seg.end - 1) - frameOffset;
if (adjEnd >= 0) {
const codonStart = Math.floor(Math.max(adjStart, 0) / 3) + 1;
const codonEnd = Math.floor(Math.max(adjEnd, 0) / 3) + 1;
for (let c = codonStart; c <= codonEnd; c++) {
cols.push({ kind:'pos', codonIndex: c, frameOffset });
}
}
}
if (si < m.segs.length - 1) cols.push({ kind:'gap' });
});
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
m.nucType = displayNucType;
if (!m.nucSeq || m.nucSeq.length !== entry.seq.length * 3) {
m.nucSeq = backTranslateProtein(entry.seq, displayNucType);
} else {
m.nucSeq = (displayNucType === 'rna') ? toRNA(m.nucSeq) : toDNA(m.nucSeq);
}
}
const buildRowblock = (sliceRaw) => {
const slice = sliceRaw.slice();
const n = Math.max(1, slice.length);
const gridTemplate = makeGridTemplate(n);
const rowblock = document.createElement('div');
rowblock.className = 'mut-rowblock';
const histRow = document.createElement('div');
histRow.className = 'mut-history-lane';
histRow.style.gridTemplateColumns = gridTemplate;
const histLabel = document.createElement('div');
histLabel.className = 'mut-lane-label';
histLabel.textContent = '';
histRow.appendChild(histLabel);
slice.forEach((c, i) => {
const cell = document.createElement('div');
cell.className = 'mut-hist-cell';
cell.style.gridColumn = String(colForUnit(i + 1));
if (c.kind === 'pos') {
const pos = (c.aaIndex != null) ? c.aaIndex : c.codonIndex;
const info = historyInfoFor(m, pos, baseType, displayNucType);
if (info?.mutation) {
const wrap = document.createElement('span');
wrap.className = 'mut-hist-mutation';
const sFrom = document.createElement('span');
const sTo = document.createElement('span');
const arrowWrap = document.createElement('span');
arrowWrap.className = 'mut-hist-caret';
arrowWrap.textContent = '>';
const fromCh = info.mutation.from;
const toCh = info.mutation.to;
sFrom.textContent = fromCh;
sTo.textContent = toCh;
const globalPos = (Number(m.chainStart) || 1) + Number(pos) - 1;
if (info.mutation.type === 'aa') {
sFrom.style.setProperty('color', mutAAColorForHist(fromCh, globalPos), 'important');
sTo.style.setProperty('color', mutAAColorForHist(toCh, globalPos), 'important');
} else {
sFrom.style.setProperty('color', mutNucColorForHist(fromCh), 'important');
sTo.style.setProperty('color', mutNucColorForHist(toCh), 'important');
}
wrap.appendChild(sFrom);
wrap.appendChild(arrowWrap);
wrap.appendChild(sTo);
cell.appendChild(wrap);
}
}
histRow.appendChild(cell);
});
rowblock.appendChild(histRow);
// RULER
const ruler = document.createElement('div');
ruler.className = 'seq-ruler-row';
ruler.style.gridTemplateColumns = gridTemplate;
const rulerLabel = document.createElement('div');
rulerLabel.className = 'mut-lane-label';
rulerLabel.textContent = '';
ruler.appendChild(rulerLabel);
slice.forEach((c, i) => {
const tick = document.createElement('div');
tick.className = 'seq-tick';
tick.style.gridColumn = String(colForUnit(i + 1));
if (c.kind === 'pos') {
const pos = (c.aaIndex != null) ? c.aaIndex : c.codonIndex;
tick.textContent = String(pos);
tick.classList.add('seq-tick--label');
} else {
tick.classList.add('seq-tick--spacer');
tick.textContent = '';
}
ruler.appendChild(tick);
});
rowblock.appendChild(ruler);
const laneProt = document.createElement('div');
laneProt.className = 'seq-row';
laneProt.style.gridTemplateColumns = gridTemplate;
const laneNuc = document.createElement('div');
laneNuc.className = 'seq-row';
laneNuc.style.gridTemplateColumns = gridTemplate;
const laneAnti = document.createElement('div');
laneAnti.className = 'seq-row';
laneAnti.style.gridTemplateColumns = gridTemplate;
const labProt = document.createElement('div');
labProt.className = 'mut-lane-label';
labProt.textContent = 'Prot';
laneProt.appendChild(labProt);
const labNuc = document.createElement('div');
labNuc.className = 'mut-lane-label';
labNuc.textContent = displayNucType.toUpperCase();
laneNuc.appendChild(labNuc);
const labAnti = document.createElement('div');
labAnti.className = 'mut-lane-label';
labAnti.textContent = 'Comp';
laneAnti.appendChild(labAnti);
const insertedSet = m.inserted || new Set();
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
const prot = entry.seq;
const nuc = String(m.nucSeq || backTranslateProtein(prot, displayNucType));
slice.forEach((c, i) => {
const gridCol = String(colForUnit(i + 1));
if (c.kind !== 'pos') {
const isGap = (c.kind === 'gap');
const g1 = isGap ? mutMakeGapAA() : makePadAA();
const g2 = isGap ? mutMakeGapCodon() : makePadCodon();
const g3 = isGap ? mutMakeGapCodon() : makePadCodon();
g1.style.gridColumn = gridCol;
g2.style.gridColumn = gridCol;
g3.style.gridColumn = gridCol;
laneProt.appendChild(g1);
laneNuc.appendChild(g2);
laneAnti.appendChild(g3);
return;
}
const aaIndex = c.aaIndex;
const aa = prot[aaIndex - 1] || 'X';
const aaCell = document.createElement('span');
aaCell.className = 'seq-cell';
aaCell.style.gridColumn = gridCol;
aaCell.dataset.mutRole = 'aa';
aaCell.dataset.entryIndex = String(m.i);
aaCell.dataset.aaIndex = String(aaIndex);
aaCell.dataset.unit = aa;
setCellText(aaCell, mutProtThree ? aaToThree(aa) : aa);
if (insertedSet.has(aaIndex)) aaCell.classList.add('mut-inserted');
else if (m.updated.has(`aa:${aaIndex}`) || m.updated.has(`codon:${aaIndex}`)) aaCell.classList.add('mut-updated');
applyColorToResidue(aaCell, aa, (m.chainStart || 1) + aaIndex - 1);
laneProt.appendChild(aaCell);
const fwdCodon = (nuc.slice((aaIndex - 1) * 3, (aaIndex - 1) * 3 + 3) || '---').padEnd(3,'-');
const codonCell = document.createElement('div');
codonCell.className = 'seq-triplet-cell';
codonCell.style.gridColumn = gridCol;
codonCell.dataset.mutRole = 'codon';
codonCell.dataset.entryIndex = String(m.i);
codonCell.dataset.codonIndex = String(aaIndex);
if (insertedSet.has(aaIndex)) codonCell.classList.add('mut-inserted');
else if (m.updated.has(`codon:${aaIndex}`)) codonCell.classList.add('mut-updated');
for (let k=0;k<3;k++){
const b = fwdCodon[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.dataset.mutRole = 'nt';
mini.dataset.entryIndex = String(m.i);
mini.dataset.codonIndex = String(aaIndex);
mini.dataset.ntIndex = String(k);
mini.textContent = b;
applyColorToNuc(mini, b);
codonCell.appendChild(mini);
}
laneNuc.appendChild(codonCell);
const anti = antiCodonFromFwdCodon(fwdCodon, displayNucType);
const antiCell = document.createElement('div');
antiCell.className = 'seq-triplet-cell';
antiCell.style.gridColumn = gridCol;
antiCell.dataset.mutRole = 'codon';
antiCell.dataset.mutRev = '1';
antiCell.dataset.entryIndex = String(m.i);
antiCell.dataset.codonIndex = String(aaIndex);
if (!mutRevEditable) antiCell.dataset.mutReadonly = '1';
if (insertedSet.has(aaIndex)) antiCell.classList.add('mut-inserted');
for (let k=0;k<3;k++){
const b = anti[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.dataset.mutRole = 'nt';
mini.dataset.mutRev = '1';
mini.dataset.entryIndex = String(m.i);
mini.dataset.codonIndex = String(aaIndex);
mini.dataset.ntIndex = String(k);
if (!mutRevEditable) mini.dataset.mutReadonly = '1';
mini.textContent = b;
applyColorToNuc(mini, b);
antiCell.appendChild(mini);
}
laneAnti.appendChild(antiCell);
});
} else {
const base = entry.seq;
const displaySeq = (displayNucType === 'rna') ? toRNA(base) : toDNA(base);
slice.forEach((c, i) => {
const gridCol = String(colForUnit(i + 1));
if (c.kind !== 'pos') {
const isGap = (c.kind === 'gap');
const g1 = isGap ? mutMakeGapAA() : makePadAA();
const g2 = isGap ? mutMakeGapCodon() : makePadCodon();
const g3 = isGap ? mutMakeGapCodon() : makePadCodon();
g1.style.gridColumn = gridCol;
g2.style.gridColumn = gridCol;
g3.style.gridColumn = gridCol;
laneProt.appendChild(g1);
laneNuc.appendChild(g2);
laneAnti.appendChild(g3);
return;
}
const codonIndex = c.codonIndex;
const frameOffset = Number.isFinite(c.frameOffset)
? c.frameOffset
: mutFrameOffsetFor(baseType, m.chainStart);
const codonOffset = frameOffset + ((codonIndex - 1) * 3);
const fwdCodon = (displaySeq.slice(codonOffset, codonOffset + 3) || '---').padEnd(3,'-');
const aa = translateNuc(fwdCodon, displayNucType)[0] || 'X';
const aaCell = document.createElement('span');
aaCell.className = 'seq-cell';
aaCell.style.gridColumn = gridCol;
aaCell.dataset.mutRole = 'aa';
aaCell.dataset.entryIndex = String(m.i);
aaCell.dataset.codonIndex = String(codonIndex);
aaCell.dataset.frameOffset = String(frameOffset);
aaCell.dataset.unit = aa;
setCellText(aaCell, mutProtThree ? aaToThree(aa) : aa);
if (insertedSet.has(codonIndex)) aaCell.classList.add('mut-inserted');
else if (m.updated.has(`codon:${codonIndex}`)) aaCell.classList.add('mut-updated');
const residuePos = (m.chainStart || 1) + frameOffset + ((codonIndex - 1) * 3);
applyColorToResidue(aaCell, aa, residuePos);
laneProt.appendChild(aaCell);
const codonCell = document.createElement('div');
codonCell.className = 'seq-triplet-cell';
codonCell.style.gridColumn = gridCol;
codonCell.dataset.mutRole = 'codon';
codonCell.dataset.entryIndex = String(m.i);
codonCell.dataset.codonIndex = String(codonIndex);
codonCell.dataset.frameOffset = String(frameOffset);
if (insertedSet.has(codonIndex)) codonCell.classList.add('mut-inserted');
else if (m.updated.has(`codon:${codonIndex}`)) codonCell.classList.add('mut-updated');
for (let k=0;k<3;k++){
const b = fwdCodon[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.dataset.mutRole = 'nt';
mini.dataset.entryIndex = String(m.i);
mini.dataset.codonIndex = String(codonIndex);
mini.dataset.ntIndex = String(k);
mini.dataset.frameOffset = String(frameOffset);
mini.textContent = b;
applyColorToNuc(mini, b);
codonCell.appendChild(mini);
}
laneNuc.appendChild(codonCell);
const anti = antiCodonFromFwdCodon(fwdCodon, displayNucType);
const antiCell = document.createElement('div');
antiCell.className = 'seq-triplet-cell';
antiCell.style.gridColumn = gridCol;
antiCell.dataset.mutRole = 'codon';
antiCell.dataset.mutRev = '1';
antiCell.dataset.entryIndex = String(m.i);
antiCell.dataset.codonIndex = String(codonIndex);
antiCell.dataset.frameOffset = String(frameOffset);
if (!mutRevEditable) antiCell.dataset.mutReadonly = '1';
if (insertedSet.has(codonIndex)) antiCell.classList.add('mut-inserted');
for (let k=0;k<3;k++){
const b = anti[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.dataset.mutRole = 'nt';
mini.dataset.mutRev = '1';
mini.dataset.entryIndex = String(m.i);
mini.dataset.codonIndex = String(codonIndex);
mini.dataset.ntIndex = String(k);
mini.dataset.frameOffset = String(frameOffset);
if (!mutRevEditable) mini.dataset.mutReadonly = '1';
mini.textContent = b;
applyColorToNuc(mini, b);
antiCell.appendChild(mini);
}
laneAnti.appendChild(antiCell);
});
}
addDirectionLabels(laneProt, 'N-', '-C', 'mut-direction-label');
addDirectionLabels(laneNuc, '5′', '3′', 'mut-direction-label');
addDirectionLabels(laneAnti, '3′', '5′', 'mut-direction-label');
rowblock.appendChild(laneProt);
rowblock.appendChild(laneNuc);
rowblock.appendChild(laneAnti);
return rowblock;
};
for (let i=0;i 200) undoStack.shift();
}
if (Array.isArray(redoStack)) redoStack.length = 0;
} catch (_) {}
}
function buildCodonGroups(nucType){
const map = {};
Object.keys(geneticCodeDNA || {}).forEach(dna => {
const aa = geneticCodeDNA[dna] || 'X';
const key = aa;
if (!map[key]) map[key] = [];
map[key].push(normalizeCodonForType(dna, nucType));
});
const order = ['A','R','N','D','C','Q','E','G','H','I','L','K','M','F','P','S','T','W','Y','V','*','X'];
const out = [];
order.forEach(aa => {
const codons = (map[aa] || []).slice().sort();
if (!codons.length) return;
out.push({ aa, codons });
});
Object.keys(map).forEach(aa => {
if (order.includes(aa)) return;
out.push({ aa, codons: map[aa].slice().sort() });
});
return out;
}
function mutEnsureProteinNuc(m, entry, nucType){
m.nucType = nucType;
if (!m.nucSeq || m.nucSeq.length !== entry.seq.length * 3) {
m.nucSeq = backTranslateProtein(entry.seq, nucType);
} else {
m.nucSeq = (nucType === 'rna') ? toRNA(m.nucSeq) : toDNA(m.nucSeq);
}
}
function mutShiftInsertedSet(set, insertPos){
const out = new Set();
for (const v of (set || [])) {
const n = Number(v);
if (!Number.isFinite(n)) continue;
out.add(n >= insertPos ? (n + 1) : n);
}
return out;
}
function mutShiftInsertedSetAfterDelete(set, delIdx){
const out = new Set();
for (const v of (set || [])) {
const n = Number(v);
if (!Number.isFinite(n)) continue;
if (n === delIdx) continue;
out.add(n > delIdx ? (n - 1) : n);
}
return out;
}
function mutShiftUpdatedSetAfterInsert(updated, insertPos){
const out = new Set();
for (const key of (updated || [])) {
const m = String(key).match(/^(aa|codon):(\d+)$/);
if (!m) { out.add(key); continue; }
const k = m[1];
const n = Number(m[2]);
if (!Number.isFinite(n)) { out.add(key); continue; }
const nn = (n >= insertPos) ? (n + 1) : n;
out.add(`${k}:${nn}`);
}
return out;
}
function mutShiftUpdatedSetAfterDelete(updated, delIdx){
const out = new Set();
for (const key of (updated || [])) {
const m = String(key).match(/^(aa|codon):(\d+)$/);
if (!m) { out.add(key); continue; }
const k = m[1];
const n = Number(m[2]);
if (!Number.isFinite(n)) { out.add(key); continue; }
if (n === delIdx) continue;
const nn = (n > delIdx) ? (n - 1) : n;
out.add(`${k}:${nn}`);
}
return out;
}
function mutCoalesceIndels(changeLog){
if (!Array.isArray(changeLog) || !changeLog.length) return changeLog || [];
const map = new Map();
const out = [];
for (const rec of changeLog) {
const kind = rec?.kind;
if (kind !== 'ins' && kind !== 'del') {
out.push(rec);
continue;
}
const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after';
const pos = Number(rec.pos);
const key = `${kind}:${pos}:${side}`;
const prev = map.get(key);
if (!prev) {
const next = { ...rec, side, pos, key, count: Number(rec.count) || 1 };
map.set(key, next);
out.push(next);
} else {
prev.count = (Number(prev.count) || 1) + (Number(rec.count) || 1);
}
}
return out;
}
function mutReindexChangeLogAfterInsert(changeLog, insertPos){
if (!Array.isArray(changeLog)) return [];
for (const rec of changeLog) {
const pos = Number(rec.pos);
if (!Number.isFinite(pos)) continue;
if (pos === insertPos && (rec.kind === 'ins' || rec.kind === 'del') && rec.side === 'before') {
rec.pos = pos;
} else if (pos >= insertPos) {
rec.pos = pos + 1;
}
if (rec.kind === 'ins' || rec.kind === 'del') {
const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after';
rec.side = side;
rec.key = `${rec.kind}:${Number(rec.pos)}:${side}`;
}
}
return mutCoalesceIndels(changeLog);
}
function mutReindexChangeLogAfterDelete(changeLog, delIdx){
if (!Array.isArray(changeLog)) return [];
const kept = [];
for (const rec of changeLog) {
const pos = Number(rec.pos);
if (!Number.isFinite(pos)) { kept.push(rec); continue; }
if ((rec.kind === 'aa' || rec.kind === 'codon' || rec.kind === 'nt') && pos === delIdx) {
continue;
}
if (pos > delIdx) {
rec.pos = pos - 1;
} else if (pos === delIdx && (rec.kind === 'ins' || rec.kind === 'del')) {
if (rec.side === 'after') rec.pos = Math.max(1, delIdx - 1);
else rec.pos = delIdx;
}
if (rec.kind === 'ins' || rec.kind === 'del') {
const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after';
rec.side = side;
rec.key = `${rec.kind}:${Number(rec.pos)}:${side}`;
}
kept.push(rec);
}
return mutCoalesceIndels(kept);
}
function mutLogChange(m, rec){
if (!m.changeLog) m.changeLog = [];
const kind = String(rec.kind || '');
const pos = Number(rec.pos);
if (kind === 'ins' || kind === 'del') {
rec.side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after';
rec.count = Number(rec.count) || 1;
rec.key = rec.key || `${kind}:${pos}:${rec.side}`;
} else {
const sub = (rec.sub != null) ? String(rec.sub) : '';
const lane = (rec.onRev ? 'anti' : 'fwd');
rec.key = rec.key || `${kind}:${pos}:${sub}:${lane}`;
}
const idx = m.changeLog.findIndex(x => x.key === rec.key);
if (idx >= 0) {
const prev = m.changeLog[idx];
if ((kind === 'ins' || kind === 'del') && Number(prev.pos) === pos) {
const prevCount = Number(prev.count) || 1;
const add = Number(rec.count) || 1;
m.changeLog[idx] = { ...prev, ...rec, count: prevCount + add };
return;
}
m.changeLog[idx] = { ...prev, ...rec };
return;
}
m.changeLog.push({ ...rec });
}
function mutRefreshUI(){
if (!mutPicked || !mutOptions) return;
if (!mutActive){
mutPicked.textContent = 'Tap a residue / codon base to edit.';
mutOptions.innerHTML = '';
mutOptions.classList.remove('is-open');
if (mutActions) mutActions.hidden = true;
return;
}
const m = mutDraft?.meta?.find(x => x.i === mutActive.entryIndex);
const baseType = m?.baseType || 'unknown';
const displayNucType = mutDisplayNucTypeFor(baseType);
const onRev = !!mutActive.rev;
if (mutActive.role === 'aa') {
const idx = (mutActive.aaIndex || mutActive.codonIndex || '?');
mutPicked.textContent = `Residue ${idx}`;
} else if (mutActive.role === 'nt') {
mutPicked.textContent =
`${onRev ? 'Comp codon' : 'Codon'} ${mutActive.codonIndex} base ${Number(mutActive.ntIndex) + 1}`;
} else {
mutPicked.textContent = `${onRev ? 'Comp codon' : 'Codon'} ${mutActive.codonIndex}`;
}
if (mutActions) mutActions.hidden = false;
mutOptions.classList.remove('is-open');
mutOptions.innerHTML = '';
const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown');
const renderAAOptions = () => {
const aas = [
'V','I','L','M','F','Y','W',
'A','G','P','C',
'S','T','N','Q',
'D','E','K','R','H',
'X','*'
];
const wrap = document.createElement('div');
wrap.className = 'mut-opt-group';
wrap.innerHTML = `
Amino acids
${isProteinLike ? 'Edits AA + assigns default codon' : 'Edits via codon defaults'}
`;
const grid = document.createElement('div');
grid.className = 'mut-opt-grid';
aas.forEach(v => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'mut-opt';
b.textContent = mutProtThree ? aaToThree(v) : v;
b.dataset.aaGroup = aaGroupOf(v);
b.addEventListener('click', () => mutApply(v));
grid.appendChild(b);
});
wrap.appendChild(grid);
mutOptions.appendChild(wrap);
};
const renderCodonOptionsCompact = () => {
const groups = buildCodonGroups(displayNucType);
const wrap = document.createElement('div');
wrap.className = 'mut-opt-group';
wrap.innerHTML = `
${onRev ? 'Comp codons' : 'Codons'}
${displayNucType.toUpperCase()}
`;
const table = document.createElement('div');
table.className = 'mut-codon-table';
{
const row = document.createElement('div');
row.className = 'mut-codon-row';
const aa = document.createElement('div');
aa.className = 'mut-codon-aa';
aa.textContent = '—';
const codons = document.createElement('div');
codons.className = 'mut-codon-codons';
const b = document.createElement('button');
b.type = 'button';
b.className = 'mut-opt mut-opt--codon';
b.textContent = '---';
b.dataset.aaGroup = 'g0';
b.addEventListener('click', () => mutApply('---'));
codons.appendChild(b);
row.appendChild(aa);
row.appendChild(codons);
table.appendChild(row);
}
groups.forEach(g => {
const row = document.createElement('div');
row.className = 'mut-codon-row';
const aa = document.createElement('div');
aa.className = 'mut-codon-aa';
aa.textContent = (g.aa === '*') ? 'STOP' : (mutProtThree ? aaToThree(g.aa) : g.aa);
const codons = document.createElement('div');
codons.className = 'mut-codon-codons';
g.codons.forEach(c => {
const shown = onRev ? antiCodonFromFwdCodon(c, displayNucType) : c;
const b = document.createElement('button');
b.type = 'button';
b.className = 'mut-opt mut-opt--codon';
b.textContent = shown;
b.dataset.aaGroup = aaGroupOf(g.aa);
b.addEventListener('click', () => mutApply(shown));
codons.appendChild(b);
});
row.appendChild(aa);
row.appendChild(codons);
table.appendChild(row);
});
wrap.appendChild(table);
mutOptions.appendChild(wrap);
};
const renderNtOptions = () => {
const opts = (displayNucType === 'rna') ? ['A','C','G','U','N','-'] : ['A','C','G','T','N','-'];
const wrap = document.createElement('div');
wrap.className = 'mut-opt-group';
wrap.innerHTML = `
Bases
${displayNucType.toUpperCase()} ${onRev ? '(Comp view)' : ''}
`;
const grid = document.createElement('div');
grid.className = 'mut-opt-grid';
opts.forEach(v => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'mut-opt';
b.textContent = v;
b.dataset.aaGroup = 'g0';
b.addEventListener('click', () => mutApply(v));
grid.appendChild(b);
});
wrap.appendChild(grid);
mutOptions.appendChild(wrap);
};
if (mutActive.role === 'aa') renderAAOptions();
else if (mutActive.role === 'codon') renderCodonOptionsCompact();
else if (mutActive.role === 'nt') renderNtOptions();
requestAnimationFrame(() => {
mutOptions.classList.add('is-open');
});
}
function mutApply(value){
if (!mutDraft || !mutActive) return;
mutPushHistory();
const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex);
if (!m) return;
const entry = mutDraft.entries[m.i];
const baseType = m.baseType;
const displayNucType = mutDisplayNucTypeFor(baseType);
const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown');
const isNucLike = (baseType === 'dna' || baseType === 'rna');
const onRev = !!mutActive.rev;
const activeFrameOffset = Number.isFinite(mutActive.frameOffset)
? mutActive.frameOffset
: mutFrameOffsetFor(baseType, m.chainStart);
const normalizeNtChar = (ch) => {
const up = String(ch || 'N').toUpperCase().slice(0,1);
if (displayNucType === 'rna') return (up === 'T') ? 'U' : up;
return (up === 'U') ? 'T' : up;
};
const normalizeCodon = (codon) => {
let c = String(codon || '---').toUpperCase().replace(/[^ACGTU\-N]/g,'-');
c = c.padEnd(3,'-').slice(0,3);
if (displayNucType === 'rna') c = c.replace(/T/g,'U');
else c = c.replace(/U/g,'T');
return c;
};
const getForwardDisp = () => {
if (isProteinLike) {
mutEnsureProteinNuc(m, entry, displayNucType);
return String(m.nucSeq || '');
}
const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq);
return String(disp || '');
};
const setForwardDisp = (nextDisp) => {
if (isProteinLike) {
m.nucSeq = nextDisp;
entry.seq = translateNuc(m.nucSeq, displayNucType);
return;
}
entry.seq = (baseType === 'rna') ? toRNA(nextDisp) : toDNA(nextDisp);
};
const logDerivedAAFromCodons = (pos, fromFwdCodon, toFwdCodon) => {
const f = String(fromFwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3);
const t = String(toFwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3);
const fromAA = translateNuc(f, displayNucType)[0] || 'X';
const toAA = translateNuc(t, displayNucType)[0] || 'X';
if (fromAA !== toAA) {
m.updated.add(`aa:${pos}`);
mutLogChange(m, { key:`aa:${pos}`, kind:'aa', pos, from:fromAA, to:toAA });
}
};
if (mutActive.role === 'aa') {
const aa = String(value || 'X').toUpperCase().slice(0,1);
const idx = Number(mutActive.aaIndex || mutActive.codonIndex);
if (!Number.isFinite(idx) || idx < 1) return;
if (isProteinLike) {
mutEnsureProteinNuc(m, entry, displayNucType);
const fromAA = entry.seq[idx - 1] || 'X';
entry.seq = entry.seq.slice(0, idx - 1) + aa + entry.seq.slice(idx);
const codonDNA = backCodonDNA?.[aa] || 'NNN';
const codon = normalizeCodonForType(codonDNA, displayNucType);
const off = (idx - 1) * 3;
const fromCodon = (m.nucSeq || '').slice(off, off + 3) || '---';
m.nucSeq = (m.nucSeq || '').slice(0, off) + codon + (m.nucSeq || '').slice(off + 3);
m.updated.add(`aa:${idx}`);
m.updated.add(`codon:${idx}`);
mutLogChange(m, { key:`aa:${idx}`, kind:'aa', pos:idx, from:fromAA, to:aa });
mutLogChange(m, {
key:`codon:${idx}:fwd`,
kind:'codon',
pos:idx,
from:fromCodon,
to:codon,
onRev:false,
fromFwd:fromCodon,
toFwd:codon
});
mutRender();
return;
}
if (isNucLike) {
const codonIndex = idx;
const disp = getForwardDisp();
const off = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset);
const fromFwdCodon = (disp.slice(off, off + 3) || '---').padEnd(3,'-');
const codonDNA = backCodonDNA?.[aa] || 'NNN';
const codon = normalizeCodonForType(codonDNA, displayNucType);
const next = disp.slice(0, off) + codon + disp.slice(off + 3);
setForwardDisp(next);
m.updated.add(`codon:${codonIndex}`);
logDerivedAAFromCodons(codonIndex, fromFwdCodon, codon);
mutLogChange(m, {
key:`codon:${codonIndex}:fwd`,
kind:'codon',
pos:codonIndex,
from:fromFwdCodon,
to:codon,
onRev:false,
fromFwd:fromFwdCodon,
toFwd:codon
});
mutRender();
return;
}
mutRender();
return;
}
if (mutActive.role === 'nt') {
const codonIndex = Number(mutActive.codonIndex);
const ntIndexDisp = Number(mutActive.ntIndex);
if (!Number.isFinite(codonIndex) || codonIndex < 1 || !Number.isFinite(ntIndexDisp)) return;
const disp = getForwardDisp();
const codonOff = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset);
const fwdNtIndex = onRev ? antiIndexToFwdIndex(ntIndexDisp) : ntIndexDisp;
const off = codonOff + fwdNtIndex;
const fromFwdCodon = (disp.slice(codonOff, codonOff + 3) || '---').padEnd(3,'-');
const fromAntiCodon = antiCodonFromFwdCodon(fromFwdCodon, displayNucType);
const chDisp = normalizeNtChar(value);
const chFwd = onRev ? complementBase(chDisp, displayNucType) : chDisp;
const fromDispBase = onRev ? (fromAntiCodon[ntIndexDisp] || 'N') : (disp[off] || 'N');
const arr = disp.split('');
arr[off] = chFwd;
const nextDisp = arr.join('');
const toFwdCodonArr = fromFwdCodon.split('');
toFwdCodonArr[fwdNtIndex] = chFwd;
const toFwdCodon = toFwdCodonArr.join('');
setForwardDisp(nextDisp);
m.updated.add(`codon:${codonIndex}`);
logDerivedAAFromCodons(codonIndex, fromFwdCodon, toFwdCodon);
mutLogChange(m, {
key:`nt:${codonIndex}:${ntIndexDisp}:${onRev ? 'anti' : 'fwd'}`,
kind:'nt',
pos:codonIndex,
sub:(ntIndexDisp + 1),
from:fromDispBase,
to:chDisp,
onRev,
fromFwdCodon,
toFwdCodon
});
mutRender();
return;
}
if (mutActive.role === 'codon') {
const codonIndex = Number(mutActive.codonIndex);
if (!Number.isFinite(codonIndex) || codonIndex < 1) return;
const disp = getForwardDisp();
const codonDisp = normalizeCodon(value);
const off = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset);
const fromFwdCodon = (disp.slice(off, off + 3) || '---').padEnd(3,'-');
const fromDispCodon = onRev ? antiCodonFromFwdCodon(fromFwdCodon, displayNucType) : fromFwdCodon;
const toFwdCodon = onRev ? complementSeq(codonDisp, displayNucType) : codonDisp;
const next = disp.slice(0, off) + toFwdCodon + disp.slice(off + 3);
setForwardDisp(next);
m.updated.add(`codon:${codonIndex}`);
logDerivedAAFromCodons(codonIndex, fromFwdCodon, toFwdCodon);
mutLogChange(m, {
key:`codon:${codonIndex}:${onRev ? 'anti' : 'fwd'}`,
kind:'codon',
pos:codonIndex,
from:fromDispCodon,
to:codonDisp,
onRev,
fromFwd:fromFwdCodon,
toFwd:toFwdCodon
});
mutRender();
return;
}
}
mutMiniViewer?.addEventListener('click', (e) => {
const target = e.target;
mutIndelAnchor = null;
let el =
target.closest?.('[data-mut-role="nt"]') ||
target.closest?.('[data-mut-role="aa"]') ||
target.closest?.('[data-mut-role="codon"]');
if (!el) {
mutClearActiveSelection();
return;
}
if (el.dataset.mutRole === 'nt' && !e.shiftKey) {
const parentCodon = el.closest?.('[data-mut-role="codon"]');
if (parentCodon) el = parentCodon;
}
mutMiniViewer.querySelectorAll('.mut-active, .selected').forEach(x => {
x.classList.remove('mut-active');
x.classList.remove('selected');
});
el.classList.add('mut-active', 'selected');
const isReadonly = (el.dataset.mutReadonly === '1');
if (isReadonly) {
notify?.('error', 'This part is read-only');
return;
}
const entryIndex = Number(el.dataset.entryIndex);
const role = el.dataset.mutRole;
mutActive = { entryIndex, role };
mutActive.rev = (el.dataset.mutRev === '1');
if (el.dataset.frameOffset) mutActive.frameOffset = Number(el.dataset.frameOffset);
if (role === 'aa') {
if (el.dataset.aaIndex) mutActive.aaIndex = Number(el.dataset.aaIndex);
if (el.dataset.codonIndex) mutActive.codonIndex = Number(el.dataset.codonIndex);
} else {
mutActive.codonIndex = Number(el.dataset.codonIndex);
if (role === 'nt') mutActive.ntIndex = Number(el.dataset.ntIndex);
}
mutRefreshUI();
});
function mutShiftSegs(m, atLocal, delta){
const next = [];
for (const seg of m.segs) {
let s = seg.start, e = seg.end;
const frameOffset = Number.isFinite(seg.frameOffset) ? seg.frameOffset : null;
if (e < atLocal) { next.push({ start: s, end: e, frameOffset }); continue; }
if (s >= atLocal) { s += delta; e += delta; }
else { e += delta; }
if (e >= s) next.push({ start: s, end: e, frameOffset });
}
m.segs = next;
}
function mutInsert(where){
if (!mutDraft || !mutActive) return;
mutPushHistory();
const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex);
const entry = mutDraft.entries[m.i];
const baseType = m.baseType;
if (!m.inserted) m.inserted = new Set();
const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown');
const side = (where === 'before') ? 'before' : 'after';
const curIndex = isProteinLike
? Number(mutActive.aaIndex || mutActive.codonIndex)
: Number(mutActive.codonIndex);
if (!Number.isFinite(curIndex) || curIndex < 1) return;
const unitKind = isProteinLike ? 'aa' : 'codon';
if (!mutIndelAnchor || mutIndelAnchor.entryIndex !== m.i || mutIndelAnchor.side !== side || mutIndelAnchor.unit !== unitKind) {
mutIndelAnchor = { entryIndex: m.i, side, pos: curIndex, unit: unitKind };
}
const anchorPos = Number(mutIndelAnchor.pos) || curIndex;
if (isProteinLike) {
const idx = curIndex;
const insertPos = (where === 'after') ? (idx + 1) : idx;
const insPos0 = (where === 'after') ? idx : (idx - 1);
m.inserted = mutShiftInsertedSet(m.inserted, insertPos);
m.updated = mutShiftUpdatedSetAfterInsert(m.updated, insertPos);
m.changeLog = mutReindexChangeLogAfterInsert(m.changeLog, insertPos);
const shiftFrom = insertPos;
mutShiftSegs(m, shiftFrom, 1);
const displayNucType = mutDisplayNucTypeFor(baseType);
mutEnsureProteinNuc(m, entry, displayNucType);
entry.seq = entry.seq.slice(0, insPos0) + 'X' + entry.seq.slice(insPos0);
const offNt = insPos0 * 3;
const insCodon = (displayNucType === 'rna') ? 'NNN'.replace(/T/g,'U') : 'NNN';
m.nucSeq = m.nucSeq.slice(0, offNt) + insCodon + m.nucSeq.slice(offNt);
mutEnsureSegIncludes(m, insertPos, insertPos);
m.inserted.add(insertPos);
mutLogChange(m, { kind:'ins', pos: anchorPos, side, count: 1 });
mutActive = { entryIndex: m.i, role:'aa', aaIndex: insertPos, rev:false };
mutRender();
return;
}
const codonIndex = curIndex;
const insertCodonPos = (where === 'after') ? (codonIndex + 1) : codonIndex;
const displayNucType = mutDisplayNucTypeFor(baseType);
const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq);
const frameOffset = Number.isFinite(mutActive.frameOffset)
? mutActive.frameOffset
: mutFrameOffsetFor(baseType, m.chainStart);
m.inserted = mutShiftInsertedSet(m.inserted, insertCodonPos);
m.updated = mutShiftUpdatedSetAfterInsert(m.updated, insertCodonPos);
m.changeLog = mutReindexChangeLogAfterInsert(m.changeLog, insertCodonPos);
const insCodon = (displayNucType === 'rna') ? 'NNN'.replace(/T/g,'U') : 'NNN';
const baseOffset = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset);
const off = (where === 'after') ? (baseOffset + 3) : baseOffset;
const next = disp.slice(0, off) + insCodon + disp.slice(off);
entry.seq = (baseType === 'rna') ? toRNA(next) : toDNA(next);
const atLocalNt = off + 1;
mutShiftSegs(m, atLocalNt + 3, 3);
mutEnsureSegIncludes(m, atLocalNt, atLocalNt + 2, frameOffset);
m.inserted.add(insertCodonPos);
mutLogChange(m, { kind:'ins', pos: anchorPos, side, count: 1 });
mutActive = { entryIndex: m.i, role:'codon', codonIndex: insertCodonPos, rev:false, frameOffset };
mutRender();
}
function mutDelete(){
if (!mutDraft || !mutActive) return;
mutPushHistory();
const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex);
const entry = mutDraft.entries[m.i];
const baseType = m.baseType;
if (!m.inserted) m.inserted = new Set();
const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown');
mutIndelAnchor = null;
if (isProteinLike) {
const idx = Number(mutActive.aaIndex || mutActive.codonIndex);
if (!Number.isFinite(idx) || idx < 1 || idx > entry.seq.length) return;
const displayNucType = mutDisplayNucTypeFor(baseType);
mutEnsureProteinNuc(m, entry, displayNucType);
entry.seq = entry.seq.slice(0, idx - 1) + entry.seq.slice(idx);
const offNt = (idx - 1) * 3;
m.nucSeq = m.nucSeq.slice(0, offNt) + m.nucSeq.slice(offNt + 3);
m.inserted = mutShiftInsertedSetAfterDelete(m.inserted, idx);
m.updated = mutShiftUpdatedSetAfterDelete(m.updated, idx);
m.changeLog = mutReindexChangeLogAfterDelete(m.changeLog, idx);
mutShiftSegs(m, idx, -1);
const anchorPos = (idx > 1) ? (idx - 1) : 1;
const side = (idx > 1) ? 'after' : 'before';
mutLogChange(m, { kind:'del', pos: anchorPos, side, count: 1 });
mutRender();
return;
}
const codonIndex = Number(mutActive.codonIndex);
if (!Number.isFinite(codonIndex) || codonIndex < 1) return;
const displayNucType = mutDisplayNucTypeFor(baseType);
const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq);
const frameOffset = Number.isFinite(mutActive.frameOffset)
? mutActive.frameOffset
: mutFrameOffsetFor(baseType, m.chainStart);
const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset);
if (off + 3 > disp.length) return;
const next = disp.slice(0, off) + disp.slice(off + 3);
entry.seq = (baseType === 'rna') ? toRNA(next) : toDNA(next);
m.inserted = mutShiftInsertedSetAfterDelete(m.inserted, codonIndex);
m.updated = mutShiftUpdatedSetAfterDelete(m.updated, codonIndex);
m.changeLog = mutReindexChangeLogAfterDelete(m.changeLog, codonIndex);
const atLocalNt = off + 1;
mutShiftSegs(m, atLocalNt, -3);
const anchorPos = (codonIndex > 1) ? (codonIndex - 1) : 1;
const side = (codonIndex > 1) ? 'after' : 'before';
mutLogChange(m, { kind:'del', pos: anchorPos, side, count: 1 });
mutRender();
}
mutUndoBtn?.addEventListener('click', mutUndo);
mutRedoBtn?.addEventListener('click', mutRedo);
mutCopyBtn?.addEventListener('click', mutCopyActive);
mutInsertLeft?.addEventListener('click', () => mutInsert('before'));
mutInsertRight?.addEventListener('click', () => mutInsert('after'));
mutDeleteBtn?.addEventListener('click', mutDelete);
mutModalClose?.addEventListener('click', mutClose);
mutModalCancel?.addEventListener('click', mutClose);
mutModalSave?.addEventListener('click', mutCommit);
function mutIsInteractiveTarget(t) {
return !!t.closest?.('button, a, input, textarea, select, label, [role="button"]');
}
mutModalBackdrop?.addEventListener('mousedown', (e) => {
if (!mutModalBackdrop || mutModalBackdrop.hidden) return;
if (e.target === mutModalBackdrop) return;
if (!mutMiniViewer) return;
if (e.target.closest?.('#mut-mini-viewer')) return;
if (mutIsInteractiveTarget(e.target)) return;
const inModal = e.target.closest?.('.kb-modal, .kb-modal-body');
if (!inModal) return;
mutClearActiveSelection();
});
mutModalBackdrop?.addEventListener('click', (e) => {
if (e.target === mutModalBackdrop) mutClose();
});
mutateBtn?.addEventListener('click', () => {
const ranges = mutGetRanges();
if (!ranges.length) {
notify?.('error', 'Select residues / bases first');
return;
}
mutOpen();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mutModalBackdrop && !mutModalBackdrop.hidden) {
mutClose();
}
});
function bindAssetsPanelFileDrop(assetsPanel) {
if (!assetsPanel || assetsPanel.dataset.dropBound === '1') return;
assetsPanel.dataset.dropBound = '1';
const preventDropDefault = (e) => { e.preventDefault(); e.stopPropagation(); };
['dragenter', 'dragover'].forEach((evt) => {
assetsPanel.addEventListener(evt, (e) => {
preventDropDefault(e);
assetsPanel.classList.add('is-dragover');
});
});
['dragleave', 'drop'].forEach((evt) => {
assetsPanel.addEventListener(evt, (e) => {
preventDropDefault(e);
assetsPanel.classList.remove('is-dragover');
});
});
assetsPanel.addEventListener('drop', async (e) => {
if (!isMemberSignedIn) return;
const files = Array.from(e.dataTransfer?.files || []);
if (!files.length) return;
try {
const { jwt, payload } = await getEditorAuthContext();
setPanelOverlay(true, files.length > 1 ? 'Uploading files' : `Uploading ${files[0].name}`);
for (const file of files) {
const fileContent = await readFileAsText(file);
const fileName = String(file.name || `asset-${Date.now()}.txt`).trim();
const fileExtension = (getFileExtension(fileName) || 'txt').toLowerCase();
await saveEditorAsset(payload, {
editor_content: {
file_name: fileName,
file_extension: fileExtension,
file_content: fileContent,
ui_state: null,
}
}, jwt);
}
showToast?.('success', files.length > 1 ? 'Files uploaded' : 'File uploaded');
await refreshAssetsForCurrentContext();
} catch (err) {
showToast?.('error', err?.message || 'Failed to upload files');
} finally {
setPanelOverlay(false);
}
});
}
async function refreshAssetsForCurrentContext() {
if (!isMemberSignedIn) return;
const assetsPanel = document.querySelector('[data-editor-panel="assets"]');
if (!assetsPanel) return;
bindAssetsPanelFileDrop(assetsPanel);
const useOverlay = !suppressAssetsOverlayOnce && activeEditorTab === 'assets';
suppressAssetsOverlayOnce = false;
if (useOverlay) setPanelOverlay(true, 'Loading assets');
try {
const { jwt, payload, contextKey } = await getEditorAuthContext();
activeScopeId = contextKey;
const paths = await listEditorAssets(payload, jwt);
if (!paths.length) {
assetsPanel.innerHTML = `Click New or Upload to get started.

`;
const emptyState = assetsPanel.querySelector('.editor-assets-empty');
emptyState?.addEventListener('click', (e) => {
if (e.target?.closest('.editor-assets-actions') || e.target?.closest('.editor-assets-search-wrap')) return;
newTypeBackdrop.hidden = false;
syncBodyScrollLock();
});
return;
}
const cards = paths.map((path, index) => {
const safe = String(path || '');
const kind = assetKindFromPath(safe);
const label = assetTypeLabel(safe);
const cleanName = toCleanAssetLabel(safe);
const safePath = safe.replace(/"/g, '"');
const checkId = `editor-asset-check-${index}`;
return ``;
}).join('');
assetsPanel.innerHTML = `${cards}
`;
const assetsSearchInput = assetsPanel.querySelector('#editor-assets-search');
const selectedAssets = new Set();
const selectableCards = [...assetsPanel.querySelectorAll('[data-selectable-asset="true"]')];
const actionsWrap = assetsPanel.querySelector('.editor-assets-actions');
const duplicateBtn = actionsWrap?.querySelector('.editor-assets-action-btn--duplicate');
const downloadBtn = actionsWrap?.querySelector('.editor-assets-action-btn--download');
const deleteBtn = actionsWrap?.querySelector('.editor-assets-action-btn--delete');
const updateActionState = () => {
const active = selectedAssets.size > 0;
actionsWrap?.classList.toggle('is-active', active);
if (duplicateBtn) duplicateBtn.disabled = !active;
if (downloadBtn) downloadBtn.disabled = !active;
if (deleteBtn) deleteBtn.disabled = !active;
};
const setCardSelection = (card, selected) => {
if (!card) return;
const key = card.getAttribute('data-asset-path') || '';
if (!key) return;
if (selected) selectedAssets.add(key);
else selectedAssets.delete(key);
const check = card.querySelector('.editor-asset-check');
if (check) check.checked = !!selected;
card.classList.toggle('is-selected', !!selected);
card.setAttribute('aria-selected', selected ? 'true' : 'false');
updateActionState();
};
const clearSelection = () => {
selectableCards.forEach((card) => setCardSelection(card, false));
};
const getVisibleSelectableCards = () => selectableCards.filter((card) => !card.hidden);
const filterVisibleAssets = () => {
const term = (assetsSearchInput?.value || '').trim().toLowerCase();
const assetCards = [...assetsPanel.querySelectorAll('[data-selectable-asset="true"]')];
assetCards.forEach((card) => {
const fileName = (card.querySelector('strong')?.textContent || '').toLowerCase();
card.hidden = !!term && !fileName.includes(term);
});
};
assetsSearchInput?.addEventListener('input', filterVisibleAssets);
assetsPanel.querySelectorAll('.editor-asset-check').forEach((check) => {
check.addEventListener('click', (e) => {
e.stopPropagation();
const card = check.closest('[data-selectable-asset="true"]');
setCardSelection(card, !!check.checked);
});
});
assetsPanel.querySelectorAll('.editor-asset-open[data-asset-path]').forEach((btn) => {
btn.addEventListener('click', async () => {
const path = btn.getAttribute('data-asset-path') || '';
const ext = getFileExtension(path);
if (STRUCTURE_EXTENSIONS.has(ext)) return showToast?.('error', 'Structure editor coming soon');
if (LIGAND_EXTENSIONS.has(ext)) return showToast?.('error', 'Ligand editor coming soon');
if (!SEQUENCE_EXTENSIONS.has(ext)) return showToast?.('error', 'Unsupported asset type');
const shouldShowOverlay = activeEditorTab === 'assets';
if (shouldShowOverlay) setPanelOverlay(true, `Loading ${toCleanAssetLabel(path)}`);
try {
const { jwt, payload } = await getEditorAuthContext();
const data = await fetchEditorAsset(payload, path, jwt);
const content = String(data?.editor_content?.file_content || data?.file_content || '');
let restored = false;
const restoredState = extractPersistedSequenceState(content);
if (restoredState) {
applySequenceEditorState(restoredState);
restored = true;
}
if (!restored) applySequenceEditorState(parseLegacyFasta(content));
currentAssetPath = path;
hasUnsavedChanges = false;
selectEditorTab('sequence');
requestAnimationFrame(() => requestAnimationFrame(() => {
syncViewToggleButtons?.();
renderSequenceViewer?.();
}));
} catch (err) {
showToast?.('error', err?.message || 'Failed to open asset');
} finally {
if (shouldShowOverlay) setPanelOverlay(false);
}
});
});
let marqueeState = null;
let emptyCanvasPointerState = null;
const hasAssets = () => Boolean(assetsPanel.querySelector('[data-selectable-asset="true"]'));
assetsPanel.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('button, a, input, textarea, select, [role="button"]')) return;
if (!hasAssets()) {
emptyCanvasPointerState = { startX: e.clientX, startY: e.clientY, dragged: false };
return;
}
const panelRect = assetsPanel.getBoundingClientRect();
const marquee = document.createElement('div');
marquee.className = 'editor-assets-marquee';
assetsPanel.appendChild(marquee);
marqueeState = { startX: e.clientX, startY: e.clientY, panelRect, marquee };
clearSelection();
document.body.classList.add('editor-assets-marquee-active');
assetsPanel.setPointerCapture?.(e.pointerId);
});
assetsPanel.addEventListener('pointermove', (e) => {
if (!hasAssets()) {
if (!emptyCanvasPointerState) return;
const dx = Math.abs(e.clientX - emptyCanvasPointerState.startX);
const dy = Math.abs(e.clientY - emptyCanvasPointerState.startY);
if (dx > 4 || dy > 4) emptyCanvasPointerState.dragged = true;
return;
}
if (!marqueeState) return;
const x1 = Math.min(marqueeState.startX, e.clientX);
const y1 = Math.min(marqueeState.startY, e.clientY);
const x2 = Math.max(marqueeState.startX, e.clientX);
const y2 = Math.max(marqueeState.startY, e.clientY);
marqueeState.marquee.style.left = `${x1 - marqueeState.panelRect.left}px`;
marqueeState.marquee.style.top = `${y1 - marqueeState.panelRect.top}px`;
marqueeState.marquee.style.width = `${x2 - x1}px`;
marqueeState.marquee.style.height = `${y2 - y1}px`;
getVisibleSelectableCards().forEach((card) => {
const r = card.getBoundingClientRect();
const hit = !(r.right < x1 || r.left > x2 || r.bottom < y1 || r.top > y2);
setCardSelection(card, hit);
});
});
const endMarquee = (e) => {
if (!hasAssets()) {
if (emptyCanvasPointerState && !emptyCanvasPointerState.dragged) {
newTypeBackdrop.hidden = false;
syncBodyScrollLock();
}
emptyCanvasPointerState = null;
return;
}
if (!marqueeState) return;
assetsPanel.releasePointerCapture?.(e.pointerId);
marqueeState.marquee.remove();
marqueeState = null;
document.body.classList.remove('editor-assets-marquee-active');
};
assetsPanel.addEventListener('pointerup', endMarquee);
assetsPanel.addEventListener('pointercancel', () => {
emptyCanvasPointerState = null;
endMarquee({});
});
duplicateBtn?.addEventListener('click', async () => {
const targets = Array.from(selectedAssets);
if (!targets.length) return;
let shouldHideOverlay = false;
try {
const existing = [...paths];
let preferredNames = [];
try {
preferredNames = await requestDuplicateNames(targets, existing);
} catch (err) {
if (String(err?.message || '').toLowerCase() === 'cancelled') return;
throw err;
}
setPanelOverlay(true, targets.length > 1 ? 'Duplicating files' : `Duplicating ${toCleanAssetLabel(targets[0])}`);
shouldHideOverlay = true;
const { jwt, payload } = await getEditorAuthContext();
for (let i = 0; i < targets.length; i += 1) {
const targetPath = targets[i];
const preferredName = preferredNames[i] || '';
const created = await duplicateEditorAsset(payload, targetPath, jwt, existing, preferredName);
existing.push(created.path);
}
showToast?.('success', targets.length > 1 ? 'Assets duplicated' : 'Asset duplicated');
await refreshAssetsForCurrentContext();
} catch (err) {
showToast?.('error', err?.message || 'Failed to duplicate asset');
} finally {
if (shouldHideOverlay) setPanelOverlay(false);
}
});
downloadBtn?.addEventListener('click', async () => {
const targets = Array.from(selectedAssets);
if (!targets.length) return;
setPanelOverlay(true, targets.length > 1 ? 'Downloading files' : `Downloading ${toCleanAssetLabel(targets[0])}`);
try {
const { jwt, payload } = await getEditorAuthContext();
for (const path of targets) {
const data = await downloadEditorAsset(payload, path, jwt);
triggerEditorAssetDownload(data);
}
} catch (err) {
showToast?.('error', err?.message || 'Failed to download asset');
} finally {
setPanelOverlay(false);
}
});
deleteBtn?.addEventListener('click', () => {
const targets = Array.from(selectedAssets);
if (!targets.length) return;
openDeleteConfirm(targets);
});
updateActionState();
} catch (err) {
assetsPanel.innerHTML = '';
showToast?.('error', err?.message || 'Failed to list assets');
} finally {
if (useOverlay) setPanelOverlay(false);
}
}
function getActiveSequenceFileName() {
if (currentAssetPath) return currentAssetPath;
const baseName = stripTrailingExtension((nameInput?.value || 'sequence').trim() || 'sequence');
return `${baseName}.fasta`;
}
async function saveCurrentSequenceToBackend(opts = {}) {
const { member, jwt, payload } = await getEditorAuthContext();
const targetPayload = opts.payload || payload;
const fileName = getActiveSequenceFileName();
const ext = getFileExtension(fileName) || 'fasta';
const body = {
editor_content: {
file_name: fileName,
file_extension: ext.toLowerCase(),
file_content: serializeSequenceEditorState(stripTrailingExtension(fileName)),
ui_state: getSequencePersistenceState(),
}
};
if (currentAssetPath) body.file_name = currentAssetPath;
const saved = await saveEditorAsset(targetPayload, body, jwt);
currentAssetPath = currentAssetPath || fileName;
hasUnsavedChanges = false;
return saved;
}
async function handleContextSwitch(ctxValue) {
const previousCtx = activeScopeId;
try {
const member = await getCurrentMemberData();
const oldPayload = window.ViciContext?.payloadFor?.(member.id) || { user_id: member.id };
const hasOpen = Boolean(String(seqRaw || '').trim().length || currentAssetPath);
if (hasOpen && hasUnsavedChanges) {
try { await withEditorSync('Syncing', async () => saveCurrentSequenceToBackend({ payload: oldPayload })); } catch (_) {}
}
} catch (_) {}
clearSequenceState();
currentAssetPath = null;
hasUnsavedChanges = false;
await withEditorSync('Switching context', async () => refreshAssetsForCurrentContext());
}
function clearSequenceState() {
seqRaw = '';
residueScoreMap = [];
highlights.length = 0;
proteinCodonOverrides.clear();
selectionRange = null;
selectionTrack = null;
selectionRanges = [];
if (nameInput) nameInput.value = '';
if (lenEl) lenEl.textContent = '–';
if (posEl) posEl.textContent = '–';
applyTypePill('unknown');
applyDefaultViewToggles('unknown');
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState();
hasUnsavedChanges = false;
}
async function saveCurrentFileBestEffort() {
try {
await withEditorSync('Syncing', async () => saveCurrentSequenceToBackend());
await withEditorSync('Syncing', async () => refreshAssetsForCurrentContext());
} catch (err) {
throw err;
}
}
function applyModalTextToViewer(text, fileName = '') {
const persistedState = extractPersistedSequenceState(text);
if (persistedState) {
applySequenceEditorState(persistedState);
if (fileName && nameInput && !nameInput.value) nameInput.value = stripTrailingExtension(fileName);
if (!isApplyingPersistedState) {
markEditorDirty();
currentAssetPath = null;
}
updateEditButtonState();
return true;
}
const raw = String(text || '').trim();
if (!raw) {
clearSequenceState();
return true;
}
if (/^\s*(ATOM|HETATM)\b/m.test(raw)) {
const entries = parseStructureFile(raw);
if (entries && entries.length) {
applyStructuredEntries(entries);
return true;
}
}
const grouped = sanitizeAndGroup(raw);
seqRaw = grouped.text;
proteinCodonOverrides.clear();
const processed = processRaw(seqRaw);
ensureScoreMapLength(processed.length);
applyDefaultViewToggles(detectSeqType(processed.seq));
updateSeqViews();
lastState = seqRaw;
if (!isApplyingPersistedState) {
markEditorDirty();
currentAssetPath = null;
}
updateEditButtonState();
return true;
}
function attachCanvasFileDrop() {
if (!seqViewer) return;
if (seqViewer.dataset.dropBound === '1') return;
seqViewer.dataset.dropBound = '1';
const prevent = (e) => { e.preventDefault(); e.stopPropagation(); };
['dragenter', 'dragover'].forEach((evt) => {
seqViewer.addEventListener(evt, (e) => {
prevent(e);
seqViewer.classList.add('is-dragover');
}, { passive: false });
});
['dragleave', 'drop'].forEach((evt) => {
seqViewer.addEventListener(evt, (e) => {
prevent(e);
seqViewer.classList.remove('is-dragover');
}, { passive: false });
});
seqViewer.addEventListener('drop', async (e) => {
prevent(e);
if (e.__viciSeqDropHandled) return;
e.__viciSeqDropHandled = true;
const f = e.dataTransfer?.files?.[0];
if (!f) return;
const text = await readFileAsText(f);
const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent();
if (has) {
try {
await saveCurrentFileBestEffort();
showToast?.('success', 'Saved');
} catch (_) {}
}
clearSelection();
hardClearSearchUI();
await nextFrame();
applyModalTextToViewer(text, f.name);
updateEditButtonState?.();
showToast?.('success', 'Loaded');
}, { passive: false });
}
attachCanvasFileDrop();
(async () => {
isMemberSignedIn = await detectMemberSignIn();
const hashTarget = String(window.location.hash || '').replace(/^#/, '').toLowerCase();
const fallbackTab = isMemberSignedIn ? 'assets' : 'sequence';
const desiredTab = ['assets', 'sequence', 'structure', 'ligand'].includes(hashTarget) ? hashTarget : fallbackTab;
selectEditorTab(desiredTab === 'assets' && !isMemberSignedIn ? 'sequence' : desiredTab);
updateHeaderActionsForTab();
})();
ensureAtLeastOneSeqBlock();
refreshSeqBlockMoveButtons();
syncHiddenModalText();
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ''));
r.onerror = () => reject(r.error || new Error('Failed to read file'));
r.readAsText(file);
});
}
async function handleSeqModalFile(file) {
if (!file) return;
const text = await readFileAsText(file);
const persisted = extractPersistedSequenceState(text);
const modalText = persisted ? String(persisted.seqRaw || '') : text;
if (seqModalInput) seqModalInput.value = modalText;
await nextFrame();
populateSeqBlocksFromRaw(modalText);
syncHiddenModalText();
showToast?.('success', 'Loaded into modal');
updateEditButtonState();
}
function hasSequenceWorkingState() {
const hasCanvasContent = (String(seqRaw || '').trim().length > 0) || viewerHasContent() || !!currentAssetPath;
return !!hasCanvasContent;
}
function updateHeaderActionsForTab() {
if (!newFileBtn || !editFileBtn) return;
if (activeEditorTab === 'assets') {
newFileBtn.innerHTML = 'New';
editFileBtn.innerHTML = `${UPLOAD_SVG}Upload`;
return;
}
const has = hasSequenceWorkingState();
if (activeEditorTab === 'sequence' && has) {
newFileBtn.innerHTML = `${EDIT_PENCIL_SVG}Edit`;
editFileBtn.innerHTML = `Save`;
return;
}
newFileBtn.innerHTML = 'New';
editFileBtn.innerHTML = `${UPLOAD_SVG}Upload`;
}
editFileBtn?.addEventListener('click', async () => {
if (!isMemberSignedIn) {
redirectToSignUp();
return;
}
if (activeEditorTab === 'sequence' && hasSequenceWorkingState()) {
try {
await saveCurrentFileBestEffort();
} catch (err) {
showToast?.('error', err?.message || 'Failed to save before closing');
return;
}
clearSequenceState();
currentAssetPath = null;
hasUnsavedChanges = false;
updateHeaderActionsForTab();
showToast?.('success', 'Saved and closed');
return;
}
if (activeEditorTab === 'assets') {
newTypeBackdrop.hidden = false;
syncBodyScrollLock();
return;
}
if (activeEditorTab !== 'sequence') {
showToast?.('info', `${activeEditorTab[0].toUpperCase()}${activeEditorTab.slice(1)} tools coming soon`);
return;
}
openSeqModal(seqRaw || '');
});
seqModalClose?.addEventListener('click', closeSeqModal);
seqModalBackdrop?.addEventListener('click', (e) => {
if (e.target === seqModalBackdrop) closeSeqModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && seqModalBackdrop && !seqModalBackdrop.hidden) {
closeSeqModal();
}
});
seqModalClear?.addEventListener('click', () => {
populateSeqBlocksFromRaw('');
syncHiddenModalText();
});
seqModalApply?.addEventListener('click', async () => {
syncHiddenModalText();
await nextFrame();
applyModalTextToViewer(seqModalInput?.value || '');
closeSeqModal();
});
seqModalFileInput?.addEventListener('change', async () => {
const f = seqModalFileInput.files && seqModalFileInput.files[0];
if (!f) return;
await handleSeqModalFile(f);
seqModalFileInput.value = '';
});
if (seqModalDropzone) {
const prevent = (e) => { e.preventDefault(); e.stopPropagation(); };
['dragenter', 'dragover'].forEach((evt) => {
seqModalDropzone.addEventListener(evt, (e) => {
prevent(e);
seqModalDropzone.classList.add('is-dragover');
});
});
['dragleave', 'drop'].forEach((evt) => {
seqModalDropzone.addEventListener(evt, (e) => {
prevent(e);
seqModalDropzone.classList.remove('is-dragover');
});
});
seqModalDropzone.addEventListener('drop', async (e) => {
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) await handleSeqModalFile(f);
});
}
function syncActiveNucType() {
if (showRNA) {
activeNucType = 'rna';
lastNucType = 'rna';
return;
}
if (showDNA) {
activeNucType = 'dna';
lastNucType = 'dna';
return;
}
activeNucType = null;
}
function getPreferredNucType(baseType) {
return activeNucType || lastNucType || baseType;
}
const palette = {
aqua: '#00f0ff',
bolt: '#1e6cff',
ultraviolet: '#a158ff',
pulse: '#ff4df8',
sunrise: '#ffca2b',
ember: '#ff6b3d',
neon: '#1fff9f',
rose: '#ff3b81'
};
const swatchKeys = Object.keys(palette);
const seqPalette = { blue: '#4fc4ff', green: '#1ed573', red: '#ff6b6b', purple: '#b388ff' };
const ntSchemes = {
classic: { A:'#4fc4ff', C:'#1fff9f', G:'#ffca2b', T:'#ff3b81', U:'#ff3b81', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' },
muted: { A:'#9fb7d6', C:'#9fb7d6', G:'#9fb7d6', T:'#9fb7d6', U:'#9fb7d6', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' },
neon: { A:'#00f0ff', C:'#1fff9f', G:'#ff4df8', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' },
pastel: { A:'#8bd3ff', C:'#a7f3d0', G:'#fde68a', T:'#f9a8d4', U:'#f9a8d4', N:'#cbd5e1', R:'#cbd5e1', Y:'#cbd5e1', S:'#cbd5e1', W:'#cbd5e1', K:'#cbd5e1', M:'#cbd5e1', B:'#cbd5e1', D:'#cbd5e1', H:'#cbd5e1', V:'#cbd5e1', '-':'#cbd5e1' },
highcontrast:{ A:'#00b86b', C:'#2ba3f9', G:'#ff4b5c', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' },
purinepyr: { A:'#00b86b', G:'#00b86b', C:'#2ba3f9', T:'#2ba3f9', U:'#2ba3f9', N:'#9fb7d6', R:'#00b86b', Y:'#2ba3f9', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' },
gc_at: { G:'#00b86b', C:'#00b86b', A:'#ffca2b', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#00b86b', W:'#ffca2b', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }
};
const notify = (type, message) => {
if (typeof showToast === 'function') showToast(type, message);
};
const isLocked = () => lockBtn?.classList.contains('is-locked');
function ensureScoreMapLength(len) {
if (residueScoreMap.length > len) residueScoreMap = residueScoreMap.slice(0, len);
else if (residueScoreMap.length < len) residueScoreMap = residueScoreMap.concat(new Array(len - residueScoreMap.length).fill(null));
}
function scoreToColor(value) {
if (value === null || Number.isNaN(value)) return null;
const clamped = Math.max(0, Math.min(100, value));
if (clamped >= 80) return seqPalette.red;
if (clamped >= 60) return seqPalette.blue;
if (clamped <= 20) return seqPalette.green;
return null;
}
function processRaw(raw) {
let seq = '';
let inHeaderLine = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (ch === '\n' || ch === '\r') { inHeaderLine = false; continue; }
if (ch === '>' && (i === 0 || raw[i - 1] === '\n' || raw[i - 1] === '\r')) {
inHeaderLine = true;
continue;
}
if (inHeaderLine) continue;
if (/[A-Za-z]/.test(ch)) seq += ch.toUpperCase();
}
return { seq, length: seq.length };
}
function sanitizeAndGroup(raw) {
if (!raw) return { text: '' };
const norm = raw.replace(/\r\n?/g, '\n');
const lines = norm.split('\n');
const entries = [];
let currentHeader = null;
let currentSeq = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
if (line.startsWith('>')) {
if (currentHeader !== null || currentSeq) {
entries.push({ header: currentHeader, seq: currentSeq });
currentSeq = '';
}
currentHeader = line;
} else {
const seqPart = line.replace(/[^A-Za-z]/g, '').toUpperCase();
if (seqPart) currentSeq += seqPart;
}
}
if (currentHeader !== null || currentSeq) entries.push({ header: currentHeader, seq: currentSeq });
if (!entries.length) return { text: '' };
const parts = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.header) parts.push(entry.header);
if (entry.seq) parts.push(entry.seq);
if (i < entries.length - 1) parts.push('');
}
return { text: parts.join('\n') };
}
function getPrimarySelectableTrack() {
if (selectionTrack && seqViewer.querySelector(`[data-track="${selectionTrack}"][data-pos-start]`)) {
return selectionTrack;
}
const first = seqViewer.querySelector('[data-track][data-pos-start]');
return first?.getAttribute('data-track') || null;
}
function getVisibleSearchTracks(){
const tracks = new Set();
seqViewer.querySelectorAll('[data-track]').forEach(el => {
const t = el.getAttribute('data-track');
if (t) tracks.add(t);
});
return Array.from(tracks);
}
function getSearchTrackOrder(){
const primary = getPrimarySelectableTrack();
const visible = getVisibleSearchTracks();
if (!primary) return visible;
return [primary, ...visible.filter(t => t !== primary)];
}
function parseUserRanges(input, maxLen, { allowPartial = false } = {}) {
const s = String(input || '')
.replace(/[–—]/g, '-')
.replace(/\s+/g, '')
.trim();
if (!s) return null;
const nums = s.match(/\d+/g);
if (!nums || !nums.length) return null;
const n1 = parseInt(nums[0], 10);
const n2 = nums.length >= 2 ? parseInt(nums[1], 10) : NaN;
if (!Number.isFinite(n1)) return null;
const clampTo = (v) => {
if (!Number.isFinite(v)) return null;
if (maxLen && Number.isFinite(maxLen)) return Math.max(1, Math.min(maxLen, v));
return Math.max(1, v);
};
const hasDash = s.includes('-');
if (hasDash && allowPartial && (!Number.isFinite(n2))) {
const a = clampTo(n1);
if (!a) return null;
return { start: a, end: a };
}
if (!Number.isFinite(n2)) {
const a = clampTo(n1);
if (!a) return null;
return { start: a, end: a };
}
const a = clampTo(n1);
const b = clampTo(n2);
if (!a || !b) return null;
return { start: Math.min(a, b), end: Math.max(a, b) };
}
function parseUserRangesMulti(input, maxLen, { allowPartial = false } = {}) {
const raw = String(input || '')
.replace(/[–—]/g, '-')
.replace(/\s+/g, ' ')
.trim();
if (!raw) return null;
const toks = raw.split(/[,\n;]+/).map(t => t.trim()).filter(Boolean);
if (!toks.length) return null;
const parseOne =
(typeof parseUserRanges === 'function')
? parseUserRanges
: function (tok, maxLen2, { allowPartial: allowPartial2 = false } = {}) {
const s = String(tok || '')
.replace(/[–—]/g, '-')
.replace(/\s+/g, '')
.trim();
if (!s) return null;
const nums = s.match(/\d+/g);
if (!nums || !nums.length) return null;
const n1 = parseInt(nums[0], 10);
const n2 = nums.length >= 2 ? parseInt(nums[1], 10) : NaN;
if (!Number.isFinite(n1)) return null;
const clampTo = (v) => {
if (!Number.isFinite(v)) return null;
if (maxLen2 && Number.isFinite(maxLen2)) return Math.max(1, Math.min(maxLen2, v));
return Math.max(1, v);
};
const hasDash = s.includes('-');
if (hasDash && allowPartial2 && (!Number.isFinite(n2))) {
const a = clampTo(n1);
if (!a) return null;
return { start: a, end: a };
}
if (!Number.isFinite(n2)) {
const a = clampTo(n1);
if (!a) return null;
return { start: a, end: a };
}
const a = clampTo(n1);
const b = clampTo(n2);
if (!a || !b) return null;
return { start: Math.min(a, b), end: Math.max(a, b) };
};
const out = [];
for (const tok of toks) {
const r = parseOne(tok, maxLen, { allowPartial });
if (r) out.push(r);
}
return out.length ? out : null;
}
function getRangeInputContext() {
const track = selectionTrack || getPrimarySelectableTrack() || 'protein';
const anchor =
(selectionRange ? findCellForTrackPos(track, selectionRange.start) : null) ||
seqViewer.querySelector(`[data-track="${track}"][data-pos-start]`);
const ctx = getContextFromElement(anchor);
const chainStart = Number(ctx.chainStart) || 1;
const chainLen = Number(ctx.chainLen) || processRaw(seqRaw).length;
let maxInputLen = processRaw(seqRaw).length;
if (track === 'translation') {
maxInputLen = Math.max(1, Math.floor(chainLen / 3));
} else if (track === 'triplet' || track === 'triplet-rev') {
maxInputLen = Math.max(1, chainLen * 3);
}
const toSelectionRange = (r) => {
if (!r) return null;
if (track === 'translation') {
const aaStart = r.start;
const aaEnd = r.end;
const startNt = chainStart + ((aaStart - 1) * 3);
const endNt = chainStart + (aaEnd * 3) - 1;
return { start: startNt, end: endNt };
}
if (track === 'triplet' || track === 'triplet-rev') {
const ntStart = r.start;
const ntEnd = r.end;
const aaStart = Math.floor((ntStart - 1) / 3) + 1;
const aaEnd = Math.floor((ntEnd - 1) / 3) + 1;
return {
start: chainStart + aaStart - 1,
end: chainStart + aaEnd - 1
};
}
return { start: r.start, end: r.end };
};
return { track, maxInputLen, toSelectionRange };
}
function getAutoScrollElement() {
if (seqViewer && (seqViewer.scrollHeight > seqViewer.clientHeight + 2)) return seqViewer;
return document.scrollingElement || document.documentElement;
}
function tickDragAutoScroll() {
if (dragAnchorStart === null || !dragPointer) { dragRaf = null; return; }
const scroller = getAutoScrollElement();
const rect = scroller === seqViewer
? scroller.getBoundingClientRect()
: { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
const viewTop = Math.max(rect.top, 0);
const viewBottom = Math.min(rect.bottom, window.innerHeight);
const viewH = Math.max(1, viewBottom - viewTop);
const edge = Math.max(48, Math.min(110, viewH * 0.18));
let vy = 0;
if (dragPointer.y < viewTop + edge) {
const d = (viewTop + edge - dragPointer.y);
vy = -Math.min(28, Math.max(6, d * 0.22));
} else if (dragPointer.y > viewBottom - edge) {
const d = (dragPointer.y - (viewBottom - edge));
vy = Math.min(28, Math.max(6, d * 0.22));
}
if (vy !== 0) {
const prevTop = (scroller === seqViewer) ? scroller.scrollTop : (window.scrollY || 0);
if (scroller === seqViewer) {
scroller.scrollTop += vy;
} else {
window.scrollBy(0, vy);
}
const newTop = (scroller === seqViewer) ? scroller.scrollTop : (window.scrollY || 0);
if (newTop !== prevTop) {
const probeY = vy > 0 ? (viewBottom - 3) : (viewTop + 3);
const r = getRangeFromPoint(dragPointer.x, probeY, selectionTrack || null);
if (r && r.track && (!selectionTrack || r.track === selectionTrack)) {
setSelection(
dragAnchorStart,
r.end,
dragAppending ? { append: true, appendIndex: dragAppendIndex } : {}
);
}
}
}
dragRaf = requestAnimationFrame(tickDragAutoScroll);
}
function startDragAutoScroll() {
if (dragRaf) cancelAnimationFrame(dragRaf);
dragRaf = requestAnimationFrame(tickDragAutoScroll);
}
function stopDragAutoScroll() {
if (dragRaf) cancelAnimationFrame(dragRaf);
dragRaf = null;
dragPointer = null;
}
function rangesOverlap(a, b) {
return !(a.end < b.start || a.start > b.end);
}
function selectionContainsRange(track, r) {
if (!r || !track) return false;
if (selectionTrack !== track) return false;
return getActiveRanges().some(sel => rangesOverlap(sel, r));
}
function blurEditorFieldsForSelection() {
if (document.activeElement === posEl) posEl.blur();
if (document.activeElement === searchInput) searchInput.blur();
}
function subtractRangeFromRanges(ranges, sub) {
const out = [];
for (const r of ranges) {
if (!rangesOverlap(r, sub)) { out.push(r); continue; }
if (sub.start <= r.start && sub.end >= r.end) continue;
if (sub.start > r.start) {
out.push({ start: r.start, end: Math.min(r.end, sub.start - 1) });
}
if (sub.end < r.end) {
out.push({ start: Math.max(r.start, sub.end + 1), end: r.end });
}
}
return mergeRanges(out);
}
function parseEntriesFromRaw(raw) {
const norm = String(raw || '').replace(/\r\n?/g, '\n');
const hasHeader = /^>/m.test(norm);
if (!hasHeader) {
const seqClean = norm.replace(/[^A-Za-z]/g, '').toUpperCase();
if (!seqClean) return [];
return [{ header: null, seq: seqClean }];
}
const lines = norm.split('\n');
const entries = [];
let currentHeader = null;
let currentSeqLines = [];
const pushIfNonEmpty = () => {
const seqClean = currentSeqLines.join('').replace(/[^A-Za-z]/g, '').toUpperCase();
if (seqClean) entries.push({ header: currentHeader, seq: seqClean });
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('>')) {
if (currentHeader !== null) pushIfNonEmpty();
currentHeader = line;
currentSeqLines = [];
} else {
currentSeqLines.push(line);
}
}
if (currentHeader !== null) pushIfNonEmpty();
return entries;
}
function detectSeqType(seq) {
if (!seq.length) return 'unknown';
const dnaLike = /^[ACGTNRYKMSWBDHV]+$/i;
const rnaLike = /^[ACGUNRYKMSWBDHV]+$/i;
const proteinLike = /^[ACDEFGHIKLMNPQRSTVWY]+$/i;
if (dnaLike.test(seq) && !/U/i.test(seq)) return 'dna';
if (rnaLike.test(seq) && /U/i.test(seq)) return 'rna';
if (proteinLike.test(seq)) return 'protein';
return 'mixed';
}
function normalizeSearchQuery(q) {
return String(q || '').replace(/[^A-Za-z]/g, '').toUpperCase();
}
function isLikelyNucQuery(q) {
return /^[ACGTUNRYKMSWBDHV]+$/i.test(String(q || ''));
}
function parseSearchQuery(raw) {
const up = String(raw || '').toUpperCase();
if (/\.{3,}/.test(up)) {
const parts = up
.split(/\.{3,}/)
.map(p => p.replace(/[^A-Z]/g, ''))
.filter(Boolean);
if (parts.length >= 2) return { mode: 'wild', parts };
}
return { mode: 'plain', q: up.replace(/[^A-Z]/g, '') };
}
function lowerBound(arr, x) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
function normFrameOffset(x) {
return ((Number(x) % 3) + 3) % 3;
}
function mergeRangesLocal(ranges) {
const cleaned = (ranges || [])
.map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
.sort((a, b) => (a.start - b.start) || (a.end - b.end));
if (!cleaned.length) return [];
const out = [cleaned[0]];
for (let i = 1; i < cleaned.length; i++) {
const cur = cleaned[i];
const last = out[out.length - 1];
if (cur.start <= last.end + 1) last.end = Math.max(last.end, cur.end);
else out.push(cur);
}
return out;
}
function getMainSelectionRanges() {
if (typeof getActiveRanges === 'function') {
const r = getActiveRanges();
if (Array.isArray(r) && r.length) return mergeRangesLocal(r);
}
if (Array.isArray(selectionRanges) && selectionRanges.length) return mergeRangesLocal(selectionRanges);
if (selectionRange) return mergeRangesLocal([selectionRange]);
return [];
}
function buildChainMeta(entries) {
let chainStart = 1;
const meta = [];
for (let i = 0; i < entries.length; i++) {
const seq = String(entries[i]?.seq || '').toUpperCase();
const len = seq.length;
const start = chainStart;
const end = start + len - 1;
const baseType = detectSeqType(seq);
meta.push({ i, start, end, len, baseType });
chainStart = end + 1;
}
return meta;
}
function normalizeAndMergeTranslationRanges(ranges) {
const cleaned = (ranges || [])
.map(r => ({
start: Math.min(Number(r.start), Number(r.end)),
end: Math.max(Number(r.start), Number(r.end)),
chainStart: Number(r.chainStart) || 1,
frameOffset: normFrameOffset(r.frameOffset)
}))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.start <= r.end)
.sort((a, b) =>
(a.chainStart - b.chainStart) ||
(a.frameOffset - b.frameOffset) ||
(a.start - b.start) ||
(a.end - b.end)
);
const out = [];
for (const r of cleaned) {
const last = out[out.length - 1];
if (
last &&
last.chainStart === r.chainStart &&
last.frameOffset === r.frameOffset &&
r.start <= last.end + 1
) {
last.end = Math.max(last.end, r.end);
} else {
out.push({ ...r });
}
}
return out;
}
function rebaseTranslationRanges(prevMeta, nextMeta, ranges) {
if (!Array.isArray(ranges) || !ranges.length) return ranges;
if (!Array.isArray(prevMeta) || !prevMeta.length) return ranges;
if (!Array.isArray(nextMeta) || !nextMeta.length) return ranges;
const out = [];
for (const r of ranges) {
if (!r) continue;
let prevIndex = prevMeta.findIndex(m => Number(m.start) === Number(r.chainStart));
if (prevIndex < 0) {
prevIndex = prevMeta.findIndex(m => Number(r.start) >= m.start && Number(r.start) <= m.end);
}
if (prevIndex < 0) continue;
const prev = prevMeta[prevIndex];
const next = nextMeta[prevIndex];
if (!next) continue;
if (next.baseType !== 'dna' && next.baseType !== 'rna') continue;
const delta = Number(next.start) - Number(prev.start);
let start = Number(r.start) + delta;
let end = Number(r.end) + delta;
const chainStart = Number(next.start);
const chainEnd = chainStart + Number(next.len) - 1;
if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
start = Math.max(chainStart, start);
end = Math.min(chainEnd, end);
if (start > end) continue;
out.push({ ...r, start, end, chainStart });
}
return normalizeAndMergeTranslationRanges(out);
}
function applyTranslationRangeOverrides(existingRanges, newRanges) {
const additions = normalizeAndMergeTranslationRanges(newRanges);
if (!additions.length) return normalizeAndMergeTranslationRanges(existingRanges);
let updated = Array.isArray(existingRanges) ? existingRanges.map(r => ({ ...r })) : [];
for (const add of additions) {
const next = [];
for (const prev of updated) {
if (prev.chainStart !== add.chainStart) {
next.push(prev);
continue;
}
const overlaps = !(prev.end < add.start || prev.start > add.end);
if (!overlaps) {
next.push(prev);
continue;
}
if (prev.start < add.start) {
next.push({ ...prev, end: add.start - 1 });
}
if (prev.end > add.end) {
next.push({ ...prev, start: add.end + 1 });
}
}
next.push({ ...add });
updated = next;
}
return normalizeAndMergeTranslationRanges(updated);
}
function getChainFrameOffset(chainStartGlobal) {
const fallback = normFrameOffset(translationFrameOffset);
if (!Array.isArray(translationRanges) || !translationRanges.length) return fallback;
const hit = translationRanges.find(r => Number(r.chainStart) === Number(chainStartGlobal));
if (!hit) return fallback;
return normFrameOffset(hit.frameOffset);
}
function reverseStringSpan(s, a0, b0) {
if (!s || a0 > b0) return s;
return s.slice(0, a0) + s.slice(a0, b0 + 1).split('').reverse().join('') + s.slice(b0 + 1);
}
function reverseProteinOverrideCodons(entry, idx, localStart1, localEnd1) {
const key = entryKey(entry, idx);
const over = proteinCodonOverrides.get(key);
if (!over || typeof over.seq !== 'string') return;
const nuc = String(over.seq || '').toUpperCase();
const a0 = (localStart1 - 1) * 3;
const b0 = (localEnd1 * 3) - 1;
if (a0 < 0 || b0 >= nuc.length || a0 > b0) return;
const mid = nuc.slice(a0, b0 + 1);
const blocks = mid.match(/.{1,3}/g) || [];
const rev = blocks.reverse().join('');
over.seq = nuc.slice(0, a0) + rev + nuc.slice(b0 + 1);
proteinCodonOverrides.set(key, over);
}
function getSearchContexts(qNorm) {
const entries = parseEntriesFromRaw(seqRaw);
const ctxs = [];
const qLooksNuc = isLikelyNucQuery(qNorm);
let globalPos = 1;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const baseSeq = String(entry.seq || '').toUpperCase();
if (!baseSeq) continue;
const baseType = detectSeqType(baseSeq);
const chainStart = globalPos;
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
const derivedType = activeNucType || (showRevComp ? lastNucType : null);
const wantTriplet = Boolean(activeNucType);
const wantTripletRev = Boolean(showRevComp && derivedType);
if (wantTriplet || wantTripletRev) {
const nucType = derivedType || 'dna';
const nuc = getDerivedNucForProtein(entry, i, baseSeq, nucType);
if (wantTriplet) {
ctxs.push({
track: 'triplet',
hay: nuc,
priority: qLooksNuc ? 0 : 4,
mapToRange: (idx0, qLen) => {
const aaStart = Math.floor(idx0 / 3) + 1;
const aaEnd = Math.floor((idx0 + qLen - 1) / 3) + 1;
return {
start: chainStart + aaStart - 1,
end: chainStart + aaEnd - 1
};
}
});
}
if (wantTripletRev) {
const rev = complementSeq(nuc, nucType);
ctxs.push({
track: 'triplet-rev',
hay: rev,
priority: qLooksNuc ? 1 : 5,
mapToRange: (idx0, qLen) => {
const aaStart = Math.floor(idx0 / 3) + 1;
const aaEnd = Math.floor((idx0 + qLen - 1) / 3) + 1;
return {
start: chainStart + aaStart - 1,
end: chainStart + aaEnd - 1
};
}
});
}
}
if (showProtein) {
ctxs.push({
track: 'protein',
hay: baseSeq,
priority: qLooksNuc ? 6 : 0,
mapToRange: (idx0, qLen) => ({
start: chainStart + idx0,
end: chainStart + idx0 + qLen - 1
})
});
}
globalPos += baseSeq.length;
continue;
}
if (baseType === 'dna' || baseType === 'rna') {
const nucType = getPreferredNucType(baseType);
const displaySeq = (nucType === 'rna') ? toRNA(baseSeq) : toDNA(baseSeq);
if (showDNA || showRNA) {
ctxs.push({
track: 'nt',
hay: displaySeq,
priority: qLooksNuc ? 0 : 4,
mapToRange: (idx0, qLen) => ({
start: chainStart + idx0,
end: chainStart + idx0 + qLen - 1
})
});
}
if (showRevComp) {
const rev = complementSeq(displaySeq, nucType);
ctxs.push({
track: 'nt-rev',
hay: rev,
priority: qLooksNuc ? 1 : 5,
mapToRange: (idx0, qLen) => ({
start: chainStart + idx0,
end: chainStart + idx0 + qLen - 1
})
});
}
if (showProtein) {
const aa = translateNuc(displaySeq, nucType);
ctxs.push({
track: 'translation',
hay: aa,
priority: qLooksNuc ? 7 : 0,
mapToRange: (idx0, qLen) => {
// translation track uses nt coordinates (each aa covers 3 nt)
const startNt = chainStart + (idx0 * 3);
const endNt = chainStart + ((idx0 + qLen) * 3) - 1;
return { start: startNt, end: endNt };
}
});
}
globalPos += baseSeq.length;
continue;
}
globalPos += baseSeq.length;
}
ctxs.sort((a, b) => (a.priority - b.priority));
return ctxs;
}
function setSearchCountVisible(visible) {
if (!searchCount) return;
searchCount.classList.toggle('is-visible', Boolean(visible));
}
function setSearchCountValue(n) {
if (!searchCount) return;
searchCount.textContent = String(n);
}
function clearSearchHits() {
if (!seqViewer) return;
seqViewer.querySelectorAll('.seq-search-hit').forEach(el => el.classList.remove('seq-search-hit'));
}
function hardClearSearchUI() {
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null; }
if (searchInput) searchInput.value = '';
searchAuthoredByUser = false;
setSearchCountVisible(false);
setSearchCountValue(0);
clearSearchHits();
searchMatches = [];
searchNeedRefresh = false;
lastSearchQuery = '';
lastSearchIndex = -1;
searchSelectionKey = '';
searchSelectionCustom = false;
selectionFromSearch = false;
updateMeta();
}
function computeMatchStarts(hay, q) {
const starts = [];
if (!hay || !q) return starts;
let i = 0;
while (true) {
const idx = hay.indexOf(q, i);
if (idx === -1) break;
starts.push(idx);
i = idx + 1; // allow overlaps
}
return starts;
}
function applySearchHitsToTrackRanges(track, totalLen, ranges) {
if (!ranges || !ranges.length) return;
const mark = new Uint8Array(totalLen + 2);
for (const r of ranges) {
const s = Math.max(1, r.start);
const e = Math.min(totalLen, r.end);
for (let p = s; p <= e; p++) mark[p] = 1;
}
const nodes = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`);
nodes.forEach(node => {
const ps = Number(node.getAttribute('data-pos-start'));
const pe = Number(node.getAttribute('data-pos-end') || ps);
let hit = false;
for (let p = ps; p <= pe; p++) {
if (mark[p]) { hit = true; break; }
}
if (hit) node.classList.add('seq-search-hit');
});
}
function computeSearchSelectionKeyNow() {
return JSON.stringify({
q: String(searchInput?.value || '').trim(),
activeNucType,
showProtein,
showDNA,
showRNA,
showRevComp
});
}
function chooseBestSearchTrack() {
if (!Array.isArray(searchMatches) || !searchMatches.length) return null;
if (selectionTrack && searchMatches.some(m => m.track === selectionTrack)) return selectionTrack;
const stats = new Map();
for (const m of searchMatches) {
if (!m?.track || !Array.isArray(m.ranges)) continue;
let units = 0;
for (const r of m.ranges) units += (Math.abs(r.end - r.start) + 1);
const cur = stats.get(m.track) || { units: 0, matches: 0 };
cur.units += units;
cur.matches += 1;
stats.set(m.track, cur);
}
let best = null;
let bestUnits = -1;
let bestMatches = -1;
for (const [t, s] of stats.entries()) {
if (s.units > bestUnits || (s.units === bestUnits && s.matches > bestMatches)) {
best = t; bestUnits = s.units; bestMatches = s.matches;
}
}
return best;
}
function getSearchRangesForTrack(track) {
if (!track || !Array.isArray(searchMatches)) return [];
const raw = searchMatches
.filter(m => m.track === track)
.flatMap(m => (m.ranges || []).map(r => ({ start: r.start, end: r.end })));
return mergeRanges(raw);
}
function refreshSearchDecorations(opts = {}) {
const preserveSelection = Boolean(opts.preserveSelection);
if (!searchAuthoredByUser) {
clearSearchHits();
searchMatches = [];
setSearchCountVisible(false);
setSearchCountValue(0);
updateMeta();
return;
}
const totalLen = processRaw(seqRaw).length;
const parsed = parseSearchQuery(searchInput?.value || '');
clearSearchHits();
searchMatches = [];
const hasQuery =
(parsed.mode === 'plain' && parsed.q.length) ||
(parsed.mode === 'wild' && parsed.parts.length >= 2);
if (!hasQuery || !totalLen) {
setSearchCountVisible(false);
if (selectionFromSearch && !preserveSelection) clearSelection();
updateMeta();
return;
}
const newKey = computeSearchSelectionKeyNow();
if (newKey !== searchSelectionKey) {
searchSelectionKey = newKey;
searchSelectionCustom = false;
}
setSearchCountVisible(true);
const matchesByTrack = new Map(); // track -> [ cleanedRangesArray ]
const segmentsByTrack = new Map(); // track -> [ {start,end}, ... ]
const addMatch = (track, ranges) => {
if (!track || !Array.isArray(ranges) || !ranges.length) return;
const cleaned = ranges
.map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end));
if (!cleaned.length) return;
if (!matchesByTrack.has(track)) matchesByTrack.set(track, []);
matchesByTrack.get(track).push(cleaned);
if (!segmentsByTrack.has(track)) segmentsByTrack.set(track, []);
segmentsByTrack.get(track).push(...cleaned);
};
const contexts = getSearchContexts(parsed.mode === 'plain' ? parsed.q : parsed.parts[0]);
for (const ctx of contexts) {
const hay = String(ctx.hay || '').toUpperCase();
if (!hay.length) continue;
if (parsed.mode === 'plain') {
const q = parsed.q;
const starts = computeMatchStarts(hay, q);
for (const idx0 of starts) {
const r = ctx.mapToRange(idx0, q.length);
if (r) addMatch(ctx.track, [{ start: r.start, end: r.end }]);
}
continue;
}
const partA = parsed.parts[0];
const partB = parsed.parts[1];
const aStarts = computeMatchStarts(hay, partA);
const bStarts = computeMatchStarts(hay, partB);
if (!aStarts.length || !bStarts.length) continue;
for (const a0 of aStarts) {
const minB = a0 + partA.length;
const j = lowerBound(bStarts, minB);
if (j >= bStarts.length) continue;
const b0 = bStarts[j];
const ra = ctx.mapToRange(a0, partA.length);
const rb = ctx.mapToRange(b0, partB.length);
if (!ra || !rb) continue;
addMatch(ctx.track, [
{ start: ra.start, end: ra.end },
{ start: rb.start, end: rb.end }
]);
}
}
const order = getSearchTrackOrder();
const chosenTrack = order.find(t => (matchesByTrack.get(t) || []).length) || null;
searchMatches = [];
if (!chosenTrack) {
setSearchCountValue(0);
updateMeta();
return;
}
const kept = matchesByTrack.get(chosenTrack) || [];
searchMatches = kept.map(ranges => ({ track: chosenTrack, ranges }));
setSearchCountValue(searchMatches.length);
applySearchHitsToTrackRanges(chosenTrack, totalLen, segmentsByTrack.get(chosenTrack) || []);
const hasManualSelection = getActiveRanges().length && !selectionFromSearch;
const shouldAdopt =
!hasManualSelection &&
!isEditingRange &&
!searchSelectionCustom;
if (shouldAdopt) {
const bestTrack = chooseBestSearchTrack();
const merged = getSearchRangesForTrack(bestTrack);
if (bestTrack && merged.length) {
selectionTrack = bestTrack;
selectionFromSearch = true;
selectionRanges = merged;
selectionRange = merged[merged.length - 1] || null;
lastAppliedSelectionTrack = selectionTrack || null;
updateSelectionStyles();
return;
}
}
updateMeta();
}
function jumpToNextMatch() {
const parsed = parseSearchQuery(searchInput?.value || '');
const hasQuery =
(parsed.mode === 'plain' && parsed.q.length) ||
(parsed.mode === 'wild' && parsed.parts.length >= 2);
if (!hasQuery) return;
refreshSearchDecorations({ preserveSelection: true });
if (!searchMatches.length) {
notify('error', 'No match found');
return;
}
const ordered = searchMatches.slice().sort((a, b) => {
const aStart = Math.min(...a.ranges.map(r => r.start));
const bStart = Math.min(...b.ranges.map(r => r.start));
return (aStart - bStart) || (a.track.localeCompare(b.track));
});
let nextIdx = lastSearchIndex + 1;
if (nextIdx >= ordered.length) nextIdx = 0;
lastSearchIndex = nextIdx;
const m = ordered[nextIdx];
selectionTrack = m.track;
selectionFromSearch = true;
const merged = mergeRanges(m.ranges);
selectionRanges = merged;
selectionRange = merged.length ? merged[merged.length - 1] : null;
updateSelectionStyles();
if (merged.length) smartScrollAfterSelection(m.track, merged[0].start);
}
function runSearch({ fromNext = false, silent = true } = {}) {
const processed = processRaw(seqRaw);
const hay = processed.seq || '';
if (!hay.length) { if (!silent) notify('error', 'No sequence loaded'); return; }
const q = normalizeSearchQuery(searchInput?.value || '');
if (!q.length) return;
let startFrom = 0;
if (fromNext && q === lastSearchQuery && selectionRange) {
startFrom = Math.max(0, selectionRange.end);
} else {
startFrom = 0;
}
let idx = hay.indexOf(q, startFrom);
if (idx === -1 && startFrom > 0) idx = hay.indexOf(q, 0);
if (idx === -1) {
if (!silent) notify('error', 'No match found');
return;
}
lastSearchQuery = q;
lastSearchIndex = idx;
const startPos = idx + 1;
const endPos = startPos + q.length - 1;
selectionTrack = getSearchTrackForCurrentSeq();
setSelection(startPos, endPos);
smartScrollAfterSelection(selectionTrack, startPos);
}
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const hasValue = Boolean((searchInput.value || '').trim());
if (hasValue && !searchAuthoredByUser) {
searchAuthoredByUser = true;
lastSearchIndex = -1;
}
jumpToNextMatch();
});
searchInput.addEventListener('input', () => {
if (suppressSearchInput) return;
searchAuthoredByUser = true;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
lastSearchIndex = -1;
refreshSearchDecorations();
}, 120);
});
}
function applyTypePill(type) {
const label =
type === 'dna' ? 'DNA' :
type === 'rna' ? 'RNA' :
type === 'protein' ? 'Protein' :
type === 'mixed' ? 'Mixed' : 'Type';
typePill.textContent = label;
typePill.classList.remove('is-dna', 'is-rna', 'is-protein', 'is-mixed', 'is-unknown');
typePill.classList.add(
type === 'dna' ? 'is-dna' :
type === 'rna' ? 'is-rna' :
type === 'protein' ? 'is-protein' :
type === 'mixed' ? 'is-mixed' : 'is-unknown'
);
}
function toDNA(seq) { return String(seq || '').toUpperCase().replace(/U/g, 'T'); }
function toRNA(seq) { return String(seq || '').toUpperCase().replace(/T/g, 'U'); }
const iupacCompDNA = {
A:'T', T:'A', U:'A', C:'G', G:'C',
R:'Y', Y:'R', S:'S', W:'W', K:'M', M:'K',
B:'V', V:'B', D:'H', H:'D', N:'N',
'-':'-'
};
const iupacCompRNA = {
A:'U', U:'A', T:'A', C:'G', G:'C',
R:'Y', Y:'R', S:'S', W:'W', K:'M', M:'K',
B:'V', V:'B', D:'H', H:'D', N:'N',
'-':'-'
};
function complementBase(b, nucType) {
const x = (b || '').toUpperCase();
const map = nucType === 'rna' ? iupacCompRNA : iupacCompDNA;
return map[x] || 'N';
}
function complementSeq(seq, nucType) {
const s = String(seq || '').toUpperCase();
let out = '';
for (let i = 0; i < s.length; i++) out += complementBase(s[i], nucType);
return out;
}
function antiCodonFromFwdCodon(fwdCodon, nucType){
const c = String(fwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3);
if (c === '---') return '---';
return complementSeq(c, nucType);
}
function antiIndexToFwdIndex(ntIndex){
const k = Number(ntIndex);
return Number.isFinite(k) ? k : 0;
}
function reverseComplementSeq(seq, nucType) {
return complementSeq(seq, nucType).split('').reverse().join('');
}
const geneticCodeDNA = {
TTT:'F',TTC:'F',TTA:'L',TTG:'L',TCT:'S',TCC:'S',TCA:'S',TCG:'S',TAT:'Y',TAC:'Y',TAA:'*',TAG:'*',
TGT:'C',TGC:'C',TGA:'*',TGG:'W',CTT:'L',CTC:'L',CTA:'L',CTG:'L',CCT:'P',CCC:'P',CCA:'P',CCG:'P',
CAT:'H',CAC:'H',CAA:'Q',CAG:'Q',CGT:'R',CGC:'R',CGA:'R',CGG:'R',ATT:'I',ATC:'I',ATA:'I',ATG:'M',
ACT:'T',ACC:'T',ACA:'T',ACG:'T',AAT:'N',AAC:'N',AAA:'K',AAG:'K',AGT:'S',AGC:'S',AGA:'R',AGG:'R',
GTT:'V',GTC:'V',GTA:'V',GTG:'V',GCT:'A',GCC:'A',GCA:'A',GCG:'A',GAT:'D',GAC:'D',GAA:'E',GAG:'E',
GGT:'G',GGC:'G',GGA:'G',GGG:'G'
};
function translateNuc(seq, nucType) {
const dna = nucType === 'rna' ? toDNA(seq) : String(seq || '').toUpperCase();
let aa = '';
for (let i = 0; i + 2 < dna.length; i += 3) {
const codon = dna.slice(i, i + 3);
aa += geneticCodeDNA[codon] || 'X';
}
return aa;
}
function addDirectionLabels(row, startText, endText, labelClass = 'seq-direction-label') {
if (!row) return;
row.classList.add(labelClass === 'seq-direction-label' ? 'seq-direction-row' : 'mut-direction-row');
const start = document.createElement('span');
start.className = `${labelClass} ${labelClass}--start`;
start.textContent = startText;
const end = document.createElement('span');
end.className = `${labelClass} ${labelClass}--end`;
end.textContent = endText;
row.appendChild(start);
row.appendChild(end);
}
function bestReadingFrameOffset(seq, nucType) {
const clean = String(seq || '').toUpperCase().replace(/[^ACGTU]/g, '');
if (!clean.length) return 0;
let bestFrame = 0;
let bestRun = -1;
for (let frame = 0; frame < 3; frame++) {
const aa = translateNuc(clean.slice(frame), nucType);
let run = 0;
let longest = 0;
for (const ch of aa) {
if (ch === '*') {
longest = Math.max(longest, run);
run = 0;
} else {
run += 1;
}
}
longest = Math.max(longest, run);
if (longest > bestRun) {
bestRun = longest;
bestFrame = frame;
}
}
return bestFrame;
}
const backCodonDNA = {
A:'GCT', C:'TGT', D:'GAT', E:'GAA', F:'TTT', G:'GGT', H:'CAT', I:'ATT', K:'AAA', L:'CTG',
M:'ATG', N:'AAT', P:'CCT', Q:'CAA', R:'CGT', S:'TCT', T:'ACT', V:'GTT', W:'TGG', Y:'TAT',
'*':'TAA', X:'NNN'
};
function backTranslateProtein(prot, nucType) {
const p = String(prot || '').toUpperCase();
let dna = '';
for (let i = 0; i < p.length; i++) dna += (backCodonDNA[p[i]] || 'NNN');
return nucType === 'rna' ? toRNA(dna) : dna;
}
function parseStructureFile(raw) {
const lines = raw.split(/\r?\n/);
const threeToOne = {
ALA: 'A', ARG: 'R', ASN: 'N', ASP: 'D', CYS: 'C', GLN: 'Q', GLU: 'E', GLY: 'G', HIS: 'H', ILE: 'I',
LEU: 'L', LYS: 'K', MET: 'M', PHE: 'F', PRO: 'P', SER: 'S', THR: 'T', TRP: 'W', TYR: 'Y', VAL: 'V',
SEC: 'U', PYL: 'O'
};
const chains = new Map();
let foundAtom = false;
lines.forEach((line) => {
const trimmed = line.trim();
if (!/^ATOM/.test(trimmed) && !/^HETATM/.test(trimmed)) return;
foundAtom = true;
let chainId = '';
let resName = '';
let resNum = null;
let bFactor = null;
if (line.length > 54) {
chainId = line.slice(21, 22).trim() || 'A';
resName = line.slice(17, 20).trim();
resNum = parseInt(line.slice(22, 26).trim(), 10);
const temp = parseFloat(line.slice(60, 66));
bFactor = Number.isFinite(temp) ? temp : null;
} else {
const parts = trimmed.split(/\s+/);
resName = parts[3] || '';
chainId = parts[4] || 'A';
resNum = parseInt(parts[5], 10);
const temp = parseFloat(parts[10]);
bFactor = Number.isFinite(temp) ? temp : null;
}
const one = threeToOne[resName.toUpperCase()];
if (!one || !Number.isFinite(resNum)) return;
if (!chains.has(chainId)) chains.set(chainId, { seq: [], scores: [], lastRes: null });
const chain = chains.get(chainId);
if (chain.lastRes === resNum) return;
chain.seq.push(one);
chain.scores.push(bFactor);
chain.lastRes = resNum;
});
if (!foundAtom || !chains.size) return null;
const entries = [];
chains.forEach((value, key) => {
entries.push({ header: `>Chain ${key}`, seq: value.seq.join(''), scores: value.scores });
});
return entries;
}
function entriesToRaw(entries) {
const parts = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (e.header) parts.push(e.header);
if (e.seq && e.seq.length) parts.push(e.seq);
if (i < entries.length - 1) parts.push('');
}
return parts.join('\n');
}
function applyStructuredEntries(entries) {
if (!entries || !entries.length) return false;
residueScoreMap = [];
entries.forEach((entry) => {
if (Array.isArray(entry.scores)) residueScoreMap.push(...entry.scores.map((s) => (Number.isFinite(s) ? s : null)));
});
const fasta = entriesToRaw(entries);
const out = sanitizeAndGroup(fasta);
seqRaw = out.text;
const processed = processRaw(seqRaw);
ensureScoreMapLength(processed.length);
applyDefaultViewToggles(detectSeqType(processed.seq));
updateSeqViews();
lastState = seqRaw;
return true;
}
function applyColorToResidue(el, residue, position) {
const res = residue.toUpperCase();
el.style.removeProperty('background');
el.style.removeProperty('color');
if (proteinColorMode === 'bfactor') {
const score = residueScoreMap[position - 1];
const color = scoreToColor(score);
if (color) el.style.color = color;
return;
}
if (proteinColorMode === 'type') {
el.style.color = mutAaGroupColorVar(res);
return;
}
if (proteinColorMode === 'polarity') {
const polar = 'STYCNQ';
const hydrophobic = 'GAVLIPMFYW';
if (polar.includes(res)) el.style.color = seqPalette.blue;
else if (hydrophobic.includes(res)) el.style.color = seqPalette.green;
return;
}
if (proteinColorMode === 'charge') {
const positive = 'HRK';
const negative = 'DE';
if (positive.includes(res)) el.style.color = seqPalette.blue;
else if (negative.includes(res)) el.style.color = seqPalette.red;
return;
}
if (proteinColorMode === 'hydrophobic') {
const hydrophobic = 'AILMFWVPG';
if (hydrophobic.includes(res)) el.style.color = seqPalette.green;
return;
}
if (proteinColorMode === 'backbone') {
if (res === 'G' || res === 'P') el.style.color = seqPalette.purple;
}
}
function mutResidueColorForHist(residue, globalPos) {
const res = String(residue || 'X').toUpperCase().slice(0, 1);
const fallback = 'var(--mut-text-2)';
if (proteinColorMode === 'bfactor') {
const i = Number(globalPos) - 1;
const score =
Number.isFinite(i) && i >= 0 && i < residueScoreMap.length
? residueScoreMap[i]
: null;
return scoreToColor(score) || fallback;
}
if (proteinColorMode === 'type') {
return mutAaGroupColorVar(res);
}
if (proteinColorMode === 'polarity') {
const polar = 'STYCNQ';
const hydrophobic = 'GAVLIPMFYW';
if (polar.includes(res)) return seqPalette.blue;
if (hydrophobic.includes(res)) return seqPalette.green;
return fallback;
}
if (proteinColorMode === 'charge') {
const positive = 'HRK';
const negative = 'DE';
if (positive.includes(res)) return seqPalette.blue;
if (negative.includes(res)) return seqPalette.red;
return fallback;
}
if (proteinColorMode === 'hydrophobic') {
const hydrophobic = 'AILMFWVPG';
if (hydrophobic.includes(res)) return seqPalette.green;
return fallback;
}
if (proteinColorMode === 'backbone') {
if (res === 'G' || res === 'P') return seqPalette.purple;
return fallback;
}
return fallback;
}
function setCellText(el, text) {
el.textContent = '';
const t = document.createElement('span');
t.className = 'seq-cell-text';
t.textContent = text;
el.appendChild(t);
}
function applyColorToNuc(el, base) {
const b = (base || '').toUpperCase();
el.style.removeProperty('color');
const scheme = ntSchemes[ntScheme] || ntSchemes.classic;
el.style.color = scheme[b] || scheme.N || '#9fb7d6';
}
function hexToRgba(hex, alpha) {
const h = String(hex || '').replace('#', '').trim();
if (h.length !== 6) return `rgba(255,255,255,${alpha})`;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
function applyHighlightsToRange(el, start, end, track) {
let hit = null;
for (let i = highlights.length - 1; i >= 0; i--) {
const h = highlights[i];
if (h.track !== track) continue;
const overlaps = !(end < h.start || start > h.end);
if (overlaps) { hit = h; break; }
}
if (hit) {
el.classList.add('seq-highlighted');
el.style.setProperty('--hl-border', hexToRgba(hit.color, 0.85));
el.style.setProperty('--hl-glow', hexToRgba(hit.color, 0.35));
el.style.setProperty('--hl-fill', hexToRgba(hit.color, 0.18));
el.style.setProperty('--hl-fill-strong', hexToRgba(hit.color, 0.30));
if (hit.label) {
el.setAttribute('data-highlight-label', hit.label);
el.setAttribute('title', hit.label);
} else {
el.removeAttribute('data-highlight-label');
el.removeAttribute('title');
}
return;
}
el.removeAttribute('data-highlight-label');
el.removeAttribute('title');
el.classList.remove('seq-highlighted');
el.style.removeProperty('--hl-border');
el.style.removeProperty('--hl-glow');
el.style.removeProperty('--hl-fill');
el.style.removeProperty('--hl-fill-strong');
}
function registerTrackCell(el, start, end, track) {
if (!track || !el || !Number.isFinite(start)) return;
const endPos = Number.isFinite(end) ? end : start;
const map = trackCellIndex[track] || (trackCellIndex[track] = []);
for (let pos = start; pos <= endPos; pos++) {
map[pos] = el;
}
}
function getNumberCssVarPx(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim();
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function gcd(a, b) { while (b) [a, b] = [b, a % b]; return a || 1; }
function lcm(a, b) { return Math.abs(a * b) / gcd(a, b); }
function getUnitMetrics(unit) {
const editorBody = document.querySelector('.seq-editor-body') || seqViewer;
const style = getComputedStyle(editorBody);
const gap = parseFloat(style.getPropertyValue('--seq-row-gap')) || 2;
const groupGap = getNumberCssVarPx(editorBody, '--seq-group-gap', 10);
const cellSizeVar = unit === 'nt' ? '--seq-nt-cell-size' : '--seq-cell-size';
const cellSize = parseFloat(style.getPropertyValue(cellSizeVar)) || 32;
const padL = parseFloat(getComputedStyle(seqViewer).paddingLeft) || 0;
const padR = parseFloat(getComputedStyle(seqViewer).paddingRight) || 0;
const usableWidth = Math.max(seqViewer.clientWidth - padL - padR - 8, cellSize);
return { gap, groupGap, cellSize, usableWidth };
}
function computeColumnCount(unit, alignTo = 1) {
const { gap, groupGap, cellSize, usableWidth } = getUnitMetrics(unit);
const mustBeMultipleOf = (() => {
const a = Math.max(1, alignTo | 0);
const g = groupSize > 0 ? groupSize : 1;
return lcm(a, g);
})();
const widthFor = (n) => {
const spacers = groupSize > 0 ? Math.floor((n - 1) / groupSize) : 0;
const tracks = n + spacers;
return (n * cellSize) + (spacers * groupGap) + ((tracks - 1) * gap);
};
let best = 0;
for (let n = mustBeMultipleOf; n <= 600; n += mustBeMultipleOf) {
if (widthFor(n) <= usableWidth) best = n;
else break;
}
if (best > 0) return best;
return 1;
}
function buildRowTemplate(nUnits) {
const parts = [];
for (let i = 1; i <= nUnits; i++) {
parts.push('var(--seq-unit-cell-size)');
if (groupSize > 0 && i % groupSize === 0 && i !== nUnits) parts.push('var(--seq-group-gap)');
}
return parts.join(' ');
}
function gridColumnForUnitIndex(i) {
if (groupSize <= 0) return i;
const spacersBefore = Math.floor((i - 1) / groupSize);
return i + spacersBefore;
}
function deriveChainLetter(label, chainIndex) {
const s = String(label || '');
const mAuth = s.match(/\bauth\s+([A-Za-z0-9])\b/i);
if (mAuth && mAuth[1]) return mAuth[1].toUpperCase();
const mChain = s.match(/\bchains?\s+([A-Za-z0-9])\b/i);
if (mChain && mChain[1]) return mChain[1].toUpperCase();
if (s.trim().length === 1) return s.trim().toUpperCase();
return String.fromCharCode(65 + (chainIndex % 26));
}
function makeChainControls(chainIndex, chainLetter, totalChains, lenText) {
const controls = document.createElement('span');
controls.className = 'seq-chain-controls';
const caret = document.createElement('span');
caret.className = 'seq-chain-caret';
const btnUp = document.createElement('button');
btnUp.type = 'button';
btnUp.setAttribute('aria-label', 'Move chain up');
btnUp.disabled = chainIndex === 0;
btnUp.innerHTML = `
`;
btnUp.addEventListener('click', (e) => { e.stopPropagation(); reorderChain(chainIndex, 'up'); });
const btnDown = document.createElement('button');
btnDown.type = 'button';
btnDown.setAttribute('aria-label', 'Move chain down');
btnDown.disabled = chainIndex === totalChains - 1;
btnDown.innerHTML = `
`;
btnDown.addEventListener('click', (e) => { e.stopPropagation(); reorderChain(chainIndex, 'down'); });
caret.appendChild(btnUp);
caret.appendChild(btnDown);
const id = document.createElement('span');
id.className = 'seq-chain-id';
id.textContent = chainLetter;
const len = document.createElement('span');
len.className = 'seq-chain-len';
len.textContent = lenText || '';
controls.appendChild(caret);
controls.appendChild(id);
controls.appendChild(len);
return controls;
}
function setRowUnitVars(rowEl, unit) {
if (unit === 'nt') {
rowEl.style.setProperty('--seq-unit-cell-size', 'var(--seq-nt-cell-size)');
rowEl.style.setProperty('--seq-unit-font-size', 'var(--seq-nt-font-size)');
} else {
rowEl.style.setProperty('--seq-unit-cell-size', 'var(--seq-cell-size)');
rowEl.style.setProperty('--seq-unit-font-size', 'var(--seq-font-size)');
}
}
function padLenToGroups(n, maxCols, alignTo = 1) {
if (n <= 0) return 0;
let out = n;
const a = Math.max(1, alignTo | 0);
if (a > 1) out = Math.min(maxCols, Math.ceil(out / a) * a);
if (groupSize > 0) out = Math.min(maxCols, Math.ceil(out / groupSize) * groupSize);
return out;
}
function buildRulerRow(rowTemplate, unit, startPos, chunkLen, padLen, chainStart) {
const rulerRow = document.createElement('div');
rulerRow.className = 'seq-ruler-row';
setRowUnitVars(rulerRow, unit);
rulerRow.style.gridTemplateColumns = rowTemplate;
const baseStart = Number.isFinite(chainStart) ? chainStart : startPos;
const maxUnits = padLen;
for (let i = 1; i <= maxUnits; i++) {
const gridCol = gridColumnForUnitIndex(i);
const pos = (startPos - baseStart) + i;
const tick = document.createElement('span');
tick.className = 'seq-tick';
tick.style.gridColumn = String(gridCol);
if (i <= chunkLen) {
if (rulerEvery > 0 && pos % rulerEvery === 0) {
tick.textContent = pos;
tick.classList.add('seq-tick--label');
}
}
rulerRow.appendChild(tick);
if (groupSize > 0 && i % groupSize === 0 && i !== maxUnits) {
const spacer = document.createElement('span');
spacer.className = 'seq-tick seq-tick--spacer';
spacer.style.gridColumn = String(gridCol + 1);
rulerRow.appendChild(spacer);
}
}
return rulerRow;
}
function trackUnitSuffix(track) {
if (!track) return '';
if (track === 'protein' || track === 'translation' || track === 'triplet' || track === 'triplet-rev') return 'aa';
if (track === 'nt' || track === 'nt-rev') return 'nt';
return '';
}
function renderSequenceViewer() {
seqViewer.classList.toggle('is-prot-3', proteinViewMode === 2);
trackCellIndex = {};
lastSelectedCells = new Set();
const entries = parseEntriesFromRaw(seqRaw);
const frag = document.createDocumentFragment();
if (!entries.length) {
seqViewer.replaceChildren();
updateSelectionStyles();
return;
}
let globalPos = 1;
entries.forEach((entry, idx) => {
if (!entry.seq) return;
const chainEl = document.createElement('div');
chainEl.className = 'sequence-chain';
const label = document.createElement('div');
label.className = 'sequence-chain__label';
const defaultLetter = String.fromCharCode(65 + (idx % 26));
const labelText = entry.header ? entry.header.replace(/^>/, '') : `Chain ${defaultLetter}`;
const chainLetter = entry.header ? deriveChainLetter(labelText, idx) : defaultLetter;
const baseType = detectSeqType(entry.seq);
const chainSuffix = (baseType === 'dna' || baseType === 'rna') ? 'nt' : 'aa';
const chainLenText = `${entry.seq.length}${chainSuffix}`;
const controls = makeChainControls(idx, chainLetter, entries.length, chainLenText);
const labelSpan = document.createElement('span');
labelSpan.className = 'seq-chain-label-text';
labelSpan.textContent = labelText;
label.replaceChildren(controls, labelSpan);
const track = document.createElement('div');
track.className = 'seq-track';
track.dataset.chainStart = String(globalPos);
track.dataset.chainLen = String(entry.seq.length);
track.dataset.baseType = baseType;
const baseSeq = entry.seq;
const displayNucType = getPreferredNucType(baseType);
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
const unit = 'aa';
const columns = computeColumnCount(unit, 1);
const derivedType = activeNucType || lastNucType || 'dna';
const wantForwardTriplets = Boolean(activeNucType);
const wantRevTriplets = Boolean(showRevComp);
const wantDerivedNuc = wantForwardTriplets || wantRevTriplets;
const derivedNuc = wantDerivedNuc
? getDerivedNucForProtein(entry, idx, baseSeq, derivedType)
: '';
const derivedComp = wantRevTriplets ? complementSeq(derivedNuc, derivedType) : '';
for (let start = 0; start < baseSeq.length; start += columns) {
const chunk = baseSeq.slice(start, start + columns);
const chunkLen = chunk.length;
const padLen = chunkLen;
const rowTemplate = buildRowTemplate(padLen);
const rowBlock = document.createElement('div');
rowBlock.className = 'seq-rowblock';
if (showRulers) rowBlock.appendChild(buildRulerRow(rowTemplate, unit, globalPos + start, chunkLen, padLen, globalPos));
if (showProtein) {
const aaRow = document.createElement('div');
aaRow.className = 'seq-row seq-row--protein';
setRowUnitVars(aaRow, unit);
aaRow.style.gridTemplateColumns = rowTemplate;
for (let i = 0; i < padLen; i++) {
const span = document.createElement('span');
const pos = globalPos + start + i;
span.classList.add('seq-cell');
span.dataset.track = 'protein';
const ch = chunk[i];
span.dataset.unit = ch;
const display = (proteinViewMode === 2) ? aaToThree(ch) : ch;
setCellText(span, display);
span.setAttribute('data-pos-start', String(pos));
span.setAttribute('data-pos-end', String(pos));
applyColorToResidue(span, ch, pos);
applyHighlightsToRange(span, pos, pos, 'protein');
registerTrackCell(span, pos, pos, 'protein');
span.style.gridColumn = String(gridColumnForUnitIndex(i + 1));
aaRow.appendChild(span);
}
addDirectionLabels(aaRow, 'N-', '-C');
rowBlock.appendChild(aaRow);
}
if (wantDerivedNuc) {
if (wantForwardTriplets) {
const triRow = document.createElement('div');
triRow.className = 'seq-triplet-row seq-triplet-row--derived';
setRowUnitVars(triRow, unit);
triRow.style.gridTemplateColumns = rowTemplate;
for (let i = 0; i < padLen; i++) {
const aaPos = globalPos + start + i;
const cell = document.createElement('div');
cell.className = 'seq-triplet-cell';
cell.dataset.track = 'triplet';
cell.style.gridColumn = String(gridColumnForUnitIndex(i + 1));
cell.setAttribute('data-pos-start', String(aaPos));
cell.setAttribute('data-pos-end', String(aaPos));
const codonStart = (aaPos - globalPos) * 3;
const codon = derivedNuc.slice(codonStart, codonStart + 3) || '---';
for (let k = 0; k < 3; k++) {
const b = codon[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.textContent = b;
applyColorToNuc(mini, b);
cell.appendChild(mini);
}
applyHighlightsToRange(cell, aaPos, aaPos, 'triplet');
registerTrackCell(cell, aaPos, aaPos, 'triplet');
triRow.appendChild(cell);
}
addDirectionLabels(triRow, '5′', '3′');
rowBlock.appendChild(triRow);
}
if (wantRevTriplets) {
const triRevRow = document.createElement('div');
triRevRow.className = 'seq-triplet-row seq-triplet-row--derived-rev';
setRowUnitVars(triRevRow, unit);
triRevRow.style.gridTemplateColumns = rowTemplate;
for (let i = 0; i < padLen; i++) {
const aaPos = globalPos + start + i;
const cell = document.createElement('div');
cell.className = 'seq-triplet-cell';
cell.dataset.track = 'triplet-rev';
cell.style.gridColumn = String(gridColumnForUnitIndex(i + 1));
cell.setAttribute('data-pos-start', String(aaPos));
cell.setAttribute('data-pos-end', String(aaPos));
const codonStart = (aaPos - globalPos) * 3;
const codon = derivedComp.slice(codonStart, codonStart + 3) || '---';
for (let k = 0; k < 3; k++) {
const b = codon[k] || '-';
const mini = document.createElement('span');
mini.className = 'seq-triplet-nt';
mini.textContent = b;
applyColorToNuc(mini, b);
cell.appendChild(mini);
}
applyHighlightsToRange(cell, aaPos, aaPos, 'triplet-rev');
registerTrackCell(cell, aaPos, aaPos, 'triplet-rev');
triRevRow.appendChild(cell);
}
addDirectionLabels(triRevRow, '3′', '5′');
rowBlock.appendChild(triRevRow);
}
}
track.appendChild(rowBlock);
}
globalPos += baseSeq.length;
}
else if (baseType === 'dna' || baseType === 'rna') {
const unit = 'nt';
const displaySeq = displayNucType === 'rna' ? toRNA(baseSeq) : toDNA(baseSeq);
const wantTranslation = Boolean(showProtein);
const wantNucRow = (showDNA || showRNA);
const wantComp = showRevComp;
const alignTo = wantTranslation ? 3 : 1;
const columns = computeColumnCount(unit, alignTo);
const frameOffset = getChainFrameOffset(globalPos);
const activeTranslationRanges = Array.isArray(translationRanges) && translationRanges.length
? translationRanges
: null;
const compSeq = wantComp ? complementSeq(displaySeq, displayNucType) : '';
let start = 0;
let firstChunk = true;
while (start < displaySeq.length) {
let targetLen = columns;
if (wantTranslation && frameOffset !== 0 && firstChunk) {
targetLen = Math.max(1, columns - (3 - frameOffset));
}
const chunk = displaySeq.slice(start, start + targetLen);
const chunkLen = chunk.length;
const padLen = chunkLen;
const rowTemplate = buildRowTemplate(padLen);
const rowBlock = document.createElement('div');
rowBlock.className = 'seq-rowblock';
if (showRulers) rowBlock.appendChild(buildRulerRow(rowTemplate, unit, globalPos + start, chunkLen, padLen, globalPos));
if (wantTranslation) {
const transRow = document.createElement('div');
transRow.className = 'seq-row seq-row--translation';
setRowUnitVars(transRow, unit);
transRow.style.gridTemplateColumns = rowTemplate;
const chunkStart = start;
const chunkEnd = start + chunkLen - 1;
const offsetInChunk = (frameOffset - (chunkStart % 3) + 3) % 3;
const firstCodonStart = chunkStart + offsetInChunk;
const renderCodon = (codonStart) => {
const codon = displaySeq.slice(codonStart, codonStart + 3);
const aa = translateNuc(codon, displayNucType)[0] || '';
const globalStart = globalPos + codonStart;
const globalEnd = globalStart + 2;
const cell = document.createElement('span');
cell.className = 'seq-cell';
cell.dataset.track = 'translation';
cell.dataset.unit = aa;
const display = (proteinViewMode === 2) ? aaToThree(aa) : aa;
setCellText(cell, display);
cell.setAttribute('data-pos-start', String(globalStart));
cell.setAttribute('data-pos-end', String(globalEnd));
const localNtStart = (codonStart - chunkStart) + 1;
const localNtEnd = localNtStart + 2;
const startCol = gridColumnForUnitIndex(localNtStart);
const endCol = gridColumnForUnitIndex(localNtEnd) + 1;
cell.style.gridColumn = `${startCol} / ${endCol}`;
if (aa && aa !== 'X' && aa !== '*') applyColorToResidue(cell, aa, globalStart);
applyHighlightsToRange(cell, globalStart, globalEnd, 'translation');
registerTrackCell(cell, globalStart, globalEnd, 'translation');
transRow.appendChild(cell);
};
if (activeTranslationRanges) {
for (let codonStart = chunkStart; codonStart + 2 <= chunkEnd; codonStart += 1) {
const globalStart = globalPos + codonStart;
const globalEnd = globalStart + 2;
const range = activeTranslationRanges.find(r => globalStart >= r.start && globalEnd <= r.end);
if (!range) continue;
const frameMatch = ((globalStart - range.chainStart - range.frameOffset) % 3 + 3) % 3 === 0;
if (!frameMatch) continue;
renderCodon(codonStart);
}
} else {
for (let codonStart = firstCodonStart; codonStart + 2 <= chunkEnd; codonStart += 3) {
renderCodon(codonStart);
}
}
addDirectionLabels(transRow, 'N-', '-C');
rowBlock.appendChild(transRow);
}
if (wantNucRow) {
const ntRow = document.createElement('div');
ntRow.className = 'seq-row seq-row--nt';
setRowUnitVars(ntRow, unit);
ntRow.style.gridTemplateColumns = rowTemplate;
for (let i = 0; i < padLen; i++) {
const span = document.createElement('span');
span.classList.add('seq-cell', 'seq-cell--nt');
span.dataset.track = 'nt';
span.style.gridColumn = String(gridColumnForUnitIndex(i + 1));
const pos = globalPos + start + i;
const b = chunk[i];
setCellText(span, b);
span.setAttribute('data-pos-start', String(pos));
span.setAttribute('data-pos-end', String(pos));
applyColorToNuc(span, b);
applyHighlightsToRange(span, pos, pos, 'nt');
registerTrackCell(span, pos, pos, 'nt');
ntRow.appendChild(span);
}
addDirectionLabels(ntRow, '5′', '3′');
rowBlock.appendChild(ntRow);
}
if (wantComp) {
const compChunk = compSeq.slice(start, start + chunkLen);
const compRow = document.createElement('div');
compRow.className = 'seq-row seq-row--nt seq-row--revcomp';
setRowUnitVars(compRow, unit);
compRow.style.gridTemplateColumns = rowTemplate;
for (let i = 0; i < padLen; i++) {
const span = document.createElement('span');
span.classList.add('seq-cell', 'seq-cell--nt');
span.dataset.track = 'nt-rev';
span.style.gridColumn = String(gridColumnForUnitIndex(i + 1));
const b = compChunk[i];
const pos = globalPos + start + i;
setCellText(span, b);
span.setAttribute('data-pos-start', String(pos));
span.setAttribute('data-pos-end', String(pos));
applyColorToNuc(span, b);
applyHighlightsToRange(span, pos, pos, 'nt-rev');
registerTrackCell(span, pos, pos, 'nt-rev');
compRow.appendChild(span);
}
addDirectionLabels(compRow, '3′', '5′');
rowBlock.appendChild(compRow);
}
track.appendChild(rowBlock);
start += chunkLen;
firstChunk = false;
}
globalPos += baseSeq.length;
}
chainEl.appendChild(label);
chainEl.appendChild(track);
frag.appendChild(chainEl);
});
seqViewer.replaceChildren(frag);
if (searchAuthoredByUser) {
refreshSearchDecorations({ preserveSelection: true });
} else {
clearSearchHits();
searchMatches = [];
setSearchCountVisible(false);
setSearchCountValue(0);
}
updateSelectionStyles();
}
function updateTranslateButtonState(detected) {
if (!translateBtn) return;
const hide = detected === 'protein' || detected === 'mixed' || detected === 'unknown';
translateBtn.hidden = hide;
}
function updateGcMeter() {
if (!gcMeter || !gcLabel || !atLabel || !gcBar) return;
const processed = processRaw(seqRaw);
const seq = String(processed.seq || '').toUpperCase();
const detected = detectSeqType(seq);
const gcType = activeNucType || (detected === 'rna' ? 'rna' : 'dna') || lastNucType || 'dna';
const atLabelText = gcType === 'rna' ? 'AU' : 'AT';
const setEmpty = () => {
gcMeter.hidden = false;
gcLabel.textContent = 'GC --';
atLabel.textContent = `${atLabelText} --`;
gcBar.style.width = '0%';
gcMeter.classList.remove('is-warn', 'is-bad');
};
if (!seq.length) {
setEmpty();
return;
}
let working = seq;
if (detected === 'protein') {
working = backTranslateProtein(seq, gcType);
} else if (!(detected === 'dna' || detected === 'rna' || detected === 'mixed')) {
setEmpty();
return;
}
let gc = 0;
let at = 0;
for (const ch of working) {
if (ch === 'G' || ch === 'C') gc += 1;
else if (ch === 'A' || ch === 'T' || ch === 'U') at += 1;
}
const total = gc + at;
if (!total) {
setEmpty();
return;
}
gcMeter.hidden = false;
const gcPct = Math.round((gc / total) * 100);
const atPct = 100 - gcPct;
gcLabel.textContent = `GC ${gcPct}%`;
atLabel.textContent = `${atLabelText} ${atPct}%`;
gcBar.style.width = `${gcPct}%`;
gcMeter.classList.remove('is-warn', 'is-bad');
if (gcPct < 35 || gcPct > 65) gcMeter.classList.add('is-bad');
else if (gcPct < 40 || gcPct > 60) gcMeter.classList.add('is-warn');
}
function updateSeqViews() {
const processed = processRaw(seqRaw);
lastSeq = processed.seq;
ensureScoreMapLength(processed.length);
if (selectionRange && selectionRange.start > processed.length) {
selectionRange = null;
selectionTrack = null;
}
const detected = detectSeqType(processed.seq);
applyTypePill(detected);
const entries = parseEntriesFromRaw(seqRaw);
const chainMeta = entries && entries.length ? buildChainMeta(entries) : [];
if (Array.isArray(translationRanges) && translationRanges.length) {
translationRanges = rebaseTranslationRanges(lastChainMeta, chainMeta, translationRanges);
}
lastChainMeta = chainMeta;
renderSequenceViewer();
updateTranslateButtonState(detected);
updateGcMeter();
}
function setViewBtnState(btn, isOn) {
if (!btn) return;
btn.classList.remove('is-blue', 'is-green', 'is-red');
btn.classList.add(isOn ? 'is-green' : 'is-blue');
}
function setRulerBtnState(btn) {
if (!btn) return;
btn.classList.remove('is-green', 'is-red');
btn.classList.add('is-blue');
const mode = showRulers ? String(rulerEvery || 3) : 'off';
btn.setAttribute('data-ruler', mode);
btn.title = showRulers ? `Ruler: every ${mode}` : 'Ruler: off';
}
function syncViewToggleButtons() {
syncActiveNucType();
const hasSeq = processRaw(seqRaw).seq.length > 0;
setRulerBtnState(btnRulers);
setViewBtnState(btnProtein, hasSeq && showProtein);
setViewBtnState(btnDNA, hasSeq && showDNA);
setViewBtnState(btnRNA, hasSeq && showRNA);
setViewBtnState(btnRevComp, hasSeq && showRevComp);
updateGcMeter();
}
if (btnRulers) {
btnRulers.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
if (!showRulers) {
showRulers = true;
rulerEvery = 3;
} else if (rulerEvery === 3) {
showRulers = true;
rulerEvery = 5;
} else {
showRulers = false;
rulerEvery = 3;
}
syncViewToggleButtons();
renderSequenceViewer();
});
}
function applyDefaultViewToggles(detectedType) {
showRulers = true;
rulerEvery = 3;
if (detectedType === 'protein') {
proteinViewMode = 1;
showProtein = true;
showDNA = false;
showRNA = false;
showRevComp = false;
lastNucType = lastNucType || 'dna';
} else if (detectedType === 'dna') {
proteinViewMode = 0;
showProtein = false;
showDNA = true;
showRNA = false;
showRevComp = false;
lastNucType = 'dna';
} else if (detectedType === 'rna') {
proteinViewMode = 0;
showProtein = false;
showDNA = false;
showRNA = true;
showRevComp = false;
lastNucType = 'rna';
} else {
proteinViewMode = 1;
showProtein = true;
showDNA = false;
showRNA = false;
showRevComp = false;
lastNucType = lastNucType || 'dna';
}
syncViewToggleButtons();
}
function setSubtoolbarRightOpen(isOpen) {
if (!subtoolbarRight) return;
if (subtoolbarHideTimer) {
clearTimeout(subtoolbarHideTimer);
subtoolbarHideTimer = null;
}
if (isOpen) {
subtoolbarRight.hidden = false;
requestAnimationFrame(() => {
subtoolbarRight.classList.add('is-open');
});
return;
}
subtoolbarRight.classList.remove('is-open');
subtoolbarHideTimer = window.setTimeout(() => {
if (!subtoolbarRight.classList.contains('is-open')) {
subtoolbarRight.hidden = true;
}
}, 240);
}
function updateSelectionToolbar() {
if (!subtoolbar || !subtoolbarRight) return;
const ranges = getActiveRanges();
const hasSelection = Boolean(ranges.length && selectionTrack);
const hasSearch = Boolean(searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length);
setSubtoolbarRightOpen(hasSelection || hasSearch);
if (clearBtn) clearBtn.disabled = !(hasSelection || hasSearch);
if (highlightTrashBtn) {
const overlaps = hasSelection && ranges.some(r => selectionOverlapsHighlight(selectionTrack, r));
highlightTrashBtn.hidden = !overlaps;
}
}
function selectionStringForSearch(){
if (Array.isArray(selectionRanges) && selectionRanges.length){
return selectionRanges.map(r => r.text || r.seq || r.value || '').join('');
}
if (selectionRange){
return selectionRange.text || selectionRange.seq || selectionRange.value || '';
}
return '';
}
if (!suppressSearchInput && searchInput){
const s = selectionStringForSearch();
if (s) {
searchInput.value = s;
lastSearchQuery = s;
searchAuthoredByUser = false;
}
}
function selectedUnitCount() {
if (!selectionRange || !selectionTrack) return 0;
const nodes = seqViewer.querySelectorAll(`[data-track="${selectionTrack}"].selected`);
return nodes.length;
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatMetaHtml(text) {
const s = String(text || '');
if (!s.trim()) return `–`;
let out = '';
let buf = '';
let mode = null;
const flush = () => {
if (!buf) return;
const cls =
mode === 'num' ? 'seq-meta-num' :
mode === 'alpha' ? 'seq-meta-letter' :
'seq-meta-sep';
out += `${escapeHtml(buf)}`;
buf = '';
};
for (const ch of s) {
const next =
/[0-9]/.test(ch) ? 'num' :
/[A-Za-z]/.test(ch) ? 'alpha' :
'sep';
if (next !== mode) { flush(); mode = next; }
buf += ch;
}
flush();
return out || `–`;
}
function getCaretOffset(el) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0);
if (!el.contains(range.startContainer)) return 0;
const pre = range.cloneRange();
pre.selectNodeContents(el);
pre.setEnd(range.startContainer, range.startOffset);
return pre.toString().length;
}
function setCaretOffset(el, offset) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
let node;
let remaining = Math.max(0, offset);
while ((node = walker.nextNode())) {
const len = node.nodeValue.length;
if (remaining <= len) {
const r = document.createRange();
r.setStart(node, remaining);
r.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
return;
}
remaining -= len;
}
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
}
function findCellForTrackPos(track, pos) {
if (!track || !Number.isFinite(pos)) return null;
let el = seqViewer.querySelector(`[data-track="${track}"][data-pos-start="${pos}"]`);
if (el) return el;
if (track === 'translation') {
const codonStart = pos - ((pos - 1) % 3);
el = seqViewer.querySelector(`[data-track="translation"][data-pos-start="${codonStart}"]`);
if (el) return el;
}
const candidates = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`);
for (const node of candidates) {
const s = Number(node.getAttribute('data-pos-start'));
const e = Number(node.getAttribute('data-pos-end') || s);
if (pos >= s && pos <= e) return node;
}
return null;
}
function getContextFromElement(el) {
const trackEl = el?.closest('.seq-track') || null;
return {
trackEl,
chainStart: Number(trackEl?.dataset.chainStart) || 1,
chainLen: Number(trackEl?.dataset.chainLen) || 0,
baseType: trackEl?.dataset.baseType || null,
chainEl: el?.closest('.sequence-chain') || null
};
}
function getSelectionContextForMeta() {
const startEl = findCellForTrackPos(selectionTrack, selectionRange?.start);
const endEl = findCellForTrackPos(selectionTrack, selectionRange?.end);
const startChain = startEl?.closest('.sequence-chain') || null;
const endChain = endEl?.closest('.sequence-chain') || null;
const sameChain = Boolean(startChain && endChain && startChain === endChain);
const ctx = getContextFromElement(startEl || endEl);
return { startEl, endEl, startChain, endChain, sameChain, ...ctx };
}
function buildTranslationRanges(ranges, track) {
const baseTrack = track || 'nt';
return (ranges || []).map((range) => {
const ctx = getContextForRange(baseTrack, range);
const chainStart = Number(ctx.chainStart) || 1;
const frameOffset = ((range.start - chainStart) % 3 + 3) % 3;
return { ...range, chainStart, frameOffset };
});
}
function formatSelectionForInput() {
if (!selectionRange || !selectionTrack) return '';
const ctx = getSelectionContextForMeta();
const { start, end } = selectionRange;
if (selectionTrack === 'translation' && ctx.sameChain) {
const aaStart = Math.floor((start - ctx.chainStart) / 3) + 1;
const aaEnd = Math.floor((end - ctx.chainStart) / 3) + 1;
const startChar = ctx.startEl ? ((ctx.startEl.getAttribute('data-unit') || ctx.startEl.textContent || '').trim()) : '';
const endChar = ctx.endEl ? (ctx.endEl.textContent || '').trim() : '';
const s = startChar ? `${aaStart}${startChar}` : `${aaStart}`;
const e = endChar ? `${aaEnd}${endChar}` : `${aaEnd}`;
return `${s}-${e}`;
}
if ((selectionTrack === 'triplet' || selectionTrack === 'triplet-rev') && ctx.sameChain) {
const ntStart = ((start - ctx.chainStart) * 3) + 1;
const ntEnd = ((end - ctx.chainStart + 1) * 3);
const startCodon = ctx.startEl ? (ctx.startEl.textContent || '').replace(/\s+/g, '').trim() : '';
const endCodon = ctx.endEl ? (ctx.endEl.textContent || '').replace(/\s+/g, '').trim() : '';
const startChar = startCodon ? startCodon[0] : '';
const endChar = endCodon ? endCodon.slice(-1) : '';
const s = startChar ? `${ntStart}${startChar}` : `${ntStart}`;
const e = endChar ? `${ntEnd}${endChar}` : `${ntEnd}`;
return `${s}-${e}`;
}
const startEl = findCellForTrackPos(selectionTrack, start);
const endEl = findCellForTrackPos(selectionTrack, end);
const startChar = startEl ? (startEl.textContent || '').trim() : '';
const endChar = endEl ? (endEl.textContent || '').trim() : '';
const s = startChar ? `${start}${startChar}` : `${start}`;
const e = endChar ? `${end}${endChar}` : `${end}`;
return `${s}-${e}`;
}
function scrollToPosOnTrack(track, pos) {
const el = findCellForTrackPos(track, pos);
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'center', inline: 'nearest' });
}
}
function smartScrollAfterSelection(track, startPos) {
if (track === 'translation') {
const ntEl = findCellForTrackPos('nt', startPos);
if (ntEl) return ntEl.scrollIntoView({ block: 'center', inline: 'nearest' });
}
scrollToPosOnTrack(track, startPos);
}
function getActiveRanges() {
if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges;
if (selectionRange) return [selectionRange];
return [];
}
function mergeRanges(ranges) {
const list = (ranges || [])
.map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
.sort((a, b) => (a.start - b.start) || (a.end - b.end));
const merged = [];
for (const r of list) {
const last = merged[merged.length - 1];
if (!last || r.start > last.end + 1) merged.push({ ...r });
else last.end = Math.max(last.end, r.end);
}
return merged;
}
function rangesOverlap(start, end, ranges) {
if (!ranges || !ranges.length) return true;
return ranges.some(r => end >= r.start && start <= r.end);
}
function getContextForRange(track, range) {
const startEl = findCellForTrackPos(track, range?.start);
const endEl = findCellForTrackPos(track, range?.end);
const startChain = startEl?.closest('.sequence-chain') || null;
const endChain = endEl?.closest('.sequence-chain') || null;
const sameChain = Boolean(startChain && endChain && startChain === endChain);
const ctx = getContextFromElement(startEl || endEl);
return { startEl, endEl, startChain, endChain, sameChain, ...ctx };
}
function formatRangeForInput(track, range) {
if (!range || !track) return '';
const ctx = getContextForRange(track, range);
const { start, end } = range;
const fmt = (pos, ch) => {
const c = String(ch || '').trim();
return c ? `${c}${pos}` : `${pos}`;
};
if (track === 'translation' && ctx.sameChain) {
const aaStart = Math.floor((start - ctx.chainStart) / 3) + 1;
const aaEnd = Math.floor((end - ctx.chainStart) / 3) + 1;
const sChar = ctx.startEl ? (ctx.startEl.textContent || '').trim() : '';
const eChar = ctx.endEl ? (ctx.endEl.textContent || '').trim() : '';
if (aaStart === aaEnd) return fmt(aaStart, sChar);
return `${fmt(aaStart, sChar)}-${fmt(aaEnd, eChar)}`;
}
if ((track === 'triplet' || track === 'triplet-rev') && ctx.sameChain) {
const ntStart = ((start - ctx.chainStart) * 3) + 1;
const ntEnd = ((end - ctx.chainStart + 1) * 3);
const startCodon = ctx.startEl ? (ctx.startEl.textContent || '').replace(/\s+/g, '').trim() : '';
const endCodon = ctx.endEl ? (ctx.endEl.textContent || '').replace(/\s+/g, '').trim() : '';
const sChar = startCodon ? startCodon[0] : '';
const eChar = endCodon ? endCodon.slice(-1) : '';
if (ntStart === ntEnd) return fmt(ntStart, sChar);
return `${fmt(ntStart, sChar)}-${fmt(ntEnd, eChar)}`;
}
const startEl = findCellForTrackPos(track, start);
const endEl = findCellForTrackPos(track, end);
const sChar = startEl ? (startEl.textContent || '').trim() : '';
const eChar = endEl ? (endEl.textContent || '').trim() : '';
if (start === end) return fmt(start, sChar);
return `${fmt(start, sChar)}-${fmt(end, eChar)}`;
}
function formatSelectionForInputMulti() {
const ranges = getActiveRanges();
if (!ranges.length || !selectionTrack) return '';
return ranges.map(r => formatRangeForInput(selectionTrack, r)).join(', ');
}
function formatRangeForMeta(track, range) {
const s = formatRangeForInput(track, range);
// input uses "-", meta uses " – "
return s.replace('-', ' – ');
}
function collectTextForRange(track, range) {
if (!seqViewer || !track || !range) return '';
const nodes = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`);
let out = '';
nodes.forEach(el => {
const s = Number(el.getAttribute('data-pos-start'));
const e = Number(el.getAttribute('data-pos-end') || s);
if (!Number.isFinite(s) || !Number.isFinite(e)) return;
if (e < range.start || s > range.end) return;
const rawUnit = el.getAttribute('data-unit') || (el.textContent || '');
out += String(rawUnit).replace(/\s+/g, '').trim();
});
return out.toUpperCase();
}
function syncSearchToSelection() {
if (!searchInput) return;
if (selectionFromSearch) return;
if (isEditingRange) return;
const ranges = getActiveRanges();
if (!ranges.length || !selectionTrack) return;
const parts = ranges.map(r => collectTextForRange(selectionTrack, r)).filter(Boolean);
if (!parts.length) return;
const value = (parts.length === 1) ? parts[0] : parts.join('...');
suppressSearchInput = true;
searchInput.value = value;
suppressSearchInput = false;
searchAuthoredByUser = false;
clearSearchHits();
searchMatches = [];
setSearchCountVisible(false);
setSearchCountValue(0);
lastSearchQuery = normalizeSearchQuery(value);
lastSearchIndex = -1;
updateMeta();
}
function updateMeta() {
if (isEditingRange) return;
const ranges = getActiveRanges();
const hasSelection = Boolean(ranges.length && selectionTrack);
const ellipsize = (arr, max = 3) => {
if (arr.length <= max) return arr;
return arr.slice(0, max).concat(['…']);
};
const computeLenForTrackAndRanges = (trk, mergedRanges) => {
if (!trk || !mergedRanges || !mergedRanges.length) return null;
const span = mergedRanges.reduce((sum, r) => sum + (r.end - r.start + 1), 0);
if (trk === 'translation') {
const aa = Math.max(1, Math.round(span / 3));
return { units: aa, suffix: 'aa' };
}
if (trk === 'triplet' || trk === 'triplet-rev') {
return { units: span * 3, suffix: 'nt' };
}
if (trk === 'nt' || trk === 'nt-rev') {
return { units: span, suffix: 'nt' };
}
return { units: span, suffix: 'aa' }; // protein
};
if (hasSelection) {
const countCells = selectedUnitCount();
if (selectionTrack === 'triplet' || selectionTrack === 'triplet-rev') {
lenEl.innerHTML = formatMetaHtml(`${countCells * 3}nt`);
} else {
const unit = trackUnitSuffix(selectionTrack);
lenEl.innerHTML = formatMetaHtml(`${countCells}${unit}`);
}
} else if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) {
const trk = chooseBestSearchTrack();
const merged = getSearchRangesForTrack(trk);
const info = computeLenForTrackAndRanges(trk, merged);
if (info) lenEl.innerHTML = formatMetaHtml(`${info.units}${info.suffix}`);
else lenEl.innerHTML = `–`;
} else {
lenEl.innerHTML = `–`;
}
if (hasSelection) {
const parts = ranges.map(r => formatRangeForMeta(selectionTrack, r));
posEl.innerHTML = formatMetaHtml(ellipsize(parts, 3).join(', '));
return;
}
if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) {
const parts = [];
for (const m of searchMatches) {
if (!m || !m.track || !Array.isArray(m.ranges)) continue;
for (const r of m.ranges) {
if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue;
parts.push(formatRangeForMeta(m.track, r));
}
}
posEl.innerHTML = parts.length
? formatMetaHtml(ellipsize(parts, 3).join(', '))
: `–`;
return;
}
posEl.innerHTML = `–`;
}
function updateSelectionStyles() {
const ranges = getActiveRanges();
const nextSelected = new Set();
if (selectionTrack && ranges.length) {
const map = trackCellIndex[selectionTrack];
if (map) {
for (const r of ranges) {
if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue;
const start = Math.min(r.start, r.end);
const end = Math.max(r.start, r.end);
for (let pos = start; pos <= end; pos++) {
const el = map[pos];
if (el) nextSelected.add(el);
}
}
}
}
lastSelectedCells.forEach((el) => {
if (!nextSelected.has(el)) el.classList.remove('selected');
});
nextSelected.forEach((el) => {
if (!lastSelectedCells.has(el)) el.classList.add('selected');
});
lastSelectedCells = nextSelected;
updateMeta();
updateSelectionToolbar();
rememberLastSelection();
}
function clearSelection() {
selectionRanges = [];
selectionRange = null;
selectionTrack = null;
selectionFromSearch = false;
lastAppliedSelectionTrack = null;
updateSelectionStyles();
markEditorDirty();
}
seqViewer?.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const hitCell = e.target?.closest?.('[data-pos-start]');
if (hitCell) return;
const hitUI = e.target?.closest?.('button, a, input, select, textarea, [contenteditable="true"], .seq-chain-controls');
if (hitUI) return;
if (typeof didDragSelect !== 'undefined' && didDragSelect) return;
clearSelection?.();
});
function setSelection(start, end, opts = {}) {
const fromSearch = Boolean(opts.fromSearch);
const append = Boolean(opts.append);
const appendIndex = Number.isFinite(opts.appendIndex) ? opts.appendIndex : null;
const next = { start: Math.min(start, end), end: Math.max(start, end) };
let nextRanges;
if (append) {
const cur = (selectionRanges && selectionRanges.length) ? selectionRanges.slice() : [];
const idx = (appendIndex !== null) ? appendIndex : cur.length;
cur[idx] = next;
nextRanges = cur;
} else {
nextRanges = [next];
}
nextRanges = mergeRanges(nextRanges);
selectionFromSearch = fromSearch;
selectionRanges = nextRanges;
selectionRange = nextRanges.length ? nextRanges[nextRanges.length - 1] : null;
lastAppliedSelectionTrack = selectionTrack || null;
updateSelectionStyles();
markEditorDirty();
}
function getRangeFromEventTarget(target) {
let t = target;
if (t && t.nodeType === 3) t = t.parentElement;
if (!(t instanceof Element)) return null;
const el = t.closest('[data-pos-start]');
if (!el) return null;
const s = Number(el.getAttribute('data-pos-start'));
const e = Number(el.getAttribute('data-pos-end') || s);
const track = el.dataset.track || null;
if (!Number.isFinite(s) || !Number.isFinite(e)) return null;
return { start: s, end: e, track };
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function getRangeFromPoint(x, y, trackHint = null) {
const rect = seqViewer.getBoundingClientRect();
const px = clamp(x, rect.left + 2, rect.right - 2);
const py = clamp(y, rect.top + 2, rect.bottom - 2);
const stack = document.elementsFromPoint(px, py);
for (const el of stack) {
const r = getRangeFromEventTarget(el);
if (r && r.track && (!trackHint || r.track === trackHint)) return r;
}
for (const el of stack) {
const r = getRangeFromEventTarget(el);
if (r && r.track) return r;
}
return null;
}
function pushUndoState() {
if (lastState === null) { lastState = seqRaw; return; }
undoStack.push({ seq: lastState, scores: [...residueScoreMap] });
if (undoStack.length > 200) undoStack.shift();
lastState = seqRaw;
}
function reorderChain(chainIndex, dir) {
const entries = parseEntriesFromRaw(seqRaw);
if (!entries.length) return;
if (chainIndex < 0 || chainIndex >= entries.length) return;
if (dir === 'up' && chainIndex > 0) [entries[chainIndex - 1], entries[chainIndex]] = [entries[chainIndex], entries[chainIndex - 1]];
else if (dir === 'down' && chainIndex < entries.length - 1) [entries[chainIndex + 1], entries[chainIndex]] = [entries[chainIndex], entries[chainIndex + 1]];
else return;
if (!isApplyingHistory) { pushUndoState(); redoStack = []; }
seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text;
updateSeqViews();
lastState = seqRaw;
}
function deleteResidues(range) {
shiftHighlightsAfterDeletion(range);
const entries = parseEntriesFromRaw(seqRaw);
if (!entries.length) return false;
let cursor = 1;
entries.forEach((entry) => {
const len = entry.seq.length;
const overlapStart = Math.max(range.start, cursor);
const overlapEnd = Math.min(range.end, cursor + len - 1);
if (overlapStart <= overlapEnd) {
const localStart = overlapStart - cursor;
const localEnd = overlapEnd - cursor;
const seqArr = entry.seq.split('');
seqArr.splice(localStart, localEnd - localStart + 1);
entry.seq = seqArr.join('');
}
cursor += len;
});
residueScoreMap.splice(range.start - 1, range.end - range.start + 1);
seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text;
return true;
}
function reverseResiduesInRange(range) {
const entries = parseEntriesFromRaw(seqRaw);
if (!entries.length) return false;
let cursor = 1;
entries.forEach((entry) => {
const len = entry.seq.length;
const overlapStart = Math.max(range.start, cursor);
const overlapEnd = Math.min(range.end, cursor + len - 1);
if (overlapStart <= overlapEnd) {
const localStart = overlapStart - cursor;
const localEnd = overlapEnd - cursor;
const seqArr = entry.seq.split('');
const slice = seqArr.slice(localStart, localEnd + 1).reverse();
seqArr.splice(localStart, localEnd - localStart + 1, ...slice);
entry.seq = seqArr.join('');
}
cursor += len;
});
const scoreSlice = residueScoreMap.slice(range.start - 1, range.end);
scoreSlice.reverse();
residueScoreMap.splice(range.start - 1, scoreSlice.length, ...scoreSlice);
seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text;
return true;
}
function reverseCodonsInRange(range) {
const entries = parseEntriesFromRaw(seqRaw);
if (!entries.length) return false;
let cursor = 1;
entries.forEach((entry) => {
const len = entry.seq.length;
const overlapStart = Math.max(range.start, cursor);
const overlapEnd = Math.min(range.end, cursor + len - 1);
if (overlapStart <= overlapEnd) {
const localStart = overlapStart - cursor;
const localEnd = overlapEnd - cursor;
const frameOffset = ((range.start - cursor) % 3 + 3) % 3;
const alignedStart = localStart - ((localStart - frameOffset) % 3 + 3) % 3;
const alignedEnd = localEnd - ((localEnd - frameOffset) % 3 + 3) % 3 + 2;
const safeStart = Math.max(0, alignedStart);
const safeEnd = Math.min(len - 1, alignedEnd);
const codonCount = Math.floor((safeEnd - safeStart + 1) / 3);
if (codonCount > 1) {
const sliceLen = codonCount * 3;
const actualEnd = safeStart + sliceLen - 1;
const seqArr = entry.seq.split('');
const slice = seqArr.slice(safeStart, actualEnd + 1);
const codons = [];
for (let i = 0; i < slice.length; i += 3) codons.push(slice.slice(i, i + 3));
codons.reverse();
seqArr.splice(safeStart, sliceLen, ...codons.flat());
entry.seq = seqArr.join('');
const globalStart = cursor + safeStart;
const scoreSlice = residueScoreMap.slice(globalStart - 1, globalStart - 1 + sliceLen);
const scoreCodons = [];
for (let i = 0; i < scoreSlice.length; i += 3) scoreCodons.push(scoreSlice.slice(i, i + 3));
scoreCodons.reverse();
residueScoreMap.splice(globalStart - 1, sliceLen, ...scoreCodons.flat());
}
}
cursor += len;
});
seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text;
return true;
}
function handleSequenceText(text, fileName) {
const content = String(text || '');
const persistedState = extractPersistedSequenceState(content);
if (persistedState) {
applySequenceEditorState(persistedState);
if (fileName && nameInput) nameInput.value = stripTrailingExtension(fileName);
return;
}
const structured = parseStructureFile(content);
if (structured && applyStructuredEntries(structured)) {
if (fileName && nameInput) nameInput.value = fileName;
return;
}
if (!isApplyingHistory) { pushUndoState(); redoStack = []; }
const combined = seqRaw ? `${seqRaw}\n${content}` : content;
seqRaw = sanitizeAndGroup(combined).text;
const processed = processRaw(seqRaw);
residueScoreMap = new Array(processed.length).fill(null);
applyDefaultViewToggles(detectSeqType(processed.seq));
updateSeqViews();
lastState = seqRaw;
if (fileName && nameInput) nameInput.value = fileName;
}
function fallbackCopy(text) {
try {
const temp = document.createElement('textarea');
temp.value = text;
temp.style.position = 'fixed';
temp.style.left = '-9999px';
temp.setAttribute('readonly', '');
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
notify('success', 'Copied');
} catch (err) {
notify('error', 'Could not copy');
}
}
function copyToClipboard(text, okMsg) {
if (!text) return notify('error', 'Nothing to copy');
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text)
.then(() => notify('success', okMsg || 'Copied'))
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function getActiveRangesSafe(){
if (typeof getActiveRanges === 'function') {
const r = getActiveRanges();
return Array.isArray(r) ? r : [];
}
if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges;
if (selectionRange) return [selectionRange];
return [];
}
function mergeRangesLocal(ranges){
const cleaned = (ranges || [])
.map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
.sort((a,b) => a.start - b.start);
if (!cleaned.length) return [];
const out = [cleaned[0]];
for (let i=1;i r.start));
const maxEnd = Math.max(...ranges.map(r => r.end));
const unitsSelected = ranges.reduce((acc,r) => acc + (r.end - r.start + 1), 0);
const track = selectionTrack || getPrimarySelectableTrack?.() || 'protein';
let count = unitsSelected;
if (track === 'translation' || track === 'nt' || track === 'nt-rev') {
count = Math.max(1, Math.ceil(unitsSelected / 3));
}
const point = (where === 'left') ? minStart : maxEnd;
const entries = parseEntriesFromRaw(seqRaw);
if (!entries || !entries.length) return;
let chainStart = 1;
let idx = -1;
for (let i=0;i= chainStart && point <= chainEnd) { idx = i; break; }
chainStart = chainEnd + 1;
}
if (idx < 0) return;
const entry = entries[idx];
const baseType = detectSeqType(String(entry.seq || ''));
if (!isApplyingHistory) {
undoStack.push(seqRaw);
redoStack = [];
}
if (baseType === 'dna' || baseType === 'rna') {
const localMin = minStart - chainStart;
const localMax = maxEnd - chainStart;
const codonStart = Math.floor(localMin / 3);
const codonEnd = Math.floor(localMax / 3);
const off = (where === 'left')
? (codonStart * 3)
: ((codonEnd + 1) * 3);
const ins = (baseType === 'rna')
? ('NNN'.repeat(count)).replace(/T/g,'U')
: ('NNN'.repeat(count));
entry.seq = entry.seq.slice(0, off) + ins + entry.seq.slice(off);
} else {
const localOff = (where === 'left')
? (minStart - chainStart)
: (maxEnd - chainStart + 1);
entry.seq = entry.seq.slice(0, localOff) + 'X'.repeat(count) + entry.seq.slice(localOff);
}
const raw = entriesToRaw(entries.map(e => ({ header: e.header, seq: e.seq })));
seqRaw = sanitizeAndGroup(raw).text;
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState?.();
notify?.('success', `Inserted ${count} ${ (baseType === 'dna' || baseType === 'rna') ? 'codon(s)' : 'residue(s)' }`);
}
insertLeftBtn?.addEventListener('click', () => insertSelectionAsX('left'));
insertRightBtn?.addEventListener('click', () => insertSelectionAsX('right'));
function getSelectedBoundsFromDOM(){
const nodes = Array.from(seqViewer?.querySelectorAll?.('.selected[data-pos-start], .selected[data-pos]') || []);
const vals = [];
nodes.forEach(n => {
const a = Number(n.dataset.posStart || n.dataset.pos);
const b = Number(n.dataset.posEnd || n.dataset.pos);
if (Number.isFinite(a)) vals.push(a);
if (Number.isFinite(b)) vals.push(b);
});
if (!vals.length) return null;
return { min: Math.min(...vals), max: Math.max(...vals) };
}
function getRangesFromSelectedCells() {
if (!seqViewer) return { ranges: [], track: null };
const selected = Array.from(seqViewer.querySelectorAll('.selected[data-pos-start]'));
if (!selected.length) return { ranges: [], track: null };
const ranges = selected
.map((el) => {
const s = Number(el.getAttribute('data-pos-start'));
const e = Number(el.getAttribute('data-pos-end') || s);
if (!Number.isFinite(s) || !Number.isFinite(e)) return null;
return { start: s, end: e };
})
.filter(Boolean);
return {
ranges: mergeRanges(ranges),
track: selected[0]?.dataset?.track || null
};
}
function mapGlobalPosToEntry(entries, pos1){
let cur = 1;
for (let i=0;i= start && pos1 <= end + 1) {
return { i, local: pos1 - start + 1 };
}
cur = end + 1;
}
return { i: Math.max(0, entries.length - 1), local: (entries[entries.length - 1]?.seq?.length || 0) + 1 };
}
function insertIntoCanvas(where){
if (!seqRaw) { notify?.('error', 'No sequence loaded'); return; }
const entries = parseEntriesFromRaw(seqRaw) || [];
if (!entries.length) { notify?.('error', 'No sequence loaded'); return; }
let anchor = getSelectedBoundsFromDOM();
if (!anchor) {
const ranges = (Array.isArray(selectionRanges) && selectionRanges.length)
? selectionRanges
: (selectionRange ? [selectionRange] : []);
if (!ranges.length) { notify?.('error', 'Select residues / bases first'); return; }
anchor = { min: Math.min(...ranges.map(r => r.start)), max: Math.max(...ranges.map(r => r.end)) };
}
const totalLen = entries.reduce((acc,e)=>acc + (e.seq?.length || 0), 0);
let pos = (where === 'before') ? anchor.min : (anchor.max + 1);
pos = Math.max(1, Math.min(totalLen + 1, pos));
const mapped = mapGlobalPosToEntry(entries, pos);
const ent = entries[mapped.i];
const local = mapped.local;
const baseType = (typeof detectSeqType === 'function') ? detectSeqType(ent.seq) : 'protein';
const track = selectionTrack || getPrimarySelectableTrack?.() || null;
const isAAlevel = (track === 'translation' || track === 'protein' || track === 'aa');
let ins = 'X';
if (baseType === 'dna' || baseType === 'rna') ins = isAAlevel ? 'NNN' : 'N';
const s = String(ent.seq || '');
const idx0 = Math.max(0, Math.min(s.length, local - 1));
ent.seq = s.slice(0, idx0) + ins + s.slice(idx0);
const raw = entriesToRaw(entries.map(e => ({ header: e.header, seq: e.seq })));
const grouped = sanitizeAndGroup(raw);
seqRaw = grouped.text;
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState?.();
notify?.('success', `Inserted ${ins.length === 3 ? 'codon' : 'residue'} ${where === 'before' ? 'left' : 'right'}`);
}
insertLeftBtn?.addEventListener('click', () => insertIntoCanvas('before'));
insertRightBtn?.addEventListener('click', () => insertIntoCanvas('after'));
function getMetaRangePlain() {
const ranges = getActiveRanges();
if (ranges.length && selectionTrack) return formatSelectionForInputMulti();
if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) {
const t = chooseBestSearchTrack();
const merged = getSearchRangesForTrack(t);
return merged.map(r => formatRangeForInput(t, r)).join(', ');
}
return '';
}
posEl?.addEventListener('mousedown', (e) => {
if (isEditingRange) return;
if (e.shiftKey || e.detail >= 2) return;
const txt = getMetaRangePlain();
if (!txt) return;
e.preventDefault();
e.stopPropagation();
copyToClipboard(txt, 'Copied range');
});
function getMetaLengthPlain() {
const ranges = getActiveRanges();
const unitMultiplier = (trk) => (trk === 'triplet' || trk === 'triplet-rev') ? 3 : 1;
if (ranges.length && selectionTrack) {
const countCells = selectedUnitCount();
return String(countCells * unitMultiplier(selectionTrack));
}
if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) {
const track = chooseBestSearchTrack();
const merged = getSearchRangesForTrack(track);
const units = merged.reduce((sum, r) => sum + (r.end - r.start + 1), 0);
return String(units * unitMultiplier(track));
}
return '';
}
function closeCopyMenu() {
if (!copyMenu) return;
copyMenu.hidden = true;
}
function openCopyMenu() {
if (!copyMenu || !copyBtn) return;
copyMenu.hidden = false;
}
function collectSelectedTextFromTrack(track) {
if (!track) return '';
const selected = Array.from(seqViewer.querySelectorAll(`[data-track="${track}"].selected`));
if (!selected.length) return '';
selected.sort((a, b) => {
const as = Number(a.getAttribute('data-pos-start')) || 0;
const bs = Number(b.getAttribute('data-pos-start')) || 0;
return as - bs;
});
return selected.map((el) => (el.getAttribute('data-unit') || el.textContent || '').trim()).join('');
}
function exportWhole(kind) {
const entries = parseEntriesFromRaw(seqRaw);
if (!entries.length) return '';
const parts = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const baseType = detectSeqType(e.seq);
const header = e.header ? e.header.replace(/^>/, '') : `Chain ${String.fromCharCode(65 + (i % 26))}`;
let out = '';
if (kind === 'protein') {
if (baseType === 'dna' || baseType === 'rna') {
out = translateNuc(e.seq, baseType);
} else {
out = String(e.seq || '').toUpperCase();
}
} else if (kind === 'dna') {
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') out = backTranslateProtein(e.seq, 'dna');
else out = toDNA(e.seq);
} else if (kind === 'rna') {
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') out = backTranslateProtein(e.seq, 'rna');
else out = toRNA(e.seq);
} else if (kind === 'revcomp') {
const nucType = (baseType === 'rna') ? 'rna' : 'dna';
const nuc = (nucType === 'rna') ? toRNA(e.seq) : toDNA(e.seq);
out = reverseComplementSeq(nuc, nucType);
}
if (!out) continue;
parts.push(`>${header}`);
parts.push(out);
if (i < entries.length - 1) parts.push('');
}
return parts.join('\n');
}
function selectionOverlapsHighlight(track, range) {
if (!track || !range) return false;
return highlights.some((h) => h.track === track && !(range.end < h.start || range.start > h.end));
}
function shiftHighlightsAfterDeletion(range) {
const del = range.end - range.start + 1;
for (let i = highlights.length - 1; i >= 0; i--) {
const h = highlights[i];
if (h.end < range.start) continue;
if (h.start > range.end) {
h.start -= del;
h.end -= del;
continue;
}
const leftRemains = h.start < range.start;
const rightRemains = h.end > range.end;
if (leftRemains && rightRemains) {
h.end -= del;
continue;
}
if (leftRemains && !rightRemains) {
h.end = range.start - 1;
continue;
}
if (!leftRemains && rightRemains) {
h.start = range.start;
h.end -= del;
continue;
}
highlights.splice(i, 1);
}
}
function removeHighlightsInSelection(track, range) {
for (let i = highlights.length - 1; i >= 0; i--) {
const h = highlights[i];
if (h.track !== track) continue;
if (range.start <= h.end && range.end >= h.start) highlights.splice(i, 1);
}
}
function buildSwatches() {
if (!highlightSwatches) return;
highlightSwatches.innerHTML = '';
swatchKeys.forEach((key, idx) => {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = 'seq-highlight-swatch';
swatch.style.background = palette[key];
swatch.dataset.color = palette[key];
if (idx === 0) swatch.classList.add('is-active');
swatch.addEventListener('click', () => {
highlightSwatches.querySelectorAll('.seq-highlight-swatch')
.forEach((el) => el.classList.remove('is-active'));
swatch.classList.add('is-active');
const color = swatch.dataset.color;
const label = highlightNameInput ? highlightNameInput.value.trim() : '';
if (searchAuthoredByUser) {
refreshSearchDecorations({ preserveSelection: true });
if (Array.isArray(searchMatches) && searchMatches.length) {
const byTrack = new Map();
for (const m of searchMatches) {
if (!m || !m.track || !Array.isArray(m.ranges)) continue;
if (!byTrack.has(m.track)) byTrack.set(m.track, []);
for (const r of m.ranges) {
if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue;
byTrack.get(m.track).push({ start: r.start, end: r.end });
}
}
renderSequenceViewer();
return;
}
}
const ranges = mergeRanges(getActiveRanges());
if (!ranges.length || !selectionTrack) return;
for (const r of ranges) {
highlights.push({ start: r.start, end: r.end, color, label, track: selectionTrack });
}
renderSequenceViewer();
});
highlightSwatches.appendChild(swatch);
});
}
let pendingDeleteAssetPaths = [];
function openDeleteConfirm(paths = []) {
const values = Array.isArray(paths) ? paths : [paths];
pendingDeleteAssetPaths = values.map((path) => String(path || '')).filter(Boolean);
if (!deleteBackdrop) return;
deleteBackdrop.hidden = false;
syncBodyScrollLock();
const count = pendingDeleteAssetPaths.length;
if (deleteCountLabel) deleteCountLabel.textContent = String(count);
if (deleteText) deleteText.textContent = count === 1
? 'You are about to permanently delete 1 asset. This cannot be undone.'
: `You are about to permanently delete ${count} assets. This cannot be undone.`;
if (deleteCountInput) {
deleteCountInput.value = '';
deleteCountInput.placeholder = String(count);
}
setTimeout(() => deleteCountInput?.focus?.(), 0);
}
function closeDeleteConfirm() {
if (!deleteBackdrop) return;
deleteBackdrop.hidden = true;
pendingDeleteAssetPaths = [];
if (deleteCountInput) deleteCountInput.value = '';
syncBodyScrollLock();
}
function renderDuplicateInputs(paths = [], existingPaths = []) {
if (!duplicateInputsWrap) return [];
const values = (Array.isArray(paths) ? paths : [paths]).filter(Boolean);
const rows = values.map((path, idx) => {
const suggested = buildDuplicatedAssetName(path, existingPaths);
const label = toCleanAssetLabel(path);
const rowId = `editor-duplicate-name-${idx}`;
return ``;
});
duplicateInputsWrap.innerHTML = rows.join('');
return [...duplicateInputsWrap.querySelectorAll('[data-duplicate-name-input]')];
}
function openDuplicateConfirm(paths = [], existingPaths = []) {
if (!duplicateBackdrop) return [];
const inputs = renderDuplicateInputs(paths, existingPaths);
duplicateBackdrop.hidden = false;
syncBodyScrollLock();
setTimeout(() => inputs[0]?.focus?.(), 0);
return inputs;
}
function closeDuplicateConfirm() {
if (!duplicateBackdrop) return;
duplicateBackdrop.hidden = true;
if (duplicateInputsWrap) duplicateInputsWrap.innerHTML = '';
syncBodyScrollLock();
}
async function requestDuplicateNames(paths = [], existingPaths = []) {
const targets = (Array.isArray(paths) ? paths : [paths]).filter(Boolean);
if (!targets.length) return [];
if (!duplicateBackdrop || !duplicateInputsWrap || !duplicateConfirm || !duplicateCancel) {
return targets.map((path) => buildDuplicatedAssetName(path, existingPaths));
}
const inputs = openDuplicateConfirm(targets, existingPaths);
return await new Promise((resolve, reject) => {
const cleanup = () => {
duplicateConfirm.removeEventListener('click', onConfirm);
duplicateCancel.removeEventListener('click', onCancel);
duplicateClose?.removeEventListener('click', onCancel);
duplicateBackdrop.removeEventListener('click', onBackdrop);
duplicateInputsWrap.removeEventListener('keydown', onKeydown);
};
const onConfirm = () => {
const values = inputs.map((input) => String(input.value || '').trim());
const emptyIdx = values.findIndex((v) => !v);
if (emptyIdx >= 0) {
showToast?.('error', 'Enter a file name for each duplicate');
inputs[emptyIdx]?.focus?.();
return;
}
cleanup();
closeDuplicateConfirm();
resolve(values);
};
const onCancel = () => {
cleanup();
closeDuplicateConfirm();
reject(new Error('cancelled'));
};
const onBackdrop = (e) => {
if (e.target === duplicateBackdrop) onCancel();
};
const onKeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
onConfirm();
}
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
duplicateConfirm.addEventListener('click', onConfirm);
duplicateCancel.addEventListener('click', onCancel);
duplicateClose?.addEventListener('click', onCancel);
duplicateBackdrop.addEventListener('click', onBackdrop);
duplicateInputsWrap.addEventListener('keydown', onKeydown);
});
}
async function deleteWholeFileNow() {
if (!pendingDeleteAssetPaths.length) return closeDeleteConfirm();
const expected = String(pendingDeleteAssetPaths.length);
const typed = String(deleteCountInput?.value || '').trim();
if (typed !== expected) {
showToast?.('error', `Type ${expected} to confirm deletion`);
return;
}
const toDelete = pendingDeleteAssetPaths.slice();
closeDeleteConfirm();
const assetsPanel = document.querySelector('[data-editor-panel="assets"]');
toDelete.forEach((path) => {
const card = [...(assetsPanel?.querySelectorAll('[data-asset-path]') || [])]
.find((el) => (el.getAttribute('data-asset-path') || '') === path);
if (card) card.remove();
});
try {
const { jwt, payload } = await getEditorAuthContext();
await Promise.all(toDelete.map((path) => deleteEditorAsset(payload, path, jwt)));
if (toDelete.includes(currentAssetPath)) {
clearSequenceState();
currentAssetPath = null;
}
showToast?.('success', toDelete.length > 1 ? 'Assets deleted' : 'Asset deleted');
suppressAssetsOverlayOnce = true;
await refreshAssetsForCurrentContext();
} catch (err) {
showToast?.('error', err?.message || 'Failed to delete asset');
suppressAssetsOverlayOnce = true;
await refreshAssetsForCurrentContext();
}
}
if (lockBtn) {
lockBtn.addEventListener('click', () => {
const locked = !lockBtn.classList.contains('is-locked');
lockBtn.classList.toggle('is-locked', locked);
lockBtn.classList.toggle('is-unlocked', !locked);
notify('success', locked ? 'Editor locked' : 'Editor unlocked');
});
}
if (groupToggleBtn) {
groupToggleBtn.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
groupModeIndex = (groupModeIndex + 1) % groupModes.length;
groupSize = groupModes[groupModeIndex];
groupToggleBtn.setAttribute('data-mode', String(groupSize));
renderSequenceViewer();
});
}
function buildTranslationRangesFromSelection(ranges, meta) {
const out = [];
for (const r of ranges) {
for (const m of meta) {
if (m.baseType !== 'dna' && m.baseType !== 'rna') continue;
const s = Math.max(r.start, m.start);
const t = Math.min(r.end, m.end);
if (s > t) continue;
const frameOffset = normFrameOffset(s - m.start);
out.push({ start: s, end: t, chainStart: m.start, frameOffset });
}
}
return normalizeAndMergeTranslationRanges(out);
}
if (translateBtn) {
translateBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const entries = parseEntriesFromRaw(seqRaw);
if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; }
const meta = buildChainMeta(entries);
const hasNuc = meta.some(m => m.baseType === 'dna' || m.baseType === 'rna');
if (!hasNuc) {
if (!showProtein) {
showProtein = true;
updateSeqViews();
updateSelectionToolbar();
}
return;
}
let ranges = getMainSelectionRanges();
if (!ranges.length) {
const domSel = (typeof getRangesFromSelectedCells === 'function')
? getRangesFromSelectedCells()
: null;
if (domSel?.ranges?.length && (domSel.track === 'nt' || domSel.track === 'nt-rev' || domSel.track === 'translation')) {
ranges = mergeRangesLocal(domSel.ranges);
}
}
if (ranges.length) {
const built = buildTranslationRangesFromSelection(ranges, meta);
if (!built.length) {
notify?.('error', 'Selection has no nucleotide sequence to translate');
return;
}
translationRanges = applyTranslationRangeOverrides(translationRanges, built);
showProtein = true;
updateSeqViews();
updateSelectionToolbar();
notify?.('success', 'Translated selection');
return;
}
translationRanges = null;
showProtein = true;
translationFrameOffset = normFrameOffset(translationFrameOffset + 1);
updateSeqViews();
updateSelectionToolbar();
notify?.('success', `Frame +${translationFrameOffset + 1}`);
}, true);
}
if (zoomInBtn && zoomOutBtn) {
const editorBody = document.querySelector('.seq-editor-body');
const applyZoom = () => {
if (!editorBody) return;
editorBody.style.setProperty('--seq-font-size', `${fontSize}px`);
editorBody.style.setProperty('--seq-cell-size', `${fontSize * 1.6}px`);
const ntFont = Math.max(12, Math.round(fontSize * 0.82));
editorBody.style.setProperty('--seq-nt-font-size', `${ntFont}px`);
editorBody.style.setProperty('--seq-nt-cell-size', `${ntFont * 1.45}px`);
requestAnimationFrame(() => renderSequenceViewer());
};
zoomInBtn.addEventListener('click', () => { fontSize = Math.min(fontSize + 2, 34); applyZoom(); });
zoomOutBtn.addEventListener('click', () => { fontSize = Math.max(fontSize - 2, 16); applyZoom(); });
applyZoom();
}
if (undoBtn) {
undoBtn.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
if (!undoStack.length) return notify('error', 'Nothing to undo');
const state = undoStack.pop();
redoStack.push({ seq: seqRaw, scores: [...residueScoreMap] });
isApplyingHistory = true;
seqRaw = state.seq;
residueScoreMap = state.scores ? [...state.scores] : [];
updateSeqViews();
isApplyingHistory = false;
lastState = seqRaw;
notify('success', 'Undid change');
});
}
if (redoBtn) {
redoBtn.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
if (!redoStack.length) return notify('error', 'Nothing to redo');
const state = redoStack.pop();
undoStack.push({ seq: seqRaw, scores: [...residueScoreMap] });
isApplyingHistory = true;
seqRaw = state.seq;
residueScoreMap = state.scores ? [...state.scores] : [];
updateSeqViews();
isApplyingHistory = false;
lastState = seqRaw;
notify('success', 'Redid change');
});
}
if (mutateBtn) {
mutateBtn.addEventListener('click', (e) => {
e.stopPropagation();
mutateBtn.classList.toggle('is-active');
});
}
if (copyBtn) {
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (selectionRange && selectionTrack) {
const ranges = mergeRanges(getActiveRanges());
const parts = ranges
.map(r => collectTextForRange(selectionTrack, r))
.filter(Boolean);
const text = (parts.length <= 1) ? (parts[0] || '') : parts.join('...');
return copyToClipboard(text, 'Copied selection');
}
if (!seqRaw) return notify('error', 'Nothing to copy');
openCopyMenu();
});
}
if (copyProteinWholeBtn) copyProteinWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('protein'), 'Copied protein'); });
if (copyDNAWholeBtn) copyDNAWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('dna'), 'Copied DNA'); });
if (copyRNAWholeBtn) copyRNAWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('rna'), 'Copied RNA'); });
if (copyRevWholeBtn) copyRevWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('revcomp'), 'Copied reverse complement'); });
document.addEventListener('click', (e) => {
if (!copyMenu || copyMenu.hidden) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('#seq-copy-menu') || t.closest('#seq-copy-btn')) return;
closeCopyMenu();
});
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
if (!seqRaw) return notify('error', 'Nothing to delete');
const selRanges = mergeRanges(getActiveRanges());
const hasSelection = Boolean(selRanges.length && selectionTrack);
const canDeleteSearchMatches = Boolean(!hasSelection && searchAuthoredByUser);
let rangesToDelete = [];
if (hasSelection) {
rangesToDelete = selRanges.slice();
} else if (canDeleteSearchMatches) {
refreshSearchDecorations({ preserveSelection: true });
if (!Array.isArray(searchMatches) || !searchMatches.length) {
return notify('error', 'No match found');
}
rangesToDelete = mergeRanges(
searchMatches.flatMap(m => (m.ranges || []).map(r => ({ start: r.start, end: r.end })))
);
} else {
return notify('error', 'Select a region to delete');
}
if (!rangesToDelete.length) return notify('error', 'Nothing to delete');
pushUndoState();
redoStack = [];
rangesToDelete.sort((a, b) => b.start - a.start);
for (const r of rangesToDelete) deleteResidues(r);
clearSelection();
hardClearSearchUI();
updateSeqViews();
lastState = seqRaw;
notify('success', rangesToDelete.length > 1 ? 'Deleted selections' : 'Deleted selection');
});
}
if (headerFileInput) {
headerFileInput.addEventListener('change', (e) => {
if (isLocked()) {
notify('error', 'Editor is locked');
headerFileInput.value = '';
return;
}
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => handleSequenceText(ev.target?.result ? ev.target.result : '', file.name);
reader.readAsText(file);
headerFileInput.value = '';
});
}
seqViewer.addEventListener('dragstart', (e) => e.preventDefault());
seqViewer.addEventListener('dragover', (e) => e.preventDefault());
seqViewer.addEventListener('drop', (e) => {
e.preventDefault();
if (isLocked()) return;
const dt = e.dataTransfer;
if (!dt) return;
const text = dt.getData('text/plain');
if (text) return handleSequenceText(text);
const file = dt.files && dt.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => handleSequenceText(ev.target?.result ? ev.target.result : '', file.name);
reader.readAsText(file);
}
});
function mergeRangesSimple(ranges) {
const cleaned = (ranges || [])
.map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) }))
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end))
.sort((a, b) => (a.start - b.start) || (a.end - b.end));
if (!cleaned.length) return [];
const out = [cleaned[0]];
for (let i = 1; i < cleaned.length; i++) {
const cur = cleaned[i];
const last = out[out.length - 1];
if (cur.start <= last.end + 1) last.end = Math.max(last.end, cur.end);
else out.push(cur);
}
return out;
}
function getAnySelectionRanges() {
if (typeof getActiveRanges === 'function') {
const r = getActiveRanges();
if (Array.isArray(r) && r.length) return mergeRangesSimple(r);
}
if (Array.isArray(selectionRanges) && selectionRanges.length) return mergeRangesSimple(selectionRanges);
if (selectionRange) return mergeRangesSimple([selectionRange]);
return [];
}
function getSelectionForAction() {
let ranges = getAnySelectionRanges();
let track = selectionTrack || (typeof getPrimarySelectableTrack === 'function' ? getPrimarySelectableTrack() : null);
if ((!ranges.length || !track) && lastGoodSelection?.ranges?.length) {
ranges = mergeRangesSimple(lastGoodSelection.ranges);
track = lastGoodSelection.track || track;
}
if (!ranges.length) return null;
return { track: track || null, ranges };
}
['mouseup', 'keyup', 'touchend'].forEach((evt) => {
document.addEventListener(evt, () => {
try { rememberLastSelection?.(); } catch (_) {}
}, { passive: true });
});
function reverseSelectionApply() {
if (isLocked?.()) { notify?.('error', 'Unlock to edit'); return; }
const sel = getSelectionForAction();
if (!sel) { notify?.('error', 'Select something first'); return; }
const entries = parseEntriesFromRaw(seqRaw);
if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; }
const meta = buildChainMeta(entries); // you already have this function
const out = entries.map(e => ({ header: e.header || null, seq: String(e.seq || '').toUpperCase() }));
for (const R of sel.ranges) {
for (const m of meta) {
const ovStart = Math.max(m.start, R.start);
const ovEnd = Math.min(m.end, R.end);
if (ovStart > ovEnd) continue;
const localStart1 = ovStart - m.start + 1; // 1-based
const localEnd1 = ovEnd - m.start + 1; // 1-based
const idx = m.i;
const baseType = m.baseType;
if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') {
const a0 = localStart1 - 1;
const b0 = localEnd1 - 1;
out[idx].seq = reverseStringSpan(out[idx].seq, a0, b0);
try { reverseProteinOverrideCodons?.(out[idx], idx, localStart1, localEnd1); } catch (_) {}
continue;
}
if (baseType === 'dna' || baseType === 'rna') {
const a0 = localStart1 - 1;
const b0 = localEnd1 - 1;
out[idx].seq = reverseStringSpan(out[idx].seq, a0, b0);
}
}
}
if (!isApplyingHistory) { pushUndoState(); redoStack = []; }
const rawOut = (typeof entriesToRaw === 'function') ? entriesToRaw(out) : entriesToRawFallback(out);
seqRaw = sanitizeAndGroup(rawOut).text;
updateSeqViews();
updateSelectionToolbar();
updateEditButtonState?.();
lastState = seqRaw;
try {
if (typeof clearSelection === 'function') clearSelection();
if (typeof setSelection === 'function') {
const rr = sel.ranges;
if (rr.length) {
selectionTrack = sel.track || selectionTrack || null;
setSelection(rr[0].start, rr[0].end);
for (let i = 1; i < rr.length; i++) {
setSelection(rr[i].start, rr[i].end, { append: true, appendIndex: i });
}
}
}
} catch (_) {}
notify?.('success', 'Reversed');
}
reverseBtn?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
reverseSelectionApply();
}, true);
function beginSelectionDrag(point, opts = {}) {
closeCopyMenu();
didDragSelect = false;
blurEditorFieldsForSelection();
const r = getRangeFromPoint(point.x, point.y, null);
if (!r || !r.track) {
clearSelection();
hardClearSearchUI();
dragAnchorStart = null;
stopDragAutoScroll();
return;
}
if (opts.event) {
opts.event.preventDefault();
}
const append = Boolean(opts.append);
const keepSearchMode = Boolean(searchAuthoredByUser && selectionFromSearch);
downRange = r;
downWasSelected = selectionContainsRange(r.track, r);
if (!append) {
selectionTrack = r.track;
if (!(keepSearchMode && selectionTrack === r.track)) {
selectionFromSearch = false;
}
} else {
if (selectionTrack && selectionTrack !== r.track) return;
selectionTrack = selectionTrack || r.track;
}
dragStartPointer = { x: point.x, y: point.y };
dragAnchorStart = r.start;
dragAppending = append;
dragAppendIndex = append ? selectionRanges.length : null;
dragPointer = { x: point.x, y: point.y };
startDragAutoScroll();
suppressNextClick = true;
if (downWasSelected) return;
setSelection(
r.start,
r.end,
append ? { append: true, appendIndex: dragAppendIndex } : {}
);
}
function updateSelectionDrag(point, opts = {}) {
if (dragAnchorStart === null) return;
if (opts.requireButton && (opts.buttons & 1) === 0) return;
if (dragStartPointer) {
const dx = point.x - dragStartPointer.x;
const dy = point.y - dragStartPointer.y;
const dist = Math.hypot(dx, dy);
if (dist < 4) return; // threshold
}
if (opts.event && opts.preventDefault) {
opts.event.preventDefault();
}
didDragSelect = true;
dragPointer = { x: point.x, y: point.y };
const r = getRangeFromPoint(point.x, point.y, selectionTrack || null);
if (!r || !r.track) return;
if (selectionTrack && r.track !== selectionTrack) return;
setSelection(
dragAnchorStart,
r.end,
dragAppending ? { append: true, appendIndex: dragAppendIndex } : {}
);
}
function endSelectionDrag() {
const clickedRange = downRange;
const wasSelected = downWasSelected;
dragStartPointer = null;
downRange = null;
downWasSelected = false;
dragAnchorStart = null;
stopDragAutoScroll();
if (didDragSelect) {
seqViewer.dataset.justDragged = '1';
setTimeout(() => { delete seqViewer.dataset.justDragged; }, 0);
}
if (!didDragSelect && clickedRange && wasSelected && selectionTrack === clickedRange.track) {
const keepSearchMode = Boolean(searchAuthoredByUser && selectionFromSearch);
if (keepSearchMode) {
searchSelectionCustom = true;
} else {
selectionFromSearch = false;
}
const cur = mergeRanges(getActiveRanges());
const next = subtractRangeFromRanges(cur, clickedRange);
selectionRanges = next;
selectionRange = next.length ? next[next.length - 1] : null;
if (!next.length) {
selectionTrack = null;
updateSelectionStyles();
if (!searchAuthoredByUser) hardClearSearchUI();
} else {
updateSelectionStyles();
syncSearchToSelection();
}
dragAppending = false;
dragAppendIndex = null;
return;
}
if (!selectionFromSearch && getActiveRanges().length && selectionTrack) {
syncSearchToSelection();
}
dragAppending = false;
dragAppendIndex = null;
}
seqViewer.addEventListener('mousedown', (e) => {
beginSelectionDrag({ x: e.clientX, y: e.clientY }, { append: e.ctrlKey || e.metaKey, event: e });
});
seqViewer.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1 || activeTouchId !== null) return;
const touch = e.touches[0];
activeTouchId = touch.identifier;
beginSelectionDrag({ x: touch.clientX, y: touch.clientY }, { append: false, event: e });
}, { passive: false });
seqViewer.addEventListener('touchmove', (e) => {
if (activeTouchId === null) return;
const touch = Array.from(e.touches).find((t) => t.identifier === activeTouchId);
if (!touch) return;
updateSelectionDrag(
{ x: touch.clientX, y: touch.clientY },
{ requireButton: false, event: e, preventDefault: true }
);
}, { passive: false });
seqViewer.addEventListener('touchend', (e) => {
if (activeTouchId === null) return;
const ended = Array.from(e.changedTouches).some((t) => t.identifier === activeTouchId);
if (!ended) return;
activeTouchId = null;
endSelectionDrag();
});
seqViewer.addEventListener('touchcancel', (e) => {
if (activeTouchId === null) return;
const canceled = Array.from(e.changedTouches).some((t) => t.identifier === activeTouchId);
if (!canceled) return;
activeTouchId = null;
endSelectionDrag();
});
let dragScrollRaf = null;
seqViewer.addEventListener('scroll', () => {
if (dragAnchorStart === null || !dragPointer) return;
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
dragScrollRaf = requestAnimationFrame(() => {
const r = getRangeFromPoint(dragPointer.x, dragPointer.y, selectionTrack || null);
if (r && r.track && (!selectionTrack || r.track === selectionTrack)) {
setSelection(
dragAnchorStart,
r.end,
dragAppending ? { append: true, appendIndex: dragAppendIndex } : {}
);
}
});
});
seqViewer.addEventListener('mousemove', (e) => {
updateSelectionDrag(
{ x: e.clientX, y: e.clientY },
{ requireButton: true, buttons: e.buttons, event: e }
);
});
window.addEventListener('mouseup', () => {
endSelectionDrag();
});
seqViewer.addEventListener('click', (e) => {
if (seqViewer.dataset.justDragged === '1') return;
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const r = getRangeFromEventTarget(e.target);
if (!r || !r.track) {
clearSelection();
return;
}
const append = e.ctrlKey || e.metaKey;
if (!append) {
selectionTrack = r.track;
selectionFromSearch = false;
setSelection(r.start, r.end);
} else {
if (selectionTrack && selectionTrack !== r.track) return;
selectionTrack = selectionTrack || r.track;
setSelection(r.start, r.end, { append: true });
}
smartScrollAfterSelection(selectionTrack, r.start);
syncSearchToSelection();
});
if (proteinColorSelect) {
proteinColorSelect.addEventListener('change', (e) => {
proteinColorMode = e.target.value;
renderSequenceViewer();
});
}
if (ntColorSelect) {
ntColorSelect.addEventListener('change', (e) => {
ntScheme = e.target.value;
renderSequenceViewer();
});
}
if (btnProtein) {
btnProtein.addEventListener('click', () => {
proteinViewMode = (proteinViewMode + 1) % 3;
showProtein = (proteinViewMode !== 0);
btnProtein.dataset.mode = proteinViewMode === 2 ? 'three' : (proteinViewMode === 1 ? 'one' : 'off');
syncViewToggleButtons();
renderSequenceViewer();
});
}
if (btnDNA) {
btnDNA.addEventListener('click', () => {
showDNA = !showDNA;
if (showDNA) showRNA = false;
syncViewToggleButtons();
renderSequenceViewer();
});
}
if (btnRNA) {
btnRNA.addEventListener('click', () => {
showRNA = !showRNA;
if (showRNA) showDNA = false;
syncViewToggleButtons();
renderSequenceViewer();
});
}
if (btnRevComp) {
btnRevComp.addEventListener('click', () => {
showRevComp = !showRevComp;
syncViewToggleButtons();
renderSequenceViewer();
});
}
buildSwatches();
if (highlightTrashBtn) {
highlightTrashBtn.addEventListener('click', () => {
if (!selectionTrack) return;
const ranges = mergeRanges(getActiveRanges());
if (!ranges.length) return;
for (const r of ranges) {
if (selectionOverlapsHighlight(selectionTrack, r)) {
removeHighlightsInSelection(selectionTrack, r);
}
}
renderSequenceViewer();
});
}
if (seqModalApply && seqModalInput) {
seqModalApply.addEventListener('click', () => {
if (isLocked()) return notify('error', 'Editor is locked');
const text = seqModalInput.value;
if (!text.trim()) return notify('error', 'Nothing to apply');
pushUndoState();
redoStack = [];
seqRaw = sanitizeAndGroup(text).text;
const processed = processRaw(seqRaw);
residueScoreMap = new Array(processed.length).fill(null);
applyDefaultViewToggles(detectSeqType(processed.seq));
clearSelection();
updateSeqViews();
lastState = seqRaw;
notify('success', 'Sequence updated');
closeSeqModal();
});
}
if (seqModalClear && seqModalInput) {
seqModalClear.addEventListener('click', () => {
seqModalInput.value = '';
seqModalInput.focus();
});
}
if (deleteClose) deleteClose.addEventListener('click', closeDeleteConfirm);
if (deleteCancel) deleteCancel.addEventListener('click', closeDeleteConfirm);
if (deleteConfirm) deleteConfirm.addEventListener('click', deleteWholeFileNow);
if (deleteBackdrop) {
deleteBackdrop.addEventListener('click', (e) => {
if (e.target === deleteBackdrop) closeDeleteConfirm();
});
}
document.querySelectorAll('[data-new-type]').forEach((btn) => {
btn.addEventListener('click', () => {
const type = btn.getAttribute('data-new-type') || 'sequence';
newTypeBackdrop.hidden = true;
syncBodyScrollLock();
selectEditorTab(type);
});
});
document.getElementById('editor-new-type-close')?.addEventListener('click', () => {
newTypeBackdrop.hidden = true;
syncBodyScrollLock();
});
newTypeBackdrop?.addEventListener('click', (e) => {
if (e.target === newTypeBackdrop) {
newTypeBackdrop.hidden = true;
syncBodyScrollLock();
}
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (seqModalBackdrop && !seqModalBackdrop.hidden) closeSeqModal();
if (deleteBackdrop && !deleteBackdrop.hidden) closeDeleteConfirm();
if (duplicateBackdrop && !duplicateBackdrop.hidden) closeDuplicateConfirm();
closeCopyMenu();
});
const ZWSP = '\u200B';
let rangeLiveTimer = null;
let lastRangeLiveKey = '';
document.addEventListener('pointerdown', (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('#seq-viewer')) return;
const lenWrap = lenEl ? (lenEl.closest('.seq-meta-box--len') || lenEl.parentElement) : null;
const inLen = lenEl ? (t === lenEl || (lenWrap && lenWrap.contains(t))) : false;
const searchWrap = searchInput ? (searchInput.closest('.seq-search') || searchInput.parentElement) : null;
const inSearch = searchInput ? (t === searchInput || (searchWrap && searchWrap.contains(t))) : false;
const rangeWrap = posEl ? (posEl.closest('.seq-meta-box--range') || posEl.parentElement) : null;
const inRange = posEl ? (t === posEl || (rangeWrap && rangeWrap.contains(t))) : false;
const inColorModes = Boolean(
t.closest('#seq-protein-color-mode') ||
t.closest('#seq-nt-color-mode')
);
const inHighlightLabel = Boolean(
t.closest('#seq-highlight-name')
);
const clickedMeta = inSearch || inRange || inLen || inColorModes || inHighlightLabel;
const preserveSelection = Boolean(
t.closest('#seq-copy-btn') ||
t.closest('#seq-clear-btn') ||
t.closest('#seq-undo-btn') ||
t.closest('#seq-redo-btn') ||
t.closest('#seq-zoom-in-btn') ||
t.closest('#seq-zoom-out-btn') ||
t.closest('#seq-translate') ||
t.closest('#seq-mutate-btn') ||
t.closest('#seq-clear-highlight') ||
t.closest('#seq-highlight-swatches') ||
t.closest('#seq-highlight-name') ||
t.closest('#seq-protein-color-mode') ||
t.closest('#seq-nt-color-mode') ||
t.closest('#seq-copy-menu') ||
t.closest('#editor-delete-backdrop') ||
t.closest('#editor-delete-confirm') ||
t.closest('#editor-delete-cancel') ||
t.closest('#editor-delete-close') ||
t.closest('#editor-duplicate-backdrop') ||
t.closest('#editor-duplicate-confirm') ||
t.closest('#editor-duplicate-cancel') ||
t.closest('#editor-duplicate-close')
);
if (!preserveSelection && !clickedMeta && getActiveRanges().length) {
clearSelection();
}
if (!clickedMeta) {
const hasSearch =
Boolean((searchInput?.value || '').trim()) ||
searchAuthoredByUser ||
(Array.isArray(searchMatches) && searchMatches.length);
if (hasSearch) hardClearSearchUI();
}
}, true);
function scheduleLiveRangeApply() {
if (rangeLiveTimer) clearTimeout(rangeLiveTimer);
rangeLiveTimer = setTimeout(() => {
if (!isEditingRange) return;
const cleaned = (posEl.textContent || '').replace(/\u200B/g, '').trim();
const processedLen = processRaw(seqRaw).length;
if (processedLen <= 0) return;
const { track, maxInputLen, toSelectionRange } = getRangeInputContext();
const parsedList = parseUserRangesMulti(cleaned, maxInputLen, { allowPartial: true });
if (!parsedList) return;
const mapped = [];
for (const p of parsedList) {
const sel = toSelectionRange(p);
if (sel && Number.isFinite(sel.start) && Number.isFinite(sel.end)) mapped.push(sel);
}
if (!mapped.length) return;
const merged = mergeRanges(mapped);
const key = `${track}:${merged.map(r => `${r.start}-${r.end}`).join(',')}`;
if (key === lastRangeLiveKey) return;
lastRangeLiveKey = key;
selectionTrack = track;
selectionFromSearch = false;
selectionRanges = merged;
selectionRange = merged[merged.length - 1] || null;
lastAppliedSelectionTrack = selectionTrack || null;
updateSelectionStyles();
}, 120);
}
posEl.addEventListener('focus', () => {
isEditingRange = true;
lastRangeLiveKey = '';
let raw = '';
if (getActiveRanges().length && selectionTrack) {
raw = formatSelectionForInputMulti();
} else if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) {
const t = chooseBestSearchTrack();
const merged = getSearchRangesForTrack(t);
raw = merged.map(r => formatRangeForInput(t, r)).join(', ');
}
posEl.textContent = raw || ZWSP;
const r = document.createRange();
r.selectNodeContents(posEl);
if (!raw) r.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
scheduleLiveRangeApply();
});
posEl.addEventListener('input', () => {
if (!isEditingRange) return;
const caret = getCaretOffset(posEl);
let raw = (posEl.textContent || '').replace(/\n/g, '').replace(/\u200B/g, '');
if (!raw) raw = ZWSP;
if ((posEl.textContent || '') !== raw) {
posEl.textContent = raw;
setCaretOffset(posEl, Math.min(caret, raw.length));
}
scheduleLiveRangeApply();
});
posEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); posEl.blur(); }
if (e.key === 'Escape') {
e.preventDefault();
isEditingRange = false;
posEl.blur();
updateMeta();
}
});
posEl.addEventListener('blur', () => {
if (!isEditingRange) return;
isEditingRange = false;
const processedLen = processRaw(seqRaw).length;
if (processedLen <= 0) { updateMeta(); return; }
const cleaned = (posEl.innerText || '').replace(/\u200B/g, '').trim();
const { track, maxInputLen, toSelectionRange } = getRangeInputContext();
const parsedList = parseUserRangesMulti(cleaned, maxInputLen, { allowPartial: false });
if (!parsedList) { updateMeta(); return; }
const mapped = [];
for (const p of parsedList) {
const sel = toSelectionRange(p);
if (sel && Number.isFinite(sel.start) && Number.isFinite(sel.end)) mapped.push(sel);
}
if (!mapped.length) { updateMeta(); return; }
const merged = mergeRanges(mapped);
selectionTrack = track;
selectionFromSearch = false;
selectionRanges = merged;
selectionRange = merged[merged.length - 1] || null;
lastAppliedSelectionTrack = selectionTrack || null;
updateSelectionStyles();
smartScrollAfterSelection(selectionTrack, merged[0].start);
});
attachCanvasFileDrop();
async function persistCurrentSequenceDraft() {
const payload = {
name: nameInput?.value?.trim() || 'untitled-sequence',
sequence: seqRaw,
highlights,
proteinColorMode,
ntScheme
};
try {
const resp = await fetch('/api/editor/save-sequence-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
notify('success', 'Saved draft');
} catch (err) {
notify('error', 'Save draft failed (endpoint missing?)');
}
}
function regroupOnResize() { renderSequenceViewer(); }
function init() {
const initialRaw = seqViewer.innerText || '';
seqRaw = sanitizeAndGroup(initialRaw).text;
const processed = processRaw(seqRaw);
applyDefaultViewToggles(detectSeqType(processed.seq));
updateSeqViews();
lastState = seqRaw;
undoStack = [];
redoStack = [];
clearSelection();
syncViewToggleButtons();
groupToggleBtn?.setAttribute('data-mode', String(groupSize));
if (clearBtn) clearBtn.disabled = true;
if (isMemberSignedIn) refreshAssetsForCurrentContext().catch(() => {});
}
init();
window.addEventListener('vici:ctxchange', async (e) => {
if (isRevertingContext) return;
if (hasSequenceWorkingState()) {
const member = await getCurrentMemberData().catch(() => null);
if (member?.id) {
isRevertingContext = true;
window.ViciContext?.set?.(activeScopeId || `user:${member.id}`);
isRevertingContext = false;
}
showToast?.('error', 'Close and save your open file before switching context');
return;
}
await handleContextSwitch(String(e?.detail || ''));
});
window.addEventListener('resize', () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(regroupOnResize, 80);
});
});