mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-08-16 04:04:35 +00:00
- On mobile, notes always open in rendered read-only view even when the saved note-open preference is markdown; the raw markdown view is only shown after entering edit mode. - The edit (pencil) toggle honors the saved preference: entering edit switches to the markdown editor if that's the preference, and leaving edit returns to rendered read-only. - View/mode switches (pencil + header MD/eye buttons) are now transient and no longer persist noteOpenMode; the preference is changed only in Settings.
4741 lines
280 KiB
JavaScript
4741 lines
280 KiB
JavaScript
/* joplock client application — extracted from templates.js */
|
||
/* Server config is passed via window._joplockConfig set inline before this script loads */
|
||
(function(){
|
||
var _cfg=window._joplockConfig||{};
|
||
var _dbg=_cfg.debug||false;
|
||
var _assetVersion='20260519pwa22';
|
||
var _openRouterEnabled=!!_cfg.openRouterEnabled;
|
||
var _textExpanders=Array.isArray(_cfg.textExpanders)?_cfg.textExpanders.filter(function(e){return e&&e.trigger&&(e.action==='ai'||e.text!=null)}).map(function(e){return{id:String(e.id||''),trigger:String(e.trigger),action:e.action==='ai'?'ai':'text',profileId:String(e.profileId||''),text:String(e.text||'')}}):[];
|
||
function _log(){if(!_dbg)return;var a=Array.prototype.slice.call(arguments);a.unshift('[joplock]');console.log.apply(console,a)}
|
||
function _clientLog(event,data){try{var safe={};Object.keys(data||{}).forEach(function(k){if(/text|body|content|password|key|secret|token/i.test(k))return;var v=data[k];if(typeof v==='string')safe[k]=v.slice(0,120);else if(typeof v==='number'||typeof v==='boolean'||v===null)safe[k]=v});fetch('/api/web/client-log',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'event='+encodeURIComponent(event)+'&data='+encodeURIComponent(JSON.stringify(safe))}).catch(function(){})}catch(e){}}
|
||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js?v='+encodeURIComponent(_assetVersion),{updateViaCache:'none'}).catch(function(){});
|
||
// If the browser restores this page from bfcache, force a reload so the server can validate the session
|
||
window.addEventListener('pageshow',function(e){if(e.persisted){_log('bfcache restore detected, reloading');window.location.reload()}});
|
||
function syncThemeColor(){var meta=document.querySelector('meta[name="theme-color"]');if(!meta)return;var color=getComputedStyle(document.body).getPropertyValue('--theme-color').trim();if(color)meta.setAttribute('content',color)}
|
||
function setTheme(t){document.body.classList.forEach(function(c){if(c.startsWith('theme-'))document.body.classList.remove(c)});document.body.classList.add('theme-'+t);syncThemeColor();localStorage.setItem('joplock-theme',t);_syncTinyMCEThemeVars();fetch('/api/web/theme',{method:'PUT',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'theme='+encodeURIComponent(t)}).catch(function(){})}
|
||
|
||
// --- Encryption (vault-based client-side E2EE) ---
|
||
var PBKDF2_ITERATIONS=210000;
|
||
var ENCRYPTION_VERSION=2;
|
||
var ENCRYPTED_START='<!--joplock-encrypted-start-->';
|
||
var ENCRYPTED_END='<!--joplock-encrypted-end-->';
|
||
var ENCRYPTED_WRAPPER_HEAD='> **\uD83D\uDD12 This note is encrypted by Joplock**\n>\n> This note\'s content is encrypted and can only be viewed in Joplock.\n> Do not edit the data below \u2014 editing will permanently corrupt the encrypted content.\n\n';
|
||
|
||
var SVG_LOCK_CLOSED='<svg class="vault-svg-icon" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke-width="2.5"/></svg>';
|
||
var SVG_LOCK_OPEN='<svg class="vault-svg-icon" viewBox="0 0 24 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="15" width="18" height="11" rx="2"/><path d="M7 15V8a5 5 0 0 1 10 0"/></svg>';
|
||
|
||
function isEncryptedBody(body){return typeof body==='string'&&body.indexOf(ENCRYPTED_START)>=0}
|
||
|
||
function extractCiphertext(body){
|
||
var start=body.indexOf(ENCRYPTED_START);
|
||
var end=body.indexOf(ENCRYPTED_END);
|
||
if(start<0||end<0)return null;
|
||
var json=body.slice(start+ENCRYPTED_START.length,end).trim();
|
||
try{var obj=JSON.parse(json);return obj.joplock_encrypted?json:null}catch(e){return null}
|
||
}
|
||
|
||
function wrapCiphertext(jsonString){
|
||
return ENCRYPTED_WRAPPER_HEAD+ENCRYPTED_START+'\n'+jsonString+'\n'+ENCRYPTED_END+'\n';
|
||
}
|
||
|
||
function _b64Encode(buf){var bytes=new Uint8Array(buf);var bin='';var chunk=0x8000;for(var i=0;i<bytes.length;i+=chunk){bin+=String.fromCharCode.apply(null,bytes.subarray(i,i+chunk))}return btoa(bin)}
|
||
function _b64Decode(str){var bin=atob(str);var buf=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)buf[i]=bin.charCodeAt(i);return buf}
|
||
|
||
async function deriveKey(password,salt){
|
||
var enc=new TextEncoder();
|
||
var keyMaterial=await crypto.subtle.importKey('raw',enc.encode(password),{name:'PBKDF2'},false,['deriveKey']);
|
||
return crypto.subtle.deriveKey({name:'PBKDF2',salt:salt,iterations:PBKDF2_ITERATIONS,hash:'SHA-256'},keyMaterial,{name:'AES-GCM',length:256},true,['encrypt','decrypt']);
|
||
}
|
||
|
||
async function exportKey(cryptoKey){var jwk=await crypto.subtle.exportKey('jwk',cryptoKey);return btoa(JSON.stringify(jwk))}
|
||
async function importKey(jwkBase64){var jwk=JSON.parse(atob(jwkBase64));return crypto.subtle.importKey('jwk',jwk,{name:'AES-GCM',length:256},true,['encrypt','decrypt'])}
|
||
|
||
// Encrypt plaintext for a vault. Returns wrapped ciphertext string.
|
||
// vaultId: folder jop_id (stored in ciphertext for reference)
|
||
// key: CryptoKey (pre-derived vault key)
|
||
// salt: Uint8Array (vault salt, stored redundantly in ciphertext for resilience)
|
||
async function encryptForVault(plaintext,vaultId,key,salt){
|
||
var iv=crypto.getRandomValues(new Uint8Array(12));
|
||
var enc=new TextEncoder();
|
||
var ct=await crypto.subtle.encrypt({name:'AES-GCM',iv:iv},key,enc.encode(plaintext));
|
||
var obj={joplock_encrypted:1,v:ENCRYPTION_VERSION,vault:vaultId,salt:_b64Encode(salt),iv:_b64Encode(iv),ct:_b64Encode(ct)};
|
||
return wrapCiphertext(JSON.stringify(obj));
|
||
}
|
||
|
||
// Decrypt with a pre-derived CryptoKey (vault key or any AES-GCM key)
|
||
async function _decryptWithKey(wrappedBody,key){
|
||
var json=extractCiphertext(wrappedBody);
|
||
if(!json)throw new Error('No encrypted data');
|
||
var obj=JSON.parse(json);
|
||
var iv=_b64Decode(obj.iv);
|
||
var ct=_b64Decode(obj.ct);
|
||
var dec=await crypto.subtle.decrypt({name:'AES-GCM',iv:iv},key,ct);
|
||
return new TextDecoder().decode(dec);
|
||
}
|
||
|
||
// v1 compat: decrypt with password (derives key from embedded salt)
|
||
async function decryptBody(password,wrappedBody){
|
||
var json=extractCiphertext(wrappedBody);
|
||
if(!json)throw new Error('No encrypted data found');
|
||
var obj=JSON.parse(json);
|
||
if(!obj.joplock_encrypted)throw new Error('Not an encrypted blob');
|
||
var salt=_b64Decode(obj.salt);
|
||
var iv=_b64Decode(obj.iv);
|
||
var ct=_b64Decode(obj.ct);
|
||
var key=await deriveKey(password,salt);
|
||
var dec=await crypto.subtle.decrypt({name:'AES-GCM',iv:iv},key,ct);
|
||
return new TextDecoder().decode(dec);
|
||
}
|
||
|
||
// Get the vault folderId embedded in an encrypted body (v2 only), or null
|
||
function getBodyVaultId(body){
|
||
var json=extractCiphertext(body);
|
||
if(!json)return null;
|
||
try{var obj=JSON.parse(json);return obj.vault||null}catch(e){return null}
|
||
}
|
||
|
||
// --- Per-vault key management (sessionStorage keyed by folderId) ---
|
||
var _VAULT_KEY_PFX='joplock-vault-key-';
|
||
var _VAULT_CHECK_PLAINTEXT='joplock_vault_check';
|
||
|
||
function _vaultKeyStorageKey(folderId){return _VAULT_KEY_PFX+folderId}
|
||
|
||
// Cache a derived vault key in sessionStorage
|
||
async function cacheVaultKey(folderId,key,salt){
|
||
var jwk=await exportKey(key);
|
||
sessionStorage.setItem(_vaultKeyStorageKey(folderId),JSON.stringify({jwk:jwk,salt:_b64Encode(salt)}));
|
||
}
|
||
|
||
// Get a cached vault key from sessionStorage. Returns CryptoKey or null.
|
||
async function getVaultKey(folderId){
|
||
try{
|
||
var stored=sessionStorage.getItem(_vaultKeyStorageKey(folderId));
|
||
if(!stored)return null;
|
||
var obj=JSON.parse(stored);
|
||
return await importKey(obj.jwk);
|
||
}catch(e){return null}
|
||
}
|
||
|
||
// Get cached salt for a vault
|
||
function getVaultSalt(folderId){
|
||
try{
|
||
var stored=sessionStorage.getItem(_vaultKeyStorageKey(folderId));
|
||
if(!stored)return null;
|
||
var obj=JSON.parse(stored);
|
||
return _b64Decode(obj.salt);
|
||
}catch(e){return null}
|
||
}
|
||
|
||
// Check if a vault is currently unlocked (key in sessionStorage)
|
||
function isVaultUnlocked(folderId){
|
||
try{
|
||
var unlocked=!!sessionStorage.getItem(_vaultKeyStorageKey(folderId));
|
||
return unlocked;
|
||
}catch(e){
|
||
_log('isVaultUnlocked error',folderId,e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Clear a vault's key from sessionStorage (lock the vault)
|
||
function clearVaultKey(folderId){
|
||
try{
|
||
_log('clearVaultKey',folderId);
|
||
sessionStorage.removeItem(_vaultKeyStorageKey(folderId));
|
||
}catch(e){_log('clearVaultKey error',folderId,e)}
|
||
}
|
||
|
||
// Clear ALL vault keys from sessionStorage
|
||
function clearAllVaultKeys(){
|
||
try{
|
||
var toRemove=[];
|
||
for(var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);if(k&&k.startsWith(_VAULT_KEY_PFX))toRemove.push(k)}
|
||
toRemove.forEach(function(k){sessionStorage.removeItem(k)});
|
||
}catch(e){}
|
||
}
|
||
|
||
// Derive vault key from password + salt, verify against check blob, cache if correct.
|
||
// Returns true if successful.
|
||
async function unlockVault(folderId,password){
|
||
try{
|
||
_log('unlockVault start',folderId,{passwordLength:(password||'').length});
|
||
// Fetch vault data (salt + verify) from server
|
||
var resp=await fetch('/api/web/vaults/'+encodeURIComponent(folderId),{method:'GET'});
|
||
if(!resp.ok){_log('unlockVault fetch failed',folderId,{status:resp.status});return false}
|
||
var data=await resp.json();
|
||
var vault=data.item;
|
||
if(!vault){_log('unlockVault missing vault payload',folderId);return false}
|
||
var salt=_b64Decode(vault.salt);
|
||
var key=await deriveKey(password,salt);
|
||
// Verify: decrypt the check blob
|
||
var verifyObj=JSON.parse(atob(vault.verify));
|
||
var iv=_b64Decode(verifyObj.iv);
|
||
var ct=_b64Decode(verifyObj.ct);
|
||
var dec=await crypto.subtle.decrypt({name:'AES-GCM',iv:iv},key,ct);
|
||
var plain=new TextDecoder().decode(dec);
|
||
if(plain!==_VAULT_CHECK_PLAINTEXT){_log('unlockVault verify mismatch',folderId);return false}
|
||
// Success — cache key
|
||
await cacheVaultKey(folderId,key,salt);
|
||
_log('unlockVault success',folderId);
|
||
return true;
|
||
}catch(e){
|
||
_log('unlockVault error',e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Build a verify blob from a password and salt for vault creation
|
||
async function buildVaultVerify(key){
|
||
var iv=crypto.getRandomValues(new Uint8Array(12));
|
||
var enc=new TextEncoder();
|
||
var ct=await crypto.subtle.encrypt({name:'AES-GCM',iv:iv},key,enc.encode(_VAULT_CHECK_PLAINTEXT));
|
||
return btoa(JSON.stringify({iv:_b64Encode(iv),ct:_b64Encode(ct)}));
|
||
}
|
||
|
||
// Create a vault: derive key, build verify, POST to server, cache key
|
||
async function createVault(folderId,password){
|
||
var salt=crypto.getRandomValues(new Uint8Array(16));
|
||
var key=await deriveKey(password,salt);
|
||
var verify=await buildVaultVerify(key);
|
||
var saltB64=_b64Encode(salt);
|
||
var resp=await fetch('/api/web/vaults',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({folderId:folderId,salt:saltB64,verify:verify})
|
||
});
|
||
if(!resp.ok){var err=await resp.json().catch(function(){return{}});throw new Error(err.error||'Failed to create vault')}
|
||
await cacheVaultKey(folderId,key,salt);
|
||
return true;
|
||
}
|
||
|
||
// Auto-lock timer (per-vault)
|
||
var _autoLockMinutes=Number(_cfg.encryptionAutoLockMinutes)||5;
|
||
var _autoLockActivity={};// folderId -> timestamp
|
||
var _autoLockTimer=null;
|
||
|
||
function touchVaultActivity(folderId){if(folderId)_autoLockActivity[folderId]=Date.now()}
|
||
function startAutoLockTimer(){
|
||
if(_autoLockTimer||_autoLockMinutes<=0)return;
|
||
_autoLockTimer=setInterval(function(){
|
||
if(_autoLockMinutes<=0)return;
|
||
var now=Date.now();
|
||
var timeoutMs=_autoLockMinutes*60*1000;
|
||
try{
|
||
var toRemove=[];
|
||
for(var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);if(k&&k.startsWith(_VAULT_KEY_PFX))toRemove.push(k)}
|
||
toRemove.forEach(function(sk){
|
||
var folderId=sk.slice(_VAULT_KEY_PFX.length);
|
||
var last=_autoLockActivity[folderId]||0;
|
||
if(now-last>timeoutMs){
|
||
clearVaultKey(folderId);
|
||
delete _autoLockActivity[folderId];
|
||
_log('auto-lock: locked vault',folderId);
|
||
// If currently open note belongs to this vault, close it
|
||
var form=activeEditorForm();
|
||
if(form){
|
||
var noteBodyVault=form.dataset.vaultId||getBodyVaultId((getTA()||{}).value||'');
|
||
if(noteBodyVault===folderId){
|
||
var panel=form.closest('#editor-panel')||document.getElementById('editor-panel');
|
||
if(panel)panel.innerHTML='<div class="editor-empty">Select a note</div>';
|
||
hideTinyMCEHost();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}catch(e){}
|
||
},30000);
|
||
}
|
||
|
||
// Do not clear vault keys on ordinary same-tab navigation between notes/pages.
|
||
// They are session-scoped and are explicitly cleared on logout/login cleanup.
|
||
;
|
||
|
||
var _defaultNoteOpenMode=_cfg.noteOpenMode||'preview';
|
||
var _highlightActiveLine=_cfg.highlightActiveLine!==false;
|
||
var _mobileStartup=_cfg.mobileStartup||null;
|
||
var _uiMode=_cfg.uiMode||'auto';
|
||
var _mobileShellMaxWidth=768;
|
||
function viewportWidth(){return Math.max(window.innerWidth||0,document.documentElement&&document.documentElement.clientWidth||0)}
|
||
function viewportHeight(){return Math.max(window.innerHeight||0,document.documentElement&&document.documentElement.clientHeight||0)}
|
||
function visualViewportBounds(){var vv=window.visualViewport;return vv?{left:vv.offsetLeft||0,top:vv.offsetTop||0,width:vv.width||viewportWidth(),height:vv.height||viewportHeight()}:{left:0,top:0,width:viewportWidth(),height:viewportHeight()}}
|
||
var _lastViewportWidth=viewportWidth();
|
||
var _resizeTimer=null;
|
||
var _traceKey='joplock-debug-trace';
|
||
function isMobileShellMode(){if(_uiMode==='mobile')return true;if(_uiMode==='desktop')return false;return viewportWidth()<=_mobileShellMaxWidth}
|
||
function isDesktopMode(){return !isMobileShellMode()}
|
||
function _trace(){if(!_dbg)return;try{var line='['+new Date().toISOString().slice(11,23)+'] '+Array.prototype.slice.call(arguments).map(function(v){return typeof v==='string'?v:JSON.stringify(v)}).join(' ');var arr=JSON.parse(sessionStorage.getItem(_traceKey)||'[]');arr.push(line);if(arr.length>80)arr=arr.slice(arr.length-80);sessionStorage.setItem(_traceKey,JSON.stringify(arr));console.log('[trace]',line)}catch(_e){}}
|
||
function _traceDump(){if(!_dbg)return;try{var arr=JSON.parse(sessionStorage.getItem(_traceKey)||'[]');for(var i=0;i<arr.length;i++)console.log(arr[i])}catch(_e){}}
|
||
window.joplockTraceDump=_traceDump;
|
||
if(_dbg)_trace('boot',{w:viewportWidth(),mobile:isMobileShellMode(),startup:!!_mobileStartup});
|
||
function handleViewportResize(){
|
||
// Immediately disable transitions during resize
|
||
document.body.classList.add('resizing');
|
||
if(_resizeTimer)clearTimeout(_resizeTimer);
|
||
_resizeTimer=setTimeout(function(){
|
||
document.body.classList.remove('resizing');
|
||
// After resize settles, sync shell mode (defined inside mobile IIFE, exposed via window)
|
||
if(window._syncResponsiveMode)window._syncResponsiveMode();
|
||
},200);
|
||
}
|
||
(function(){var serverTheme=_cfg.theme||'matrix';var s=localStorage.getItem('joplock-theme');var e=document.querySelector('.theme-picker');if(s&&s!==serverTheme){localStorage.setItem('joplock-theme',serverTheme)}if(e)e.value=serverTheme})();
|
||
window.addEventListener('pageshow',function(e){if(e.persisted)window.location.replace('/login')});
|
||
function setMobileNav(open){var nav=document.getElementById('nav-panel');var bd=document.getElementById('mobile-nav-backdrop');if(!nav||!bd)return;nav.classList.toggle('open',open);bd.classList.toggle('open',open);document.body.classList.toggle('mobile-nav-open',open)}
|
||
function toggleNav(){if(isMobileShellMode()){var nav=document.getElementById('nav-panel');if(!nav)return;setMobileNav(!nav.classList.contains('open'))}else{document.body.classList.toggle('nav-collapsed');localStorage.setItem('joplock-nav-collapsed',document.body.classList.contains('nav-collapsed')?'1':'')}}
|
||
function closeNav(){setMobileNav(false)}
|
||
(function(){if(localStorage.getItem('joplock-nav-collapsed')==='1')document.body.classList.add('nav-collapsed')})();
|
||
function activeEditorForm(){if(isMobileShellMode()){var mobileBody=document.getElementById('mobile-editor-body');var mobileForm=mobileBody&&mobileBody.querySelector?mobileBody.querySelector('#note-editor-form'):null;return mobileForm||null}return document.getElementById('note-editor-form')}
|
||
function queryActiveEditor(selector){var form=activeEditorForm();return form&&form.querySelector?form.querySelector(selector):null}
|
||
function activeEditorMeta(){if(isMobileShellMode()){var mobileBody=document.getElementById('mobile-editor-body');var mobileMeta=mobileBody&&mobileBody.querySelector?mobileBody.querySelector('#note-meta'):null;if(mobileMeta)return mobileMeta}return document.getElementById('status-note-meta')}
|
||
function setSaveState(html,text){var s=queryActiveEditor('#autosave-status');if(s)s.innerHTML=html||'';var mobile=document.getElementById('mobile-editor-status');if(mobile)mobile.innerHTML=text?html:''}
|
||
function markEdited(){setSaveState('<span class="autosave-edited">Edited</span>','Edited');_log('markEdited')}
|
||
function renderNoteMeta(){var src=document.getElementById('note-meta');var mobileBody=document.getElementById('mobile-editor-body');if(isMobileShellMode()&&mobileBody){src=mobileBody.querySelector('#note-meta')||src}var target;if(isMobileShellMode()){target=src}else{target=document.getElementById('status-note-meta');if(src&&target){target.setAttribute('data-created-time',src.getAttribute('data-created-time')||'0');target.setAttribute('data-updated-time',src.getAttribute('data-updated-time')||'0')}}if(!target)return;var c=Number(target.getAttribute('data-created-time')||0),u=Number(target.getAttribute('data-updated-time')||0);if(!c&&!u){target.textContent='';return}var months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];var fmt=function(ts){if(!ts)return '';var d=new Date(ts);return String(d.getDate()).padStart(2,'0')+'-'+months[d.getMonth()]+'-'+String(d.getFullYear()).slice(-2)};target.textContent='Created '+fmt(c)+' | Edited '+fmt(u)}
|
||
var _folderMenuState={id:'',title:''};
|
||
function closeFolderContextMenu(){var menu=document.getElementById('folder-context-menu');if(menu)menu.hidden=true}
|
||
function openFolderContextMenu(event,id,title){if(event){event.preventDefault();event.stopPropagation()}var menu=document.getElementById('folder-context-menu');if(!menu)return false;_folderMenuState={id:id,title:title};menu.hidden=false;menu.style.left=(event.clientX||16)+'px';menu.style.top=(event.clientY||16)+'px';return false}
|
||
function closeFolderModal(){var modal=document.getElementById('folder-modal');var backdrop=document.getElementById('folder-modal-backdrop');if(modal)modal.hidden=true;if(backdrop)backdrop.hidden=true}
|
||
function openFolderModal(){var input=document.getElementById('folder-edit-title');var modal=document.getElementById('folder-modal');var backdrop=document.getElementById('folder-modal-backdrop');if(modal&&modal.parentNode!==document.body)document.body.appendChild(modal);if(backdrop&&backdrop.parentNode!==document.body)document.body.appendChild(backdrop);if(input)input.value=_folderMenuState.title||'';if(modal)modal.hidden=false;if(backdrop)backdrop.hidden=false;closeFolderContextMenu();if(input)input.focus()}
|
||
function openEmptyTrashModal(){var modal=document.getElementById('empty-trash-modal');var backdrop=document.getElementById('empty-trash-modal-backdrop');if(modal&&modal.parentNode!==document.body)document.body.appendChild(modal);if(backdrop&&backdrop.parentNode!==document.body)document.body.appendChild(backdrop);if(modal)modal.hidden=false;if(backdrop)backdrop.hidden=false}
|
||
function closeEmptyTrashModal(){var modal=document.getElementById('empty-trash-modal');var backdrop=document.getElementById('empty-trash-modal-backdrop');if(modal)modal.hidden=true;if(backdrop)backdrop.hidden=true}
|
||
function submitEmptyTrash(event){if(event)event.preventDefault();closeEmptyTrashModal();htmx.ajax('POST','/fragments/trash/empty',{target:'#nav-panel',swap:'innerHTML'});return false}
|
||
function editFolderFromMenu(){if(!_folderMenuState.id)return;openFolderModal()}
|
||
function deleteFolderFromMenu(){if(!_folderMenuState.id)return;closeFolderContextMenu();if(confirm('Delete notebook "'+(_folderMenuState.title||'Untitled')+'"?')){htmx.ajax('DELETE','/fragments/folders/'+encodeURIComponent(_folderMenuState.id),{target:'#nav-panel',swap:'innerHTML'})}}
|
||
function submitFolderEdit(event){if(event)event.preventDefault();var input=document.getElementById('folder-edit-title');var title=input?input.value.trim():'';if(!_folderMenuState.id||!title)return false;var folderId=_folderMenuState.id;closeFolderModal();if(window.isMobileShellMode&&window.isMobileShellMode()){fetch('/fragments/folders/'+encodeURIComponent(folderId),{method:'PUT',headers:{'Content-Type':'application/x-www-form-urlencoded','hx-request':'true'},body:'title='+encodeURIComponent(title)}).then(function(){htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});var notesTitle=document.getElementById('mobile-notes-title');if(notesTitle&¬esTitle.textContent===_folderMenuState.title)notesTitle.textContent=title})}else{htmx.ajax('PUT','/fragments/folders/'+encodeURIComponent(folderId),{target:'#nav-panel',swap:'innerHTML',values:{title:title}})}return false}
|
||
function navFolderState(){try{return JSON.parse(localStorage.getItem('joplock-nav-folders')||'{}')}catch(e){return {}}}
|
||
function saveNavFolderState(s){localStorage.setItem('joplock-nav-folders',JSON.stringify(s))}
|
||
function toggleNavFolder(id,force){
|
||
var el=document.querySelector('.nav-folder[data-folder-id="'+id.replace(/"/g,'\\"')+'"]');
|
||
if(!el)return;
|
||
var collapsed=force===undefined?!el.classList.contains('collapsed'):!force;
|
||
var s=navFolderState();
|
||
if(!collapsed){
|
||
document.querySelectorAll('.nav-folder[data-folder-id]').forEach(function(other){
|
||
var otherId=other.getAttribute('data-folder-id');
|
||
if(!otherId||other===el)return;
|
||
other.classList.add('collapsed');
|
||
s[otherId]='0';
|
||
});
|
||
}
|
||
el.classList.toggle('collapsed',collapsed);
|
||
s[id]=collapsed?'0':'1';saveNavFolderState(s);
|
||
// Lazy-load notes on first expand
|
||
if(!collapsed){
|
||
var notesDiv=el.querySelector('.nav-folder-notes[data-folder-id]');
|
||
if(notesDiv&&!notesDiv.getAttribute('data-loaded')){
|
||
notesDiv.setAttribute('data-loaded','1');
|
||
var folderId=notesDiv.getAttribute('data-folder-id');
|
||
htmx.ajax('GET','/fragments/folder-notes?folderId='+encodeURIComponent(folderId),{target:notesDiv,swap:'innerHTML'});
|
||
}
|
||
}
|
||
}
|
||
function openNavFolderAndFirstNote(id){
|
||
if(isMobileShellMode())return;
|
||
var el=document.querySelector('.nav-folder[data-folder-id="'+id.replace(/"/g,'\\"')+'"]');
|
||
if(!el)return;
|
||
var alreadyOpen=!el.classList.contains('collapsed');
|
||
toggleNavFolder(id,true);
|
||
// After notes are loaded, click the first one
|
||
function clickFirst(notesDiv){
|
||
var first=notesDiv.querySelector('.notelist-item');
|
||
if(first)first.click();
|
||
}
|
||
var notesDiv=el.querySelector('.nav-folder-notes[data-folder-id]');
|
||
if(!notesDiv)return;
|
||
if(alreadyOpen&¬esDiv.getAttribute('data-loaded')){
|
||
// Notes already present — click immediately
|
||
clickFirst(notesDiv);
|
||
} else {
|
||
// Wait for htmx swap into this notesDiv to settle, then click first note
|
||
function onSettle(e){
|
||
if(e.detail&&e.detail.target===notesDiv){
|
||
document.body.removeEventListener('htmx:afterSettle',onSettle);
|
||
clickFirst(notesDiv);
|
||
}
|
||
}
|
||
document.body.addEventListener('htmx:afterSettle',onSettle);
|
||
}
|
||
}
|
||
function getTA(){return queryActiveEditor('#note-body')}
|
||
function getPV(){var pv=queryActiveEditor('#note-preview');return pv&&pv.style.display!=='none'?pv:null}
|
||
function isMarkdownVisible(){var host=queryActiveEditor('#cm-host');return !!(host&&host.style.display!=='none')}
|
||
function inMobileEditor(){var form=activeEditorForm();return !!(form&&form.closest&&form.closest('#mobile-editor-body'))}
|
||
var _notesCache = null;
|
||
var _notesCachePromise = null;
|
||
function invalidateNotesCache(){
|
||
_notesCache = null;
|
||
}
|
||
function fetchNoteHeaders(){
|
||
if(_notesCache)return Promise.resolve(_notesCache);
|
||
if(_notesCachePromise)return _notesCachePromise;
|
||
_notesCachePromise = fetch('/api/web/notes/headers')
|
||
.then(function(r){return r.json()})
|
||
.then(function(data){
|
||
_notesCache = data.items || [];
|
||
_notesCachePromise = null;
|
||
return _notesCache;
|
||
})
|
||
.catch(function(err){
|
||
_notesCachePromise = null;
|
||
_log('Failed to fetch note headers',err);
|
||
return [];
|
||
});
|
||
return _notesCachePromise;
|
||
}
|
||
var _tinymceEditor=null;
|
||
var _cmView=null;
|
||
function getCM(){return _cmView}
|
||
function cmSyncToTA(){var ta=getTA();if(ta&&_cmView){var md=_cmView.state.doc.toString();if(ta.value!==md){ta.value=md;return true}}return false}
|
||
function cmSetVal(v){if(!_cmView)return;var cur=_cmView.state.doc.toString();if(cur===(v||''))return;_cmView.dispatch({changes:{from:0,to:cur.length,insert:v||''}})}
|
||
function getTinyMCE(){return _tinymceEditor}
|
||
function tinyMCEContent(){return _tinymceEditor?_tinymceEditor.getContent():''}
|
||
function tinyMCESetContent(html){if(_tinymceEditor)_tinymceEditor.setContent(html)}
|
||
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
|
||
function _isMarkdownModeActive(){return _editorMode==='markdown'||_editorMode==='md'}
|
||
function _runMarkdownToolbarFormat(cmd){
|
||
if(cmd==='bold'){wrapSel('**','**');return true}
|
||
if(cmd==='italic'){wrapSel('*','*');return true}
|
||
if(cmd==='underline'){wrapSel('++','++');return true}
|
||
if(cmd==='strikethrough'){wrapSel('~~','~~');return true}
|
||
if(cmd==='code'){wrapSel('`','`');return true}
|
||
if(cmd==='InsertUnorderedList'){insertPfx('- ');return true}
|
||
if(cmd==='InsertOrderedList'){insertPfx('1. ');return true}
|
||
if(cmd==='InsertHorizontalRule'){insertTxt('\n---\n');return true}
|
||
if(cmd==='removeformat'||cmd==='RemoveFormat'){clearFormat();return true}
|
||
if(cmd==='codesample'||cmd==='mceCodeSample'){openCodeModal();return true}
|
||
if(cmd==='mceLink'||cmd==='link'){insertLink();return true}
|
||
if(cmd==='mceImage'||cmd==='image'){insertImg();return true}
|
||
return false;
|
||
}
|
||
function _runMarkdownToolbarBlock(tag){
|
||
if(tag==='h1'){insertPfx('# ');return true}
|
||
if(tag==='h2'){insertPfx('## ');return true}
|
||
if(tag==='h3'){insertPfx('### ');return true}
|
||
if(tag==='blockquote'){insertPfx('> ');return true}
|
||
return false;
|
||
}
|
||
function tinyMCEFormat(cmd){
|
||
if(_isMarkdownModeActive()&&_runMarkdownToolbarFormat(cmd))return;
|
||
var ed=getTinyMCE();
|
||
if(cmd==='codesample'||cmd==='mceCodeSample'){openCodeModal();return}
|
||
if(ed){if(cmd==='InsertUnorderedList'||cmd==='InsertOrderedList'||cmd==='InsertHorizontalRule'||cmd==='removeformat'||cmd==='RemoveFormat')ed.execCommand(cmd==='removeformat'?'RemoveFormat':cmd);else if(cmd==='mceLink'||cmd==='link')ed.execCommand('mceLink');else if(cmd==='mceImage'||cmd==='image')ed.execCommand('mceImage');else ed.formatter.toggle(cmd);ed.focus()}
|
||
}
|
||
function tinyMCEFormatBlock(tag){
|
||
if(_isMarkdownModeActive()&&_runMarkdownToolbarBlock(tag))return;
|
||
var ed=getTinyMCE();
|
||
if(ed){ed.formatter.toggle(tag);ed.focus()}
|
||
}
|
||
function tinyMCEInsertCheckbox(){if(_isMarkdownModeActive()){insertPfx('- [ ] ');return}var ed=getTinyMCE();if(ed){ed.execCommand('mceInsertContent',false,'<div class="md-checkbox"> </div>');ed.focus()}}
|
||
function tinyMCEInsertCodeBlock(editPre){
|
||
// Route rendered-mode code-block insert/edit through the custom themed CM6
|
||
// modal (openCodeModal), not TinyMCE's built-in unthemed codesample dialog.
|
||
openCodeModal(editPre||null);
|
||
return true;
|
||
}
|
||
function tinyMCEInsertDate(){if(_isMarkdownModeActive()){insertStamp('date');return}var ed=getTinyMCE();if(ed){var d=new Date();var s=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');ed.execCommand('mceInsertContent',false,s);ed.focus()}}
|
||
function tinyMCEInsertDateTime(){if(_isMarkdownModeActive()){insertStamp('datetime');return}var ed=getTinyMCE();if(ed){var d=new Date();var s=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');ed.execCommand('mceInsertContent',false,s);ed.focus()}}
|
||
function tinyMCEInsertLink(){if(_isMarkdownModeActive()){insertLink();return}var ed=getTinyMCE();if(ed){ed.execCommand('mceLink');ed.focus()}}
|
||
function tinyMCEInsertImage(){if(_isMarkdownModeActive()){insertImg();return}var ed=getTinyMCE();if(ed){ed.execCommand('mceImage');ed.focus()}}
|
||
function _copyTextToClipboard(text,onDone){
|
||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(text).then(function(){if(onDone)onDone(true)}).catch(function(){if(onDone)onDone(false)});
|
||
return;
|
||
}
|
||
try{
|
||
var ta=document.createElement('textarea');
|
||
ta.value=text;
|
||
ta.style.position='fixed';
|
||
ta.style.opacity='0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if(onDone)onDone(true);
|
||
}catch(_e){if(onDone)onDone(false)}
|
||
}
|
||
function ensureTinyMCEEditableAfterPre(editor){
|
||
if(!editor||!editor.getBody||!editor.getDoc)return;
|
||
var body=editor.getBody();
|
||
var doc=editor.getDoc();
|
||
if(!body||!doc)return;
|
||
body.querySelectorAll('pre').forEach(function(pre){
|
||
var next=pre.nextElementSibling;
|
||
if(next)return;
|
||
var p=doc.createElement('p');
|
||
p.innerHTML='<br data-mce-bogus="1">';
|
||
body.appendChild(p);
|
||
});
|
||
}
|
||
function highlightTinyMCECodeBlocks(editor){
|
||
if(!editor||!editor.getBody||!window.hljs)return;
|
||
var body=editor.getBody();
|
||
if(!body)return;
|
||
body.querySelectorAll('pre[class*="language-"]').forEach(function(pre){
|
||
var target=pre.querySelector('code');
|
||
if(!target)return;
|
||
if(target&&target.querySelector&&target.querySelector('[class*="hljs-"]'))return;
|
||
if(target&&target.dataset&&target.dataset.highlighted)delete target.dataset.highlighted;
|
||
try{window.hljs.highlightElement(target)}catch(_e){}
|
||
});
|
||
}
|
||
function normalizeTinyMCECodeSampleClasses(editor){
|
||
if(!editor||!editor.getBody)return;
|
||
var body=editor.getBody();
|
||
if(!body)return;
|
||
body.querySelectorAll('pre').forEach(function(pre){
|
||
if(pre.className&&pre.className.indexOf('language-')!==-1)return;
|
||
var code=pre.querySelector('code[class*="language-"]');
|
||
if(!code)return;
|
||
var m=(code.className||'').match(/language-[\w-]+/);
|
||
if(m)pre.classList.add(m[0]);
|
||
});
|
||
}
|
||
function normalizeTinyMCECodeSampleMarkup(editor){
|
||
if(!editor||!editor.getBody||!editor.getDoc)return;
|
||
var body=editor.getBody();
|
||
var doc=editor.getDoc();
|
||
if(!body||!doc)return;
|
||
body.querySelectorAll('pre').forEach(function(pre){
|
||
var code=pre.querySelector('code');
|
||
if(code)return;
|
||
// Skip pre already processed by codesample plugin (Prism spans, no <code> wrapper).
|
||
if(pre.hasAttribute('data-mce-highlighted'))return;
|
||
var btn=pre.querySelector('.pre-copy-btn');
|
||
if(btn&&btn.parentNode===pre)pre.removeChild(btn);
|
||
var text='';
|
||
Array.prototype.forEach.call(pre.childNodes,function(node){
|
||
if(node.nodeType===3)text+=node.nodeValue||'';
|
||
else if(node.nodeType===1)text+=node.textContent||'';
|
||
});
|
||
while(pre.firstChild)pre.removeChild(pre.firstChild);
|
||
code=doc.createElement('code');
|
||
code.textContent=text;
|
||
pre.appendChild(code);
|
||
if(btn)pre.insertBefore(btn,pre.firstChild);
|
||
});
|
||
}
|
||
function initTinyMCECodeCopyButtons(editor){
|
||
if(!editor||!editor.getBody)return;
|
||
var body=editor.getBody();
|
||
if(!body)return;
|
||
normalizeTinyMCECodeSampleClasses(editor);
|
||
ensureTinyMCEEditableAfterPre(editor);
|
||
body.querySelectorAll('pre').forEach(function(pre){
|
||
if(!pre.getAttribute('data-jop-code-edit-bound')){
|
||
pre.setAttribute('data-jop-code-edit-bound','1');
|
||
var _openCodeSample=function(e){
|
||
if(e.target&&e.target.closest&&e.target.closest('.pre-copy-btn')){return;}
|
||
// Read-only mode blocks opening the code editor modal.
|
||
if(_tinymceReadonly){return;}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
tinyMCEInsertCodeBlock(pre);
|
||
};
|
||
pre.addEventListener('click',_openCodeSample);
|
||
pre.addEventListener('touchend',_openCodeSample,{passive:false});
|
||
}
|
||
if(pre.querySelector('.pre-copy-btn'))return;
|
||
var btn=editor.getDoc().createElement('button');
|
||
btn.type='button';
|
||
btn.className='pre-copy-btn';
|
||
btn.title='Copy code';
|
||
btn.setAttribute('aria-label','Copy code');
|
||
btn.textContent='';
|
||
btn.setAttribute('contenteditable','false');
|
||
btn.setAttribute('data-mce-bogus','all');
|
||
btn.addEventListener('click',function(e){
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var code=pre.querySelector('code');
|
||
var text=code?code.textContent:(pre.textContent||'');
|
||
_copyTextToClipboard(text,function(ok){
|
||
btn.classList.toggle('is-copied',!!ok);
|
||
btn.classList.toggle('is-failed',!ok);
|
||
setTimeout(function(){btn.classList.remove('is-copied');btn.classList.remove('is-failed')},1200);
|
||
});
|
||
});
|
||
pre.insertBefore(btn,pre.firstChild);
|
||
});
|
||
}
|
||
// Persistent TinyMCE lifecycle: init once on page load, reuse across note swaps.
|
||
// The editor lives in #tinymce-host (outside #editor-panel so htmx swaps don't
|
||
// destroy the iframe). Positioning tracks #tinymce-slot inside the swapped
|
||
// editor fragment so the editor visually sits where the fragment expects it.
|
||
var _tinymceInitStarted=false;
|
||
var _tinymceInitPromise=null;
|
||
// When true, TinyMCE input/change events are ignored (used to suppress spurious
|
||
// save-triggers when we call setContent programmatically on note switch / unlock).
|
||
var _tinymceSuppressEdits=false;
|
||
var _tinymceShowRequested=false;
|
||
function _setTinyMCEContent(html){
|
||
if(!_tinymceEditor)return;
|
||
_tinymceSuppressEdits=true;
|
||
try{
|
||
_tinymceEditor.setContent(html||'');
|
||
if(_tinymceEditor.undoManager)_tinymceEditor.undoManager.clear();
|
||
}finally{
|
||
setTimeout(function(){
|
||
_tinymceSuppressEdits=false;
|
||
// Run after all sync SetContent handlers (codesample plugin) have finished.
|
||
ensureTinyMCEEditableAfterPre(_tinymceEditor);
|
||
initTinyMCECodeCopyButtons(_tinymceEditor);
|
||
_applyTinyMCESpellcheck(_tinymceEditor);
|
||
},0);
|
||
}
|
||
}
|
||
function _tinyMCEContentFontStyle(){
|
||
var rs=getComputedStyle(document.body);
|
||
var fontFamily=rs.getPropertyValue('--font-family-note').trim();
|
||
if(!fontFamily)fontFamily=rs.fontFamily||'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
|
||
var fontSize=rs.getPropertyValue(isMobileShellMode()?'--font-size-note-mobile':'--font-size-note').trim()||'15px';
|
||
var fontSizeCode=rs.getPropertyValue('--font-size-code').trim()||'13px';
|
||
return ''
|
||
+'body{font-family:'+fontFamily+';font-size:'+fontSize+';line-height:1.7;color:var(--text);background:var(--bg)}'
|
||
+'h1,h2,h3,h4,h5,h6{color:var(--text-heading)}'
|
||
+'strong{color:var(--text-heading)}'
|
||
+'a{color:var(--accent)}'
|
||
+'code{background:var(--bg-hover);padding:2px 5px;border-radius:3px;font-family:"Cascadia Mono",monospace;font-size:'+fontSizeCode+'}'
|
||
+'pre{position:relative;background:#000;color:#e6edf3;border:1px solid #343a40;padding:12px;border-radius:6px;font-family:"Cascadia Mono",monospace;font-size:'+fontSizeCode+'}'
|
||
+'pre[class*="language-"]{background:#000!important;color:#e6edf3!important;text-shadow:none!important;font-family:"Cascadia Mono",monospace!important}'
|
||
+'pre code{background:transparent;padding:0}'
|
||
+'pre[class*="language-"] .token.comment,pre[class*="language-"] .token.prolog,pre[class*="language-"] .token.doctype,pre[class*="language-"] .token.cdata{color:#5c6370;font-style:italic}'
|
||
+'pre[class*="language-"] .token.punctuation{color:#abb2bf}'
|
||
+'pre[class*="language-"] .token.property,pre[class*="language-"] .token.tag,pre[class*="language-"] .token.boolean,pre[class*="language-"] .token.number,pre[class*="language-"] .token.constant,pre[class*="language-"] .token.symbol,pre[class*="language-"] .token.deleted{color:#d19a66}'
|
||
+'pre[class*="language-"] .token.selector,pre[class*="language-"] .token.attr-name,pre[class*="language-"] .token.string,pre[class*="language-"] .token.char,pre[class*="language-"] .token.builtin,pre[class*="language-"] .token.inserted{color:#98c379}'
|
||
+'pre[class*="language-"] .token.operator,pre[class*="language-"] .token.entity,pre[class*="language-"] .token.url{color:#56b6c2}'
|
||
+'pre[class*="language-"] .token.atrule,pre[class*="language-"] .token.attr-value,pre[class*="language-"] .token.keyword{color:#c678dd}'
|
||
+'pre[class*="language-"] .token.function,pre[class*="language-"] .token.class-name{color:#61afef}'
|
||
+'pre[class*="language-"] .token.regex,pre[class*="language-"] .token.important,pre[class*="language-"] .token.variable{color:#e06c75}'
|
||
+'pre>.pre-copy-btn{position:absolute!important;top:6px!important;right:8px!important;left:auto!important;float:none!important;margin:0!important;z-index:2;display:none;padding:2px 8px;font-size:11px;line-height:1.5;border:1px solid var(--border);border-radius:4px;background:var(--bg-elevated);color:var(--text-dim);opacity:0;pointer-events:none;transition:opacity .15s ease,color .15s ease,border-color .15s ease}'
|
||
+'pre:hover>.pre-copy-btn{display:inline-block;opacity:1;pointer-events:auto}'
|
||
+'.pre-copy-btn::after{content:"Copy"}'
|
||
+'.pre-copy-btn:hover{color:var(--accent);border-color:var(--accent)}'
|
||
+'blockquote{border-left:3px solid var(--accent);color:var(--text-dim)}'
|
||
+'hr{border-color:var(--border)}'
|
||
+'img{max-width:100%;height:auto;border-radius:6px}'
|
||
+'.md-checkbox{display:flex;align-items:baseline;gap:0.35em;margin:0.25em 0;padding-left:24px}'
|
||
+'.md-checkbox::before{content:"";display:inline-block;width:16px;height:16px;border:1.5px solid var(--accent);border-radius:3px;flex-shrink:0;background:transparent;box-sizing:border-box}'
|
||
+'.md-checkbox.checked::before{background:var(--accent);border-color:var(--accent);content:"\\2713";color:var(--bg);font-size:11px;font-weight:bold;line-height:16px;text-align:center}';
|
||
}
|
||
function _syncTinyMCEThemeVars(){
|
||
if(!_tinymceEditor||!_tinymceEditor.getDoc)return;
|
||
try{
|
||
var iframeDoc=_tinymceEditor.getDoc();
|
||
if(!iframeDoc||!iframeDoc.documentElement)return;
|
||
var rs=getComputedStyle(document.body);
|
||
var vars=['--bg','--text','--text-dim','--text-heading','--accent','--border','--bg-hover','--toolbar-bg'];
|
||
for(var i=0;i<vars.length;i++){
|
||
var val=rs.getPropertyValue(vars[i]).trim();
|
||
if(val)iframeDoc.documentElement.style.setProperty(vars[i],val);
|
||
}
|
||
}catch(_e){}
|
||
}
|
||
function _tinyMCEToolbarSpec(){
|
||
return 'jop_edit | bold italic underline strikethrough | blocks | bullist numlist jop_checkbox | code jop_code blockquote hr | jop_date jop_datetime | removeformat | link image jop_upload | jop_spellcheck jop_history';
|
||
}
|
||
// Spellcheck state (client-only, persisted in localStorage). Default off so
|
||
// shared browsers don't leak note text to remote spellcheck services.
|
||
function _spellcheckEnabled(){
|
||
try{return localStorage.getItem('_joplockSpellcheck')==='1'}catch(_e){return false}
|
||
}
|
||
function _setSpellcheckEnabled(on){
|
||
try{localStorage.setItem('_joplockSpellcheck',on?'1':'0')}catch(_e){}
|
||
}
|
||
// Read-only mode. Mobile notes open read-only by default so a tap scrolls/
|
||
// reads without accidentally editing; the jop_edit toolbar toggle enters edit
|
||
// mode. Desktop opens editable. State lives per editor session (not persisted).
|
||
var _tinymceReadonly=false;
|
||
function _tinymceReadonlyDefault(){return isMobileShellMode()}
|
||
// Push the current _tinymceReadonly flag into the live editor + sync the
|
||
// jop_edit toggle button. Safe to call before/after init.
|
||
function _applyTinyMCEReadonly(editor){
|
||
editor=editor||_tinymceEditor;
|
||
if(!editor)return;
|
||
try{
|
||
if(editor.mode&&editor.mode.set)editor.mode.set(_tinymceReadonly?'readonly':'design');
|
||
else if(editor.setMode)editor.setMode(_tinymceReadonly?'readonly':'design');
|
||
}catch(_e){}
|
||
// Reflect state on the edit toggle button (active = editable).
|
||
if(typeof _jopEditBtnApi!=='undefined'&&_jopEditBtnApi){try{_jopEditBtnApi.setActive(!_tinymceReadonly)}catch(_e){}}
|
||
}
|
||
function _setTinyMCEReadonly(on){
|
||
_tinymceReadonly=!!on;
|
||
_applyTinyMCEReadonly(_tinymceEditor);
|
||
}
|
||
var _jopEditBtnApi=null;
|
||
// Apply the current spellcheck state to the live TinyMCE iframe body.
|
||
function _applyTinyMCESpellcheck(editor){
|
||
if(!editor)return;
|
||
var on=_spellcheckEnabled();
|
||
try{
|
||
var body=editor.getBody&&editor.getBody();
|
||
if(body){body.spellcheck=on;body.setAttribute('spellcheck',on?'true':'false')}
|
||
}catch(_e){}
|
||
}
|
||
function initPersistentTinyMCE(){
|
||
if(_tinymceInitStarted||!window.tinymce)return _tinymceInitPromise;
|
||
var textarea=document.getElementById('tinymce-editor');
|
||
if(!textarea)return null;
|
||
_tinymceInitStarted=true;
|
||
// Suppress edit events during initial render (TinyMCE fires input/change while
|
||
// populating the iframe from the textarea's initial value). Cleared after init.
|
||
_tinymceSuppressEdits=true;
|
||
_tinymceInitPromise=window.tinymce.init({
|
||
target:textarea,
|
||
license_key:'gpl',
|
||
menubar:false,
|
||
newline_behavior:document.body.dataset.newlineBehavior||'linebreak',
|
||
toolbar:_tinyMCEToolbarSpec(),
|
||
toolbar_mode:'sliding',
|
||
height:'100%',
|
||
resize:false,
|
||
skin:'oxide-dark',
|
||
highlight_on_focus:false,
|
||
plugins:'autolink advlist lists link image code codesample table',
|
||
link_default_target:'_blank',
|
||
// Native browser spellcheck. Toggled at runtime via the jop_spellcheck
|
||
// toolbar button; browser_spellcheck must be true so TinyMCE does not
|
||
// force spellcheck="false" on the iframe body.
|
||
browser_spellcheck:true,
|
||
codesample_languages:[
|
||
{text:'Plain text',value:''},
|
||
{text:'Bash',value:'bash'},
|
||
{text:'BASIC',value:'basic'},
|
||
{text:'C',value:'c'},
|
||
{text:'C++',value:'cpp'},
|
||
{text:'CSS',value:'css'},
|
||
{text:'Go',value:'go'},
|
||
{text:'HTML/XML',value:'markup'},
|
||
{text:'JavaScript',value:'javascript'},
|
||
{text:'JSON',value:'json'},
|
||
{text:'Python',value:'python'},
|
||
{text:'SQL',value:'sql'},
|
||
{text:'TypeScript',value:'typescript'},
|
||
{text:'YAML',value:'yaml'}
|
||
],
|
||
valid_elements:'*[*]',
|
||
extended_valid_elements:'div[class|data-*],span[class|style],img[src|alt|class|width|height|data-resource-id]',
|
||
content_css:'dark',
|
||
content_style:_tinyMCEContentFontStyle(),
|
||
mobile:{
|
||
menubar:false,
|
||
toolbar_mode:'sliding',
|
||
toolbar_persist:true,
|
||
toolbar:_tinyMCEToolbarSpec()
|
||
},
|
||
images_upload_handler:function(blobInfo){
|
||
return new Promise(function(resolve,reject){
|
||
var formData=new FormData();
|
||
formData.append('file',blobInfo.blob(),blobInfo.filename());
|
||
fetch('/fragments/upload',{method:'POST',body:formData,credentials:'same-origin'}).then(function(r){return r.json()}).then(function(data){if(data&&data.error){reject(data.error);return}resolve('/resources/'+data.resourceId)}).catch(reject);
|
||
});
|
||
},
|
||
// Upload pasted/dropped images automatically through images_upload_handler
|
||
// so they become Joplin resources instead of inline data: URIs.
|
||
automatic_uploads:true,
|
||
paste_data_images:true,
|
||
// Browse button in the Image/Media dialogs -> upload through /fragments/upload.
|
||
file_picker_types:'image media file',
|
||
file_picker_callback:function(callback,value,meta){
|
||
var input=document.createElement('input');
|
||
input.type='file';
|
||
if(meta&&meta.filetype==='image')input.accept='image/*';
|
||
input.onchange=function(){
|
||
var file=input.files&&input.files[0];
|
||
if(!file)return;
|
||
var fd=new FormData();
|
||
fd.append('file',file);
|
||
fetch('/fragments/upload',{method:'POST',body:fd,credentials:'same-origin'}).then(function(r){return r.json()}).then(function(data){
|
||
if(data&&data.error){alert(data.error);return}
|
||
callback('/resources/'+data.resourceId,{alt:file.name,title:file.name});
|
||
}).catch(function(){alert('Upload failed')});
|
||
};
|
||
input.click();
|
||
},
|
||
setup:function(editor){
|
||
// Custom app buttons used by the TinyMCE built-in toolbar.
|
||
editor.ui.registry.addIcon('jop_checkbox','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="5.5" y="5.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>');
|
||
editor.ui.registry.addIcon('jop_date','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="4.5" y="5.5" width="15" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 4.5v3M16 4.5v3M4.5 9.5h15" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/></svg>');
|
||
editor.ui.registry.addIcon('jop_datetime','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="3.5" y="5.5" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6.5 4.5v3M12.5 4.5v3M3.5 9.5h12" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/><circle cx="17.5" cy="16.5" r="3.8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M17.5 14.8v2.1l1.5.9" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||
editor.ui.registry.addIcon('jop_upload','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.2 12.8l5.3-5.3a3 3 0 114.3 4.3l-6.4 6.4a4.5 4.5 0 01-6.4-6.4l6.4-6.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||
editor.ui.registry.addIcon('jop_history','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12a8 8 0 108-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M4 5v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 8.5V12l2.5 1.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||
editor.ui.registry.addButton('jop_checkbox',{
|
||
tooltip:'Checkbox',
|
||
icon:'jop_checkbox',
|
||
onAction:function(){tinyMCEInsertCheckbox();}
|
||
});
|
||
editor.ui.registry.addButton('jop_date',{
|
||
tooltip:'Insert date',
|
||
icon:'jop_date',
|
||
onAction:function(){tinyMCEInsertDate();}
|
||
});
|
||
editor.ui.registry.addButton('jop_datetime',{
|
||
tooltip:'Insert date & time',
|
||
icon:'jop_datetime',
|
||
onAction:function(){tinyMCEInsertDateTime();}
|
||
});
|
||
editor.ui.registry.addButton('jop_upload',{
|
||
tooltip:'Upload file',
|
||
icon:'jop_upload',
|
||
onAction:function(){openUploadModal();}
|
||
});
|
||
editor.ui.registry.addButton('jop_history',{
|
||
tooltip:'Note history',
|
||
icon:'jop_history',
|
||
onAction:function(){
|
||
var form=activeEditorForm();
|
||
var noteId=form?form.dataset.noteId:'';
|
||
if(noteId)openHistoryModal(noteId);
|
||
}
|
||
});
|
||
// Custom code button opens our themed full-screen CM6 code modal
|
||
// instead of TinyMCE's built-in (unthemed, non-highlighting)
|
||
// codesample dialog. Uses a distinct name so it doesn't depend on
|
||
// button-registration ordering vs the codesample plugin.
|
||
editor.ui.registry.addButton('jop_code',{
|
||
tooltip:'Code sample',
|
||
icon:'code-sample',
|
||
onAction:function(){openCodeModal();}
|
||
});
|
||
// Spellcheck toggle. Uses native browser spellcheck on the iframe body;
|
||
// no external plugin/service. State persisted client-side.
|
||
editor.ui.registry.addIcon('jop_spellcheck','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 17l4-10 4 10M5.2 14h5.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.5 15.5l2 2 3.5-4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||
editor.ui.registry.addToggleButton('jop_spellcheck',{
|
||
tooltip:'Toggle spellcheck',
|
||
icon:'jop_spellcheck',
|
||
onAction:function(api){
|
||
var on=!_spellcheckEnabled();
|
||
_setSpellcheckEnabled(on);
|
||
api.setActive(on);
|
||
_applyTinyMCESpellcheck(editor);
|
||
// Re-focus so the browser re-scans the current content.
|
||
try{editor.focus()}catch(_e){}
|
||
},
|
||
onSetup:function(api){
|
||
api.setActive(_spellcheckEnabled());
|
||
return function(){};
|
||
}
|
||
});
|
||
// Edit toggle. Notes open read-only on mobile so tapping scrolls/reads
|
||
// without accidental edits; tapping this pencil enters edit mode.
|
||
// Active (highlighted) = editable, inactive = read-only.
|
||
editor.ui.registry.addIcon('jop_edit','<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 20h4l10-10-4-4L4 16v4z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M13.5 6.5l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>');
|
||
editor.ui.registry.addToggleButton('jop_edit',{
|
||
tooltip:'Toggle edit mode',
|
||
icon:'jop_edit',
|
||
// Keep this button enabled in read-only mode so it can toggle back
|
||
// to editable (default TinyMCE buttons are disabled in readonly).
|
||
context:'any',
|
||
onAction:function(api){
|
||
var goEditable=_tinymceReadonly; // about to turn edit ON
|
||
_setTinyMCEReadonly(!_tinymceReadonly);
|
||
api.setActive(!_tinymceReadonly);
|
||
if(goEditable){
|
||
// Entering edit mode: honor the saved note-open preference,
|
||
// which may be markdown (read-only always shows rendered).
|
||
if(_defaultNoteOpenMode==='markdown'&&_editorMode!=='markdown'){setEditorMode('markdown');}
|
||
else{try{editor.focus()}catch(_e){}}
|
||
}else{
|
||
// Leaving edit mode: always drop back to rendered (read-only)
|
||
// so a subsequent tap scrolls/reads rather than edits markdown.
|
||
if(_editorMode==='markdown'){setEditorMode('rich');}
|
||
}
|
||
},
|
||
onSetup:function(api){
|
||
_jopEditBtnApi=api;
|
||
api.setActive(!_tinymceReadonly);
|
||
return function(){if(_jopEditBtnApi===api)_jopEditBtnApi=null};
|
||
}
|
||
});
|
||
editor.on('keydown',function(e){
|
||
if(_tinymceReadonly){e.preventDefault();return;}
|
||
if(e.key!=='Enter')return;
|
||
if(e.shiftKey)return;
|
||
var selNode=editor.selection&&editor.selection.getNode?editor.selection.getNode():null;
|
||
var body=editor.getBody?editor.getBody():null;
|
||
// Handle Enter inside list items — newline_behavior block conflicts with lists plugin
|
||
var li=selNode;
|
||
while(li&&li!==body&&li.nodeName!=='LI')li=li.parentNode;
|
||
if(li&&li!==body){
|
||
e.preventDefault();
|
||
editor.undoManager.transact(function(){
|
||
var doc=editor.getDoc();
|
||
var rng=editor.selection.getRng();
|
||
var text=li.textContent||'';
|
||
var ulol=li.parentNode;
|
||
var empty=!text.replace(/[\u00a0\u200b]/g,'').trim();
|
||
if(empty||rng.collapsed&&rng.startOffset===0&&!text.trim()){
|
||
// Empty li or caret at start of empty li — exit list
|
||
var p=doc.createElement('p');
|
||
p.innerHTML='<br data-mce-bogus="1">';
|
||
ulol.parentNode.insertBefore(p,ulol.nextSibling);
|
||
li.parentNode.removeChild(li);
|
||
if(!ulol.children.length)ulol.parentNode.removeChild(ulol);
|
||
editor.selection.setCursorLocation(p,0);
|
||
}else if(rng.collapsed){
|
||
// Split li at caret
|
||
var neo=doc.createElement('li');
|
||
var frag=rng.extractContents();
|
||
if(frag.childNodes.length||frag.textContent){
|
||
while(frag.firstChild)neo.appendChild(frag.firstChild);
|
||
}else{
|
||
neo.innerHTML='<br data-mce-bogus="1">';
|
||
}
|
||
// Clean trailing br in current li
|
||
if(!(li.textContent||'').replace(/[\u00a0\u200b]/g,'').trim()){
|
||
var nb=doc.createElement('br');
|
||
nb.setAttribute('data-mce-bogus','1');
|
||
li.appendChild(nb);
|
||
}
|
||
ulol.insertBefore(neo,li.nextSibling);
|
||
editor.selection.setCursorLocation(neo,0);
|
||
}
|
||
});
|
||
onEdit();
|
||
return;
|
||
}
|
||
var cb=selNode;
|
||
while(cb&&cb!==body&&!(cb.nodeType===1&&cb.classList&&cb.classList.contains('md-checkbox')))cb=cb.parentNode;
|
||
if(!cb||cb===body)return;
|
||
e.preventDefault();
|
||
editor.undoManager.transact(function(){
|
||
var doc=editor.getDoc();
|
||
var label=(cb.textContent||'').replace(/^[\u2610\u2611][\u00a0 ]*/,'').replace(/\u00a0/g,' ').trim();
|
||
if(!label){
|
||
var p=doc.createElement('p');
|
||
p.innerHTML='<br data-mce-bogus="1">';
|
||
cb.parentNode.replaceChild(p,cb);
|
||
editor.selection.setCursorLocation(p,0);
|
||
return;
|
||
}
|
||
var neo=doc.createElement('div');
|
||
neo.className='md-checkbox';
|
||
var tn=doc.createTextNode('\u00a0');
|
||
neo.appendChild(tn);
|
||
if(cb.nextSibling)cb.parentNode.insertBefore(neo,cb.nextSibling);
|
||
else cb.parentNode.appendChild(neo);
|
||
var range=doc.createRange();
|
||
range.setStart(tn,1);
|
||
range.collapse(true);
|
||
editor.selection.setRng(range);
|
||
});
|
||
onEdit();
|
||
});
|
||
// Resolve nearest PRE codeblock from an event target, or null.
|
||
function _resolveTinyMCEPre(target){
|
||
if(!target||!target.closest)return null;
|
||
var pre=target.closest('pre,code[class*="language-"]');
|
||
if(pre&&pre.nodeName==='CODE'&&pre.parentElement&&pre.parentElement.nodeName==='PRE')pre=pre.parentElement;
|
||
if(pre&&pre.nodeName!=='PRE')pre=pre.closest&&pre.closest('pre')?pre.closest('pre'):null;
|
||
return pre&&pre.nodeName==='PRE'?pre:null;
|
||
}
|
||
function _openTinyMCECodeBlock(pre){
|
||
// Read-only mode blocks opening the code editor modal.
|
||
if(_tinymceReadonly)return;
|
||
normalizeTinyMCECodeSampleClasses(editor);
|
||
tinyMCEInsertCodeBlock(pre);
|
||
}
|
||
// Long-press state for touch devices. Tap/scroll must NOT open the
|
||
// code editor; only a sustained hold does.
|
||
var _lpTimer=null,_lpPre=null,_lpX=0,_lpY=0,_lpFired=false;
|
||
var LP_MS=500,LP_MOVE=10;
|
||
function _cancelLongPress(){if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}_lpPre=null;}
|
||
editor.on('touchstart',function(e){
|
||
_lpFired=false;
|
||
if(_tinymceReadonly)return;
|
||
var t=e&&e.target?e.target:null;
|
||
if(t&&t.closest&&t.closest('.pre-copy-btn'))return;
|
||
var pre=_resolveTinyMCEPre(t);
|
||
if(!pre)return;
|
||
var pt=e.touches&&e.touches[0]?e.touches[0]:e;
|
||
_lpPre=pre;_lpX=pt.clientX;_lpY=pt.clientY;
|
||
_lpTimer=setTimeout(function(){
|
||
_lpTimer=null;_lpFired=true;
|
||
if(_lpPre)_openTinyMCECodeBlock(_lpPre);
|
||
_lpPre=null;
|
||
},LP_MS);
|
||
});
|
||
editor.on('touchmove',function(e){
|
||
if(!_lpTimer)return;
|
||
var pt=e.touches&&e.touches[0]?e.touches[0]:e;
|
||
if(Math.abs(pt.clientX-_lpX)>LP_MOVE||Math.abs(pt.clientY-_lpY)>LP_MOVE)_cancelLongPress();
|
||
});
|
||
editor.on('touchend touchcancel',_cancelLongPress);
|
||
editor.on('click',function(e){
|
||
var target=e&&e.target?e.target:null;
|
||
if(!target||!target.closest)return;
|
||
if(target.closest('.pre-copy-btn'))return;
|
||
var cb=target.closest('.md-checkbox');
|
||
if(cb){
|
||
e.preventDefault();
|
||
// Read-only mode blocks toggling checkboxes.
|
||
if(_tinymceReadonly)return;
|
||
var checked=!cb.classList.contains('checked');
|
||
cb.classList.toggle('checked',checked);
|
||
onEdit();
|
||
return;
|
||
}
|
||
var pre=_resolveTinyMCEPre(target);
|
||
if(!pre)return;
|
||
// Touch: long-press already handled (or was scroll/tap). Suppress
|
||
// the synthetic click so a tap never opens the editor.
|
||
if(_lpFired){_lpFired=false;e.preventDefault();e.stopPropagation();return;}
|
||
if(e.pointerType==='touch'||e.sourceCapabilities&&e.sourceCapabilities.firesTouchEvents){e.preventDefault();e.stopPropagation();return;}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_openTinyMCECodeBlock(pre);
|
||
});
|
||
// Drag-and-drop file upload directly into the note (no modal).
|
||
// We handle ALL dropped files ourselves (images + other files) and stop
|
||
// TinyMCE's default handling so images don't get inlined as base64.
|
||
editor.on('dragover',function(e){
|
||
if(e.dataTransfer){try{e.dataTransfer.dropEffect='copy'}catch(_){}}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
editor.on('drop',function(e){
|
||
var files=e.dataTransfer&&e.dataTransfer.files;
|
||
if(!files||!files.length)return; // let TinyMCE handle text/html drops
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if(e.stopImmediatePropagation)e.stopImmediatePropagation();
|
||
var doc=editor.getDoc();
|
||
var range=null;
|
||
if(doc.caretRangeFromPoint){
|
||
range=doc.caretRangeFromPoint(e.clientX,e.clientY);
|
||
}else if(doc.caretPositionFromPoint){
|
||
var pos=doc.caretPositionFromPoint(e.clientX,e.clientY);
|
||
if(pos){range=doc.createRange();range.setStart(pos.offsetNode,pos.offset);range.collapse(true)}
|
||
}
|
||
if(range)editor.selection.setRng(range);
|
||
var arr=Array.prototype.slice.call(files);
|
||
arr.reduce(function(p,file){return p.then(function(){return _uploadFileToTinyMCE(file,editor)})},Promise.resolve()).then(function(){markEdited();scheduleSave()}).catch(function(err){console.error('Drop upload failed:',err)});
|
||
return false;
|
||
});
|
||
// Clipboard paste of non-image files (e.g. PDFs). Pasted images are handled by
|
||
// TinyMCE's built-in paste_data_images + images_upload_handler pipeline, so we
|
||
// only intercept non-image file items here to avoid double insertion.
|
||
editor.on('paste',function(e){
|
||
var cd=e.clipboardData||(window.clipboardData);
|
||
if(!cd)return;
|
||
var items=cd.items;
|
||
if(!items||!items.length)return;
|
||
var fileItems=[];
|
||
for(var i=0;i<items.length;i++){
|
||
if(items[i].kind==='file'&&items[i].type&&items[i].type.indexOf('image/')!==0){
|
||
var f=items[i].getAsFile();
|
||
if(f)fileItems.push(f);
|
||
}
|
||
}
|
||
if(!fileItems.length)return;
|
||
e.preventDefault();
|
||
fileItems.reduce(function(p,file){return p.then(function(){return _uploadFileToTinyMCE(file,editor)})},Promise.resolve()).then(function(){markEdited();scheduleSave()}).catch(function(err){console.error('Paste upload failed:',err)});
|
||
});
|
||
function onEdit(){
|
||
if(_tinymceSuppressEdits)return;
|
||
if(_tinymceReadonly)return;
|
||
var form=activeEditorForm();
|
||
if(!form)return;
|
||
if(form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1')return;
|
||
// Skip if editor host is hidden (locked note, empty state).
|
||
var host=document.getElementById('tinymce-host');
|
||
if(!host||!host.classList.contains('tinymce-host-visible'))return;
|
||
var ta=getTA();
|
||
if(ta){
|
||
var html=editor.getContent();
|
||
var md=tinymceToMarkdown(html);
|
||
if(ta.value!==md){
|
||
ta.value=md;
|
||
ta.dispatchEvent(new Event('input',{bubbles:true}));
|
||
}
|
||
}
|
||
markEdited();
|
||
scheduleSave();
|
||
}
|
||
editor.on('input',onEdit);
|
||
editor.on('change',onEdit);
|
||
// Text-expander: after text is committed to the iframe DOM, check the
|
||
// caret suffix for a trigger and expand. Runs on keyup so the just-typed
|
||
// character is present. Guarded to rendered mode + text triggers only.
|
||
editor.on('keyup',function(e){
|
||
if(_tinymceSuppressEdits)return;
|
||
if(e&&(e.ctrlKey||e.metaKey||e.altKey))return;
|
||
if(!_textExpanders.length)return;
|
||
try{maybeExpandTextFromTinyMCE(editor)}catch(err){_clientLog('expander.tinymce.error',{message:String(err&&err.message||err)})}
|
||
});
|
||
// Ctrl/Cmd-Space: manual AI prose completion inside the rendered editor.
|
||
// The global document keydown handler cannot see keystrokes inside the
|
||
// TinyMCE iframe, so wire it on the editor directly.
|
||
editor.on('keydown',function(e){
|
||
if(!e)return;
|
||
// When the AI completion popup is open, its keys (Enter/Tab/Esc/arrows)
|
||
// must be handled here — iframe key events never reach the outer document.
|
||
if(_activeRenderPopup&&_activeRenderPopupKind==='tinymce-prose'){
|
||
if(handleRenderPopupKey(e))return;
|
||
// Any other key (typing, backspace, etc.) dismisses the stale suggestion.
|
||
if(!e.ctrlKey&&!e.metaKey&&!e.altKey)hideRenderAutocompletePopup();
|
||
}
|
||
if(e.altKey)return;
|
||
if((e.ctrlKey||e.metaKey)&&(e.code==='Space'||e.key===' '||e.keyCode===32)){
|
||
e.preventDefault();
|
||
try{requestTinyMCEProseCompletion({editor:editor})}catch(err){_clientLog('expander.tinymce.error',{message:String(err&&err.message||err)})}
|
||
}
|
||
});
|
||
// Code block init is deferred to _setTinyMCEContent so it runs after
|
||
// TinyMCE's codesample plugin has finished processing the content.
|
||
editor.on('focus',function(){
|
||
document.body.dispatchEvent(new CustomEvent('joplock:editor-focus',{detail:{source:'tinymce'}}));
|
||
});
|
||
editor.on('blur',function(){
|
||
document.body.dispatchEvent(new CustomEvent('joplock:editor-blur',{detail:{source:'tinymce'}}));
|
||
});
|
||
editor.on('init',function(){
|
||
_tinymceEditor=editor;
|
||
ensureTinyMCEEditableAfterPre(editor);
|
||
initTinyMCECodeCopyButtons(editor);
|
||
try{
|
||
var iframeDoc=editor.getDoc();
|
||
if(iframeDoc){
|
||
if(iframeDoc.documentElement){
|
||
iframeDoc.documentElement.style.overscrollBehavior='none';
|
||
}
|
||
if(iframeDoc.body){
|
||
iframeDoc.body.style.overscrollBehavior='none';
|
||
iframeDoc.body.style.webkitOverflowScrolling='touch';
|
||
}
|
||
}
|
||
}catch(e){}
|
||
// Wire drop handlers on the host div (chrome area outside iframe)
|
||
var host=document.getElementById('tinymce-host');
|
||
if(host&&!host._jopDropWired){
|
||
host._jopDropWired=true;
|
||
host.addEventListener('dragover',function(ev){ev.preventDefault()});
|
||
host.addEventListener('drop',function(ev){
|
||
var files=ev.dataTransfer&&ev.dataTransfer.files;
|
||
if(!files||!files.length)return;
|
||
ev.preventDefault();
|
||
var arr=Array.prototype.slice.call(files);
|
||
arr.reduce(function(p,file){return p.then(function(){return _uploadFileToTinyMCE(file,_tinymceEditor)})},Promise.resolve()).then(function(){markEdited();scheduleSave()}).catch(function(err){console.error('Drop upload failed:',err)});
|
||
});
|
||
}
|
||
_syncTinyMCEThemeVars();
|
||
_tinymceSuppressEdits=false;
|
||
_applyTinyMCESpellcheck(editor);
|
||
_applyTinyMCEReadonly(editor);
|
||
refreshTinyMCEForActiveNote();
|
||
});
|
||
}
|
||
}).then(function(editors){
|
||
if(editors&&editors[0])_tinymceEditor=editors[0];
|
||
});
|
||
return _tinymceInitPromise;
|
||
}
|
||
function positionTinyMCEHost(){
|
||
var host=document.getElementById('tinymce-host');
|
||
if(!host)return;
|
||
var slot=queryActiveEditor('#tinymce-slot');
|
||
if(!slot||slot.offsetParent===null){
|
||
host.classList.remove('tinymce-host-visible');
|
||
return;
|
||
}
|
||
var vv=window.visualViewport;
|
||
var vpH=vv?vv.height:window.innerHeight;
|
||
var top,left,width;
|
||
if(isMobileShellMode()){
|
||
// On mobile the slot lives inside a rubber-bandable container so
|
||
// getBoundingClientRect().top drifts during over-scroll. Instead,
|
||
// anchor the host to the bottom of the last stable fixed header element
|
||
// (mobile editor header, then toolbar if visible) — these are in normal
|
||
// flow inside position:fixed .mobile-app and don't bounce.
|
||
var editorScreen=document.getElementById('mobile-editor-screen');
|
||
var anchorEl=null;
|
||
// Walk fixed headers in order: search header (if visible), main header, toolbar.
|
||
['mobile-editor-search-header','mobile-editor-header'].forEach(function(id){
|
||
var el=document.getElementById(id);
|
||
if(el&&getComputedStyle(el).display!=='none')anchorEl=el;
|
||
});
|
||
var tb=document.getElementById('mobile-editor-body')&&
|
||
document.getElementById('mobile-editor-body').querySelector('#editor-toolbar');
|
||
if(tb&&getComputedStyle(tb).display!=='none')anchorEl=tb;
|
||
if(anchorEl){
|
||
var ar=anchorEl.getBoundingClientRect();
|
||
top=ar.bottom;
|
||
}else{
|
||
// Fallback: use slot rect (no rubber-band active yet)
|
||
top=slot.getBoundingClientRect().top;
|
||
}
|
||
var mobileApp=document.getElementById('mobile-app');
|
||
var mar=mobileApp?mobileApp.getBoundingClientRect():{left:0,width:window.innerWidth};
|
||
left=mar.left;
|
||
width=mar.width;
|
||
// Keep the host locked to the visible viewport while the keyboard is open,
|
||
// otherwise iOS may pan the outer page (dual-scroll effect).
|
||
// When keyboard is closed, prefer innerHeight to avoid rubber-band gaps.
|
||
if(vv&&window.innerHeight-vv.height>80){
|
||
vpH=vv.height+vv.offsetTop;
|
||
}else{
|
||
vpH=window.innerHeight;
|
||
}
|
||
}else{
|
||
var rect=slot.getBoundingClientRect();
|
||
if(rect.width===0||rect.height===0){
|
||
host.classList.remove('tinymce-host-visible');
|
||
return;
|
||
}
|
||
top=rect.top;left=rect.left;width=rect.width;
|
||
host.style.height=Math.max(0,rect.height)+'px';
|
||
host.style.top=top+'px';
|
||
host.style.left=left+'px';
|
||
host.style.width=width+'px';
|
||
return;
|
||
}
|
||
host.style.top=top+'px';
|
||
host.style.left=left+'px';
|
||
host.style.width=width+'px';
|
||
host.style.height=Math.max(0,vpH-top)+'px';
|
||
}
|
||
function hideTinyMCEHost(){
|
||
_tinymceShowRequested=false;
|
||
var host=document.getElementById('tinymce-host');
|
||
if(host)host.classList.remove('tinymce-host-visible');
|
||
}
|
||
function stabilizeTinyMCEHostPosition(){
|
||
if(!isMobileShellMode())return;
|
||
positionTinyMCEHost();
|
||
requestAnimationFrame(positionTinyMCEHost);
|
||
setTimeout(positionTinyMCEHost,60);
|
||
setTimeout(positionTinyMCEHost,180);
|
||
try{
|
||
if(_tinymceEditor&&_tinymceEditor.hasFocus&&_tinymceEditor.hasFocus()&&_tinymceEditor.selection&&_tinymceEditor.selection.scrollIntoView){
|
||
_tinymceEditor.selection.scrollIntoView();
|
||
}
|
||
}catch(_e){}
|
||
}
|
||
function showTinyMCEHost(){
|
||
_tinymceShowRequested=true;
|
||
var host=document.getElementById('tinymce-host');
|
||
if(!host)return;
|
||
// Reveal on next frame so theme CSS overrides settle before first paint.
|
||
requestAnimationFrame(function(){
|
||
if(!_tinymceShowRequested)return;
|
||
host.classList.add('tinymce-host-visible');
|
||
positionTinyMCEHost();
|
||
requestAnimationFrame(function(){
|
||
positionTinyMCEHost();
|
||
setTimeout(positionTinyMCEHost,60);
|
||
setTimeout(positionTinyMCEHost,180);
|
||
});
|
||
if(isMobileShellMode()){
|
||
try{
|
||
if(_tinymceEditor&&_tinymceEditor.hasFocus&&_tinymceEditor.hasFocus()&&_tinymceEditor.selection&&_tinymceEditor.selection.scrollIntoView){
|
||
_tinymceEditor.selection.scrollIntoView();
|
||
}
|
||
}catch(_e){}
|
||
}
|
||
});
|
||
}
|
||
function refreshTinyMCEForActiveNote(){
|
||
if(!_tinymceEditor)return;
|
||
var form=activeEditorForm();
|
||
if(!form){hideTinyMCEHost();return}
|
||
// Locked encrypted note: keep TinyMCE hidden, do not populate content.
|
||
if(form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1'){
|
||
hideTinyMCEHost();
|
||
return;
|
||
}
|
||
// Mode toggle: markdown mode shows the raw textarea, not TinyMCE.
|
||
if(_editorMode==='markdown'||_editorMode==='md'){
|
||
hideTinyMCEHost();
|
||
return;
|
||
}
|
||
var slot=queryActiveEditor('#tinymce-slot');
|
||
if(!slot){hideTinyMCEHost();return}
|
||
// Use the server-rendered HTML from the slot's data attribute when available;
|
||
// fall back to /fragments/preview if the note body was set client-side (e.g.
|
||
// after unlock decrypts ciphertext into #note-body).
|
||
var ta=getTA();
|
||
var mdVal=ta?ta.value:'';
|
||
var renderedFromServer=slot.dataset.renderedBody||'';
|
||
if(renderedFromServer&&!(form.dataset.encrypted==='1')){
|
||
_setTinyMCEContent(renderedFromServer);
|
||
showTinyMCEHost();
|
||
_applyTinyMCEReadonly(_tinymceEditor);
|
||
return;
|
||
}
|
||
// Encrypted-and-now-unlocked: server sent no useful HTML. Re-render from plaintext.
|
||
fetch('/fragments/preview',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'body='+encodeURIComponent(mdVal)}).then(function(r){return r.text()}).then(function(h){
|
||
if(!_tinymceEditor)return;
|
||
_setTinyMCEContent(h);
|
||
showTinyMCEHost();
|
||
_applyTinyMCEReadonly(_tinymceEditor);
|
||
});
|
||
}
|
||
// Reposition on resize / scroll / htmx swaps.
|
||
window.addEventListener('resize',positionTinyMCEHost);
|
||
document.addEventListener('scroll',positionTinyMCEHost,true);
|
||
// Back-compat shim: legacy code paths call initTinyMCE(textarea,content).
|
||
// Now they just refresh the persistent editor.
|
||
function initTinyMCE(_textarea,content){
|
||
if(!_tinymceEditor){
|
||
initPersistentTinyMCE();
|
||
// When init completes it will refresh from the active note.
|
||
return;
|
||
}
|
||
if(typeof content==='string'){
|
||
_setTinyMCEContent(content);
|
||
showTinyMCEHost();
|
||
}
|
||
}
|
||
function noteCompletionSource(context) {
|
||
var before = context.matchBefore(/\[\[[^\]]*/);
|
||
if (!before) return null;
|
||
var query = before.text.slice(2);
|
||
return fetchNoteHeaders().then(function(headers) {
|
||
var options = headers.filter(function(h) {
|
||
return h.title.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||
}).map(function(h) {
|
||
return {
|
||
label: h.title,
|
||
apply: '[' + h.title + '](:/' + h.id + ')',
|
||
detail: 'Note link'
|
||
};
|
||
});
|
||
return {
|
||
from: before.from,
|
||
options: options,
|
||
filter: false
|
||
};
|
||
});
|
||
}
|
||
function getTextBeforeCursorPV(pv){
|
||
var sel=window.getSelection();
|
||
if(!sel||!sel.rangeCount||!pv||!pv.contains(sel.anchorNode))return'';
|
||
var range=sel.getRangeAt(0);
|
||
if(!range.collapsed)return'';
|
||
var node=range.startContainer;
|
||
var offset=range.startOffset;
|
||
var walker=document.createTreeWalker(pv,NodeFilter.SHOW_TEXT,null,false);
|
||
var text='';
|
||
while(walker.nextNode()){
|
||
var n=walker.currentNode;
|
||
if(n===node){
|
||
text+=(n.textContent||'').slice(0,offset);
|
||
break;
|
||
}
|
||
text+=(n.textContent||'');
|
||
}
|
||
return text;
|
||
}
|
||
function requestProseCompletion(prompt,force,profileId){
|
||
if(!force||!_openRouterEnabled)return Promise.resolve('');
|
||
console.info('[joplock] prose autocomplete request context',{promptChars:String(prompt||'').length});
|
||
var body='prompt='+encodeURIComponent(prompt||'')+(profileId?'&profileId='+encodeURIComponent(profileId):'');
|
||
return fetch('/api/web/ai/prose-complete',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){if(r.ok)return r.json();return r.json().catch(function(){return {error:'HTTP '+r.status}}).then(function(data){var err=new Error((data&&data.error)||('HTTP '+r.status));err.providerStatus=data&&data.providerStatus;err.providerError=data&&data.providerError;err.contextChars=data&&data.contextChars;throw err})}).then(function(data){if(data&&typeof data.contextChars==='number')console.info('[joplock] prose autocomplete provider context',{contextChars:data.contextChars});if(data&&data.emptyReason)console.warn('[joplock] prose autocomplete empty',{reason:data.emptyReason,rawChars:data.rawChars,suffixTrimmedChars:data.suffixTrimmedChars,trimmedChars:data.trimmedChars,finishReason:data.finishReason||''});return data&&data.text?String(data.text).trim():''}).catch(function(err){console.warn('[joplock] prose autocomplete failed:',err&&err.message?err.message:err);if(err&&typeof err.contextChars==='number')console.warn('[joplock] prose autocomplete provider context',{contextChars:err.contextChars});if(err&&err.providerError)console.warn('[joplock] AI provider error', {status:err.providerStatus,error:err.providerError});return ''});
|
||
}
|
||
var _manualProseCompletionInFlight=false;
|
||
var _manualCodeMirrorProseText=null;
|
||
function manualProseCompletionSource(context){
|
||
if(!_manualCodeMirrorProseText)return null;
|
||
return {from:context.pos,options:[{label:_manualCodeMirrorProseText,apply:_manualCodeMirrorProseText,detail:'OpenRouter prose'}],filter:false};
|
||
}
|
||
function requestManualProseCompletion(opts){
|
||
opts=opts||{};
|
||
if(!_openRouterEnabled||_manualProseCompletionInFlight)return;
|
||
var pv=getPV();
|
||
var cm=getCM();
|
||
var prompt='';
|
||
var stateOverride=null;
|
||
var cmRangeOverride=null;
|
||
var profileId=opts.profileId||'';
|
||
if(pv){syncPV();prompt=getTextBeforeCursorPV(pv)}
|
||
else if(cm&&isMarkdownVisible()){prompt=cm.state.sliceDoc(0,cm.state.selection.main.head)}
|
||
else{var ta2=getTA();prompt=ta2?ta2.value.slice(0,ta2.selectionStart||ta2.value.length):''}
|
||
if(opts.state)stateOverride=opts.state;
|
||
if(opts.cmRange)cmRangeOverride=opts.cmRange;
|
||
if(!prompt||prompt.trim().length<20){console.info('[joplock] manual prose autocomplete skipped: prompt too short',{length:prompt?prompt.trim().length:0});return}
|
||
console.info('[joplock] manual prose autocomplete query sent');
|
||
_manualProseCompletionInFlight=true;
|
||
requestProseCompletion(prompt,true,profileId).then(function(text){
|
||
console.info('[joplock] manual prose autocomplete response received',{empty:!text,length:text?text.length:0});
|
||
if(!text)return;
|
||
if(getPV()){
|
||
var coords=getCaretCoordinates();
|
||
if(!coords)return;
|
||
var state=stateOverride||getRenderProseState(true);
|
||
if(!state){var sel=window.getSelection();if(sel&&sel.rangeCount){var range=sel.getRangeAt(0);if(range.startContainer&&range.startContainer.nodeType===3)state={kind:'prose',node:range.startContainer,startIdx:range.startOffset,endIdx:range.startOffset,prompt:prompt};else{var el=range.startContainer&&range.startContainer.nodeType===1?range.startContainer:(range.startContainer&&range.startContainer.parentElement?range.startContainer.parentElement:null);var block=el&&el.closest?el.closest('p,div,li,blockquote,h1,h2,h3,h4,h5,h6'):null;if(block&&getPV()&&getPV().contains(block))state={kind:'prose',node:block,startIdx:0,endIdx:(block.textContent||'').length,prompt:prompt}}}}
|
||
if(state)showRenderAutocompletePopup(coords,[{label:text,text:text}],'prose',state);
|
||
}else{var cm2=getCM();if(cm2&&isMarkdownVisible()){var s=cmRangeOverride||cm2.state.selection.main;var rect=cm2.coordsAtPos(s.from);if(!rect)return;showRenderAutocompletePopup({top:rect.top,left:rect.left,height:rect.height||18},[{label:text,text:text}],'cm-prose',{from:s.from,to:s.to})}else insertTxt(text)}
|
||
}).finally(function(){_manualProseCompletionInFlight=false});
|
||
}
|
||
|
||
// --- Input ring buffer for Expander trigger detection ---
|
||
// Captures raw characters from beforeinput events before contenteditable normalization.
|
||
function createInputRingBuffer(size){
|
||
var buf=[];
|
||
var s=size||20;
|
||
return {
|
||
push:function(chars){if(!chars)return;buf.push(String(chars));while(buf.length>s)buf.shift()},
|
||
tail:function(){return buf.join('')},
|
||
reset:function(){buf=[]}
|
||
};
|
||
}
|
||
var _inputRingBuffer=createInputRingBuffer(15);
|
||
var _pendingTextExpansion=null;
|
||
// Dedup flag: set when beforeinput successfully feeds the buffer so the input fallback can skip.
|
||
// On iOS Safari, beforeinput may not fire or may fire with insertCompositionText for normal Latin
|
||
// keystrokes (autocorrect pipeline), so input is used as a fallback.
|
||
var _ringBufFedFromBeforeinput=false;
|
||
|
||
// Only feed the buffer from committed text input events.
|
||
// insertCompositionText (in-progress IME) is excluded; only insertFromComposition (committed) is accepted.
|
||
function _ringBufAccepts(inputType){return inputType==='insertText'||inputType==='insertFromComposition'}
|
||
|
||
function detectTextExpanderFromBuffer(buffer){
|
||
var tail=buffer.tail();
|
||
if(!tail||!_textExpanders.length)return null;
|
||
var candidates=_textExpanders.slice().sort(function(a,b){return b.trigger.length-a.trigger.length});
|
||
for(var i=0;i<candidates.length;i++){var entry=candidates[i];if(entry.trigger&&tail.slice(-entry.trigger.length)===entry.trigger)return entry}
|
||
return null;
|
||
}
|
||
|
||
function findPVTextPosition(root, offset){
|
||
var walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,{acceptNode:function(n){return n.parentElement&&n.parentElement.closest('script,style,button')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}},false);
|
||
var count=0,node;
|
||
while((node=walker.nextNode())){var len=(node.textContent||'').length;if(offset<=count+len)return {node:node,offset:Math.max(0,offset-count)};count+=len}
|
||
return null;
|
||
}
|
||
|
||
function insertPVExpansionText(range,text){
|
||
var value=String(text||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
|
||
range.deleteContents();
|
||
var parts=value.split('\n');
|
||
var frag=document.createDocumentFragment();
|
||
var caretNode=null;
|
||
for(var i=0;i<parts.length;i++){
|
||
var node=document.createTextNode(parts[i]);
|
||
frag.appendChild(node);
|
||
caretNode=node;
|
||
if(i<parts.length-1)frag.appendChild(document.createElement('br'));
|
||
}
|
||
range.insertNode(frag);
|
||
setPVCaret(caretNode,caretNode?caretNode.textContent.length:0);
|
||
}
|
||
|
||
function replacePVTextExpansion(entry){
|
||
var pv=getPV();var sel=window.getSelection();
|
||
if(!pv||!sel||!sel.rangeCount||!sel.isCollapsed||!entry){_clientLog('expander.pv.skip',{reason:'missing-selection-or-entry',hasPv:!!pv,hasSel:!!sel,rangeCount:sel&&sel.rangeCount||0,collapsed:!!(sel&&sel.isCollapsed)});return false;}
|
||
var range=sel.getRangeAt(0);if(!pv.contains(range.commonAncestorContainer)){_clientLog('expander.pv.skip',{reason:'outside-preview'});return false;}
|
||
var el=range.startContainer&&range.startContainer.nodeType===1?range.startContainer:(range.startContainer&&range.startContainer.parentElement?range.startContainer.parentElement:null);
|
||
if(el&&el.closest&&el.closest('pre,code')){_clientLog('expander.pv.skip',{reason:'code-block',trigger:entry.trigger});return false;}
|
||
if(range.startContainer&&range.startContainer.nodeType===3){
|
||
var currentText=range.startContainer.textContent||'';
|
||
if(currentText.slice(0,range.startOffset).slice(-entry.trigger.length)===entry.trigger){
|
||
var directRange=document.createRange();directRange.setStart(range.startContainer,range.startOffset-entry.trigger.length);directRange.setEnd(range.startContainer,range.startOffset);directRange.deleteContents();
|
||
insertPVExpansionText(directRange,entry.text);syncPV();pv.focus();_clientLog('expander.pv.replace',{path:'direct',trigger:entry.trigger,replacementLength:entry.text.length,lines:String(entry.text).split('\n').length});return true;
|
||
}
|
||
}
|
||
var beforeRange=range.cloneRange();beforeRange.selectNodeContents(pv);beforeRange.setEnd(range.startContainer,range.startOffset);
|
||
var before=beforeRange.toString();
|
||
if(before.slice(-entry.trigger.length)!==entry.trigger){_clientLog('expander.pv.skip',{reason:'trigger-not-at-caret',trigger:entry.trigger,beforeTail:before.slice(-15).replace(/ /g,'·').replace(/\u00a0/g,'_')});return false;}
|
||
var start=findPVTextPosition(pv,before.length-entry.trigger.length);
|
||
if(!start){_clientLog('expander.pv.skip',{reason:'start-not-found',trigger:entry.trigger});return false;}
|
||
var replaceRange=document.createRange();replaceRange.setStart(start.node,start.offset);replaceRange.setEnd(range.startContainer,range.startOffset);replaceRange.deleteContents();
|
||
insertPVExpansionText(replaceRange,entry.text);syncPV();pv.focus();_clientLog('expander.pv.replace',{path:'range',trigger:entry.trigger,replacementLength:entry.text.length,lines:String(entry.text).split('\n').length});return true;
|
||
}
|
||
|
||
function removePVTriggerForAction(entry){
|
||
var pv=getPV();var sel=window.getSelection();
|
||
if(!pv||!sel||!sel.rangeCount||!sel.isCollapsed||!entry)return null;
|
||
var range=sel.getRangeAt(0);if(!pv.contains(range.commonAncestorContainer))return null;
|
||
var el=range.startContainer&&range.startContainer.nodeType===1?range.startContainer:(range.startContainer&&range.startContainer.parentElement?range.startContainer.parentElement:null);
|
||
if(el&&el.closest&&el.closest('pre,code'))return null;
|
||
if(range.startContainer&&range.startContainer.nodeType===3){
|
||
var currentText=range.startContainer.textContent||'';
|
||
if(currentText.slice(0,range.startOffset).slice(-entry.trigger.length)===entry.trigger){
|
||
var caretOffset=range.startOffset-entry.trigger.length;var directRange=document.createRange();directRange.setStart(range.startContainer,caretOffset);directRange.setEnd(range.startContainer,range.startOffset);directRange.deleteContents();setPVCaret(range.startContainer,caretOffset);syncPV();pv.focus();return {kind:'prose',node:range.startContainer,startIdx:caretOffset,endIdx:caretOffset};
|
||
}
|
||
}
|
||
var beforeRange=range.cloneRange();beforeRange.selectNodeContents(pv);beforeRange.setEnd(range.startContainer,range.startOffset);
|
||
var before=beforeRange.toString();if(before.slice(-entry.trigger.length)!==entry.trigger)return null;
|
||
var start=findPVTextPosition(pv,before.length-entry.trigger.length);if(!start)return null;
|
||
var replaceRange=document.createRange();replaceRange.setStart(start.node,start.offset);replaceRange.setEnd(range.startContainer,range.startOffset);replaceRange.deleteContents();setPVCaret(start.node,start.offset);syncPV();pv.focus();return {kind:'prose',node:start.node,startIdx:start.offset,endIdx:start.offset};
|
||
}
|
||
|
||
function replaceCMTextExpansion(entry){
|
||
var cm=getCM();if(!cm||!isMarkdownVisible()||!entry)return false;
|
||
var s=cm.state.selection.main;if(!s||!s.empty)return false;
|
||
var from=s.from-entry.trigger.length;if(from<0)return false;
|
||
if(cm.state.sliceDoc(from,s.from)!==entry.trigger)return false;
|
||
cm.dispatch({changes:{from:from,to:s.from,insert:entry.text},selection:{anchor:from+entry.text.length}});cm.focus();return true;
|
||
}
|
||
|
||
function removeCMTriggerForAction(entry){
|
||
var cm=getCM();if(!cm||!isMarkdownVisible()||!entry)return null;
|
||
var s=cm.state.selection.main;if(!s||!s.empty)return null;
|
||
var from=s.from-entry.trigger.length;if(from<0)return null;
|
||
if(cm.state.sliceDoc(from,s.from)!==entry.trigger)return null;
|
||
cm.dispatch({changes:{from:from,to:s.from,insert:''},selection:{anchor:from}});cm.focus();return {from:from,to:from,trigger:entry.trigger};
|
||
}
|
||
|
||
// TinyMCE (rendered-mode) text expander. Inspects the text before the caret in
|
||
// the TinyMCE iframe selection; if it ends with a trigger, replaces the trigger
|
||
// with the expansion text. Text triggers only; AI triggers are skipped in
|
||
// rendered mode (the AI prose-completion path is not wired into the iframe).
|
||
function replaceTinyMCETextExpansion(entry,editor){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||!entry||!entry.trigger)return false;
|
||
var sel=ed.selection;if(!sel)return false;
|
||
var rng=sel.getRng&&sel.getRng();if(!rng||!rng.collapsed)return false;
|
||
var node=rng.startContainer;
|
||
// Only act inside a text node; bail inside code/pre.
|
||
var el=node&&node.nodeType===1?node:(node&&node.parentElement?node.parentElement:null);
|
||
if(el&&el.closest&&el.closest('pre,code'))return false;
|
||
if(!node||node.nodeType!==3)return false;
|
||
var text=node.textContent||'';
|
||
var caret=rng.startOffset;
|
||
if(text.slice(0,caret).slice(-entry.trigger.length)!==entry.trigger)return false;
|
||
var start=caret-entry.trigger.length;if(start<0)return false;
|
||
var doc=ed.getDoc();
|
||
var newRange=doc.createRange();
|
||
newRange.setStart(node,start);
|
||
newRange.setEnd(node,caret);
|
||
newRange.deleteContents();
|
||
// Insert expansion text; multi-line becomes <br>-separated for the rendered editor.
|
||
var parts=String(entry.text).split('\n');
|
||
var frag=doc.createDocumentFragment();
|
||
var lastNode=null;
|
||
for(var i=0;i<parts.length;i++){
|
||
if(i>0)frag.appendChild(doc.createElement('br'));
|
||
lastNode=doc.createTextNode(parts[i]);
|
||
frag.appendChild(lastNode);
|
||
}
|
||
newRange.insertNode(frag);
|
||
// Place caret after inserted content.
|
||
var caretRange=doc.createRange();
|
||
if(lastNode){caretRange.setStart(lastNode,(lastNode.textContent||'').length);}
|
||
else{caretRange.setStart(node,start);}
|
||
caretRange.collapse(true);
|
||
sel.setRng(caretRange);
|
||
ed.focus();
|
||
// Sync to markdown source of truth.
|
||
tinyMCESyncToTA();
|
||
markEdited();scheduleSave();
|
||
_clientLog('expander.tinymce.replace',{trigger:entry.trigger,replacementLength:entry.text.length,lines:parts.length});
|
||
return true;
|
||
}
|
||
|
||
// Build an AI-prompt string from the text before the caret in the TinyMCE
|
||
// iframe. Walks the iframe body in document order up to the caret, joining
|
||
// block boundaries with newlines so the provider sees paragraph structure.
|
||
function getTextBeforeCaretTinyMCE(editor){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||!ed.selection)return '';
|
||
var rng=ed.selection.getRng&&ed.selection.getRng();
|
||
if(!rng)return '';
|
||
var doc=ed.getDoc&&ed.getDoc();var body=ed.getBody&&ed.getBody();
|
||
if(!doc||!body)return '';
|
||
// Range from the very start of the body to the caret.
|
||
var pre=doc.createRange();
|
||
pre.setStart(body,0);
|
||
try{pre.setEnd(rng.startContainer,rng.startOffset);}catch(_e){return '';}
|
||
var frag=pre.cloneContents();
|
||
// Serialize the fragment to text, treating block elements as line breaks.
|
||
var host=doc.createElement('div');host.appendChild(frag);
|
||
var BLOCK=/^(P|DIV|LI|BLOCKQUOTE|H1|H2|H3|H4|H5|H6|PRE|TR|BR)$/;
|
||
var out='';
|
||
(function walk(node){
|
||
for(var n=node.firstChild;n;n=n.nextSibling){
|
||
if(n.nodeType===3){out+=n.textContent||'';}
|
||
else if(n.nodeType===1){
|
||
if(n.tagName==='BR'){out+='\n';continue;}
|
||
var block=BLOCK.test(n.tagName);
|
||
if(block&&out&&!/\n$/.test(out))out+='\n';
|
||
walk(n);
|
||
if(block&&out&&!/\n$/.test(out))out+='\n';
|
||
}
|
||
}
|
||
})(host);
|
||
return out.replace(/\u00a0/g,' ');
|
||
}
|
||
|
||
// Insert AI prose-completion text at the current TinyMCE caret and sync to the
|
||
// markdown source of truth. Multi-line completions become <br>-separated lines
|
||
// (matching replaceTinyMCETextExpansion), so a single paragraph is preserved.
|
||
// Text is inserted as DOM text nodes (never HTML) so provider output cannot
|
||
// inject markup into the iframe.
|
||
function insertProseCompletionTinyMCE(text,editor,bookmark){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||!text||!ed.selection)return false;
|
||
ed.focus();
|
||
if(bookmark){try{ed.selection.moveToBookmark(bookmark);}catch(_e){}}
|
||
var sel=ed.selection;
|
||
var rng=sel.getRng&&sel.getRng();
|
||
if(!rng)return false;
|
||
var doc=ed.getDoc();
|
||
rng.deleteContents();
|
||
var parts=String(text).split('\n');
|
||
var frag=doc.createDocumentFragment();
|
||
var lastNode=null;
|
||
for(var i=0;i<parts.length;i++){
|
||
if(i>0)frag.appendChild(doc.createElement('br'));
|
||
lastNode=doc.createTextNode(parts[i]);
|
||
frag.appendChild(lastNode);
|
||
}
|
||
rng.insertNode(frag);
|
||
var caretRange=doc.createRange();
|
||
if(lastNode)caretRange.setStart(lastNode,(lastNode.textContent||'').length);
|
||
else caretRange.setStart(rng.startContainer,rng.startOffset);
|
||
caretRange.collapse(true);
|
||
sel.setRng(caretRange);
|
||
ed.focus();
|
||
tinyMCESyncToTA();
|
||
markEdited();scheduleSave();
|
||
return true;
|
||
}
|
||
|
||
// Remove a matched AI-trigger from the TinyMCE iframe (so the trigger text does
|
||
// not become part of the prompt or remain after the completion). Returns true
|
||
// when the trigger was found immediately before the caret and removed.
|
||
function removeTinyMCETriggerForAction(entry,editor){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||!entry||!entry.trigger||!ed.selection)return false;
|
||
var rng=ed.selection.getRng&&ed.selection.getRng();
|
||
if(!rng||!rng.collapsed)return false;
|
||
var node=rng.startContainer;if(!node||node.nodeType!==3)return false;
|
||
var caret=rng.startOffset;
|
||
if((node.textContent||'').slice(0,caret).slice(-entry.trigger.length)!==entry.trigger)return false;
|
||
var start=caret-entry.trigger.length;if(start<0)return false;
|
||
var doc=ed.getDoc();
|
||
var delRange=doc.createRange();
|
||
delRange.setStart(node,start);delRange.setEnd(node,caret);
|
||
delRange.deleteContents();
|
||
var caretRange=doc.createRange();
|
||
caretRange.setStart(node,start);caretRange.collapse(true);
|
||
ed.selection.setRng(caretRange);
|
||
return true;
|
||
}
|
||
|
||
// Screen (viewport) coordinates of the TinyMCE caret, for positioning the
|
||
// autocomplete popup which lives in the OUTER document. The caret rect from the
|
||
// iframe is relative to the iframe viewport, so offset it by the iframe element.
|
||
function tinyMCECaretCoords(editor){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||!ed.selection)return null;
|
||
var rng=ed.selection.getRng&&ed.selection.getRng();
|
||
if(!rng)return null;
|
||
var rect=null;
|
||
try{
|
||
var rects=rng.getClientRects&&rng.getClientRects();
|
||
if(rects&&rects.length)rect=rects[rects.length-1];
|
||
if(!rect||(!rect.width&&!rect.height)){
|
||
var node=rng.startContainer;
|
||
var el=node&&node.nodeType===1?node:(node&&node.parentElement);
|
||
if(el&&el.getBoundingClientRect)rect=el.getBoundingClientRect();
|
||
}
|
||
}catch(_e){return null;}
|
||
if(!rect)return null;
|
||
var ifr=ed.iframeElement||(ed.getContentAreaContainer&&ed.getContentAreaContainer().querySelector&&ed.getContentAreaContainer().querySelector('iframe'));
|
||
var off={left:0,top:0};
|
||
if(ifr&&ifr.getBoundingClientRect){var ir=ifr.getBoundingClientRect();off.left=ir.left;off.top=ir.top;}
|
||
return {top:rect.top+off.top,left:rect.left+off.left,height:rect.height||18};
|
||
}
|
||
|
||
// Launch an AI prose completion from within TinyMCE (rendered mode). Used by
|
||
// both the Ctrl/Cmd-Space shortcut and AI-action Expander triggers. The result
|
||
// is offered in the same accept/dismiss popup used by markdown mode (Enter/Tab
|
||
// to insert, Esc to discard) rather than inserted directly.
|
||
function requestTinyMCEProseCompletion(opts){
|
||
opts=opts||{};
|
||
var ed=opts.editor||getTinyMCE();
|
||
if(!ed||!_openRouterEnabled||_manualProseCompletionInFlight)return false;
|
||
var prompt=getTextBeforeCaretTinyMCE(ed);
|
||
if(!prompt||prompt.trim().length<20){console.info('[joplock] tinymce prose autocomplete skipped: prompt too short',{length:prompt?prompt.trim().length:0});return false;}
|
||
// Capture the caret so accept can insert at the right spot even if focus moves.
|
||
var bookmark=null;try{bookmark=ed.selection.getBookmark(2);}catch(_e){}
|
||
_manualProseCompletionInFlight=true;
|
||
_clientLog('expander.tinymce.ai.request',{profileId:opts.profileId||'',promptChars:prompt.length});
|
||
requestProseCompletion(prompt,true,opts.profileId||'').then(function(text){
|
||
if(!text)return;
|
||
var coords=tinyMCECaretCoords(ed);
|
||
if(!coords){insertProseCompletionTinyMCE(text,ed);_clientLog('expander.tinymce.ai.insert',{length:text.length,fallback:true});return;}
|
||
showRenderAutocompletePopup(coords,[{label:text,text:text}],'tinymce-prose',{editor:ed,bookmark:bookmark});
|
||
_clientLog('expander.tinymce.ai.popup',{length:text.length});
|
||
}).finally(function(){_manualProseCompletionInFlight=false});
|
||
return true;
|
||
}
|
||
|
||
function maybeExpandTextFromTinyMCE(editor){
|
||
var ed=editor||getTinyMCE();
|
||
if(!ed||_editorMode==='markdown'||_editorMode==='md'||!_textExpanders.length)return false;
|
||
var sel=ed.selection;if(!sel)return false;
|
||
var rng=sel.getRng&&sel.getRng();if(!rng||!rng.collapsed)return false;
|
||
var node=rng.startContainer;if(!node||node.nodeType!==3)return false;
|
||
var before=(node.textContent||'').slice(0,rng.startOffset);
|
||
var candidates=_textExpanders.slice().sort(function(a,b){return b.trigger.length-a.trigger.length});
|
||
for(var i=0;i<candidates.length;i++){
|
||
var entry=candidates[i];
|
||
if(!entry.trigger)continue;
|
||
if(before.slice(-entry.trigger.length)!==entry.trigger)continue;
|
||
if(entry.action==='ai'){
|
||
// AI prose-completion in rendered mode: remove the trigger from the
|
||
// iframe, then request a completion and insert it at the caret.
|
||
if(!_openRouterEnabled||_manualProseCompletionInFlight){
|
||
_clientLog('expander.tinymce.skip',{reason:'ai-blocked',trigger:entry.trigger,openRouterEnabled:_openRouterEnabled,inFlight:_manualProseCompletionInFlight});
|
||
continue;
|
||
}
|
||
if(!removeTinyMCETriggerForAction(entry,ed))continue;
|
||
tinyMCESyncToTA();
|
||
_clientLog('expander.tinymce.ai.trigger',{trigger:entry.trigger,profileId:entry.profileId||''});
|
||
requestTinyMCEProseCompletion({editor:ed,profileId:entry.profileId||''});
|
||
return true;
|
||
}
|
||
var ok=replaceTinyMCETextExpansion(entry,ed);
|
||
_clientLog('expander.tinymce.match',{trigger:entry.trigger,action:entry.action,ok:ok});
|
||
if(ok)return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function runTextExpanderAction(entry,source){
|
||
if(!entry)return false;
|
||
if(entry.action!=='ai')return source==='cm'?replaceCMTextExpansion(entry):replacePVTextExpansion(entry);
|
||
if(!_openRouterEnabled||_manualProseCompletionInFlight){_clientLog('expander.ai.skip',{source:source,trigger:entry.trigger,reason:'blocked',openRouterEnabled:_openRouterEnabled,inFlight:_manualProseCompletionInFlight});return false;}
|
||
if(source==='cm'){
|
||
var cmRange=removeCMTriggerForAction(entry);if(!cmRange)return false;
|
||
_clientLog('expander.ai.trigger',{source:source,trigger:entry.trigger,profileId:entry.profileId||''});
|
||
requestManualProseCompletion({allowWithoutCtrlSpace:true,cmRange:cmRange,profileId:entry.profileId||''});return true;
|
||
}
|
||
var state=removePVTriggerForAction(entry);if(!state)return false;
|
||
_clientLog('expander.ai.trigger',{source:source,trigger:entry.trigger,profileId:entry.profileId||''});
|
||
requestManualProseCompletion({allowWithoutCtrlSpace:true,state:state,profileId:entry.profileId||''});return true;
|
||
}
|
||
|
||
function maybeExpandTextFromCM(cm){
|
||
if(!cm||!isMarkdownVisible()||!_textExpanders.length)return false;
|
||
var s=cm.state.selection.main;if(!s||!s.empty)return false;
|
||
var from=Math.max(0,s.from-15);
|
||
var before=cm.state.sliceDoc(from,s.from);
|
||
var candidates=_textExpanders.slice().sort(function(a,b){return b.trigger.length-a.trigger.length});
|
||
for(var i=0;i<candidates.length;i++){
|
||
var entry=candidates[i];
|
||
if(entry.trigger&&before.slice(-entry.trigger.length)===entry.trigger){_pendingTextExpansion=null;var ok=runTextExpanderAction(entry,'cm');_clientLog('expander.cm.match',{trigger:entry.trigger,action:entry.action,ok:ok,replacementLength:entry.text.length,lines:String(entry.text).split('\n').length});if(ok)_resetRingBuffer('cm-text-expansion');return ok}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function consumePendingTextExpansion(source){
|
||
var entry=_pendingTextExpansion;_pendingTextExpansion=null;
|
||
if(!entry)return false;
|
||
var ok=runTextExpanderAction(entry,source);
|
||
if(ok)_resetRingBuffer('text-expansion');
|
||
_clientLog('expander.consume',{source:source,trigger:entry.trigger,ok:ok});
|
||
return ok;
|
||
}
|
||
|
||
function _feedRingBuffer(source,inputType,data){
|
||
if(!_ringBufAccepts(inputType)||!data)return;
|
||
_inputRingBuffer.push(data);
|
||
var tail=_inputRingBuffer.tail().slice(-12).replace(/ /g,'·').replace(/\u00a0/g,'⍽');
|
||
var expansion=detectTextExpanderFromBuffer(_inputRingBuffer);
|
||
if(expansion){_pendingTextExpansion=expansion;_clientLog('expander.detect',{source:source,inputType:inputType,trigger:expansion.trigger,tail:tail})}
|
||
}
|
||
|
||
function _resetRingBuffer(reason){
|
||
_inputRingBuffer.reset();
|
||
_pendingTextExpansion=null;
|
||
}
|
||
function maybeTriggerManualProseFromCM(cm, upd){
|
||
if(!upd||!upd.docChanged||!upd.transactions||!upd.transactions.length)return;
|
||
maybeExpandTextFromCM(cm);
|
||
}
|
||
var _activeRenderPopup = null;
|
||
var _activeRenderPopupKind = null;
|
||
var _activeRenderPopupState = null;
|
||
var _popupSelectedIndex = 0;
|
||
var _popupItems = [];
|
||
var _renderProseTimer = null;
|
||
|
||
function getRenderQueryState() {
|
||
var sel = window.getSelection();
|
||
if (!sel || !sel.rangeCount) return null;
|
||
var range = sel.getRangeAt(0);
|
||
if (range.startContainer.nodeType !== 3) return null;
|
||
var text = range.startContainer.textContent;
|
||
var offset = range.startOffset;
|
||
var beforeText = text.slice(0, offset);
|
||
var bracketIdx = beforeText.lastIndexOf('[[');
|
||
if (bracketIdx === -1) return null;
|
||
if (beforeText.slice(bracketIdx).indexOf(']]') !== -1) return null;
|
||
var query = beforeText.slice(bracketIdx + 2);
|
||
return {
|
||
kind: 'note',
|
||
node: range.startContainer,
|
||
startIdx: bracketIdx,
|
||
endIdx: offset,
|
||
query: query
|
||
};
|
||
}
|
||
|
||
function getRenderProseState(force) {
|
||
var sel = window.getSelection();
|
||
if (!sel || !sel.rangeCount) return null;
|
||
var range = sel.getRangeAt(0);
|
||
if (range.startContainer.nodeType !== 3) return null;
|
||
var text = range.startContainer.textContent || '';
|
||
var offset = range.startOffset;
|
||
var beforeText = text.slice(0, offset);
|
||
if (beforeText.length < 20) return null;
|
||
if (/\[\[[^\]]*$/.test(beforeText)) return null;
|
||
var query = '';
|
||
if (!force) {
|
||
var match = beforeText.match(/(?:^|[^A-Za-z0-9'’\-])([A-Za-z][A-Za-z0-9'’\-]{2,})\s+$/);
|
||
if (!match) return null;
|
||
query = match[1];
|
||
}
|
||
return {
|
||
kind: 'prose',
|
||
node: range.startContainer,
|
||
startIdx: offset,
|
||
endIdx: offset,
|
||
query: query,
|
||
prompt: text
|
||
};
|
||
}
|
||
|
||
function getRenderAutocompleteState() {
|
||
return getRenderQueryState();
|
||
}
|
||
|
||
function getCaretCoordinates() {
|
||
var sel = window.getSelection();
|
||
if (!sel || !sel.rangeCount) return null;
|
||
var range = sel.getRangeAt(0);
|
||
var rect = range.getBoundingClientRect();
|
||
var vv=visualViewportBounds();
|
||
function validRect(r){return !!(r&&Number.isFinite(r.top)&&Number.isFinite(r.left)&&r.bottom>=vv.top&&r.top<=vv.top+vv.height&&r.right>=vv.left&&r.left<=vv.left+vv.width&&(r.width>0||r.height>0))}
|
||
if (validRect(rect)) {
|
||
return { top: rect.top, left: rect.left, height: rect.height || 18 };
|
||
}
|
||
var span = document.createElement('span');
|
||
span.textContent = '\u200b';
|
||
var clonedRange = range.cloneRange();
|
||
clonedRange.insertNode(span);
|
||
var spanRect = span.getBoundingClientRect();
|
||
var parent = span.parentNode;
|
||
if (parent) {
|
||
parent.removeChild(span);
|
||
parent.normalize();
|
||
}
|
||
if (!validRect(spanRect)) return null;
|
||
return { top: spanRect.top, left: spanRect.left, height: spanRect.height || 18 };
|
||
}
|
||
|
||
function hideRenderAutocompletePopup() {
|
||
if (_activeRenderPopup) {
|
||
if (_activeRenderPopup.parentNode) {
|
||
_activeRenderPopup.parentNode.removeChild(_activeRenderPopup);
|
||
}
|
||
_activeRenderPopup = null;
|
||
}
|
||
_activeRenderPopupKind = null;
|
||
_activeRenderPopupState = null;
|
||
_popupItems = [];
|
||
}
|
||
|
||
function placeRenderAutocompletePopup(popup, coords) {
|
||
var vv=visualViewportBounds();
|
||
var margin=8;
|
||
var viewportLeft=vv.left+margin;
|
||
var viewportTop=vv.top+margin;
|
||
var viewportRight=vv.left+vv.width-margin;
|
||
var viewportBottom=vv.top+vv.height-margin;
|
||
popup.style.maxWidth=Math.max(120,vv.width-(margin*2))+'px';
|
||
popup.style.maxHeight=Math.max(80,vv.height-(margin*2))+'px';
|
||
var rect=popup.getBoundingClientRect();
|
||
var below=(coords.top||0)+(coords.height||18)+margin;
|
||
var above=(coords.top||0)-rect.height-margin;
|
||
var spaceBelow=viewportBottom-below;
|
||
var spaceAbove=above-viewportTop;
|
||
var top=(spaceBelow>=Math.min(rect.height,80)||spaceBelow>=spaceAbove)?below:above;
|
||
var maxTop=Math.max(viewportTop,viewportBottom-rect.height);
|
||
top=Math.min(Math.max(top,viewportTop),maxTop);
|
||
var maxLeft=Math.max(viewportLeft,viewportRight-rect.width);
|
||
var left=Math.min(Math.max(coords.left||viewportLeft,viewportLeft),maxLeft);
|
||
popup.style.top=top+'px';
|
||
popup.style.left=left+'px';
|
||
}
|
||
|
||
function showRenderAutocompletePopup(coords, items, kind, state) {
|
||
hideRenderAutocompletePopup();
|
||
if (!coords || !Number.isFinite(coords.top) || !Number.isFinite(coords.left)) return;
|
||
if (!items || !items.length) return;
|
||
_activeRenderPopupKind = kind || null;
|
||
_activeRenderPopupState = state || null;
|
||
_popupItems = items;
|
||
_popupSelectedIndex = 0;
|
||
var popup = document.createElement('div');
|
||
popup.className = 'note-autocomplete-popup';
|
||
popup.style.position = 'fixed';
|
||
popup.style.top = '0px';
|
||
popup.style.left = '0px';
|
||
popup.style.zIndex = '999999';
|
||
items.forEach(function(entry, idx) {
|
||
var row = document.createElement('div');
|
||
row.className = 'note-autocomplete-item' + (idx === 0 ? ' active' : '');
|
||
row.textContent = entry.label || entry.title || entry.text || '';
|
||
row.addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
applyActiveAutocompleteSelection(entry);
|
||
hideRenderAutocompletePopup();
|
||
});
|
||
popup.appendChild(row);
|
||
});
|
||
document.body.appendChild(popup);
|
||
placeRenderAutocompletePopup(popup,coords);
|
||
_activeRenderPopup = popup;
|
||
}
|
||
|
||
function applyActiveAutocompleteSelection(entry){
|
||
if(!entry)return;
|
||
if(_activeRenderPopupKind==='prose'){
|
||
var shouldScroll=shouldScrollPreviewAfterAutocomplete(_activeRenderPopupState);
|
||
replaceRenderQueryWithText((entry.text||entry.label||'').trim(),_activeRenderPopupState);
|
||
if(shouldScroll)scrollPreviewToBottom();
|
||
}else if(_activeRenderPopupKind==='cm-prose'){
|
||
var cm=getCM();
|
||
var state=_activeRenderPopupState||{};
|
||
var text=(entry.text||entry.label||'').trim();
|
||
if(cm&&text){var from=typeof state.from==='number'?state.from:cm.state.selection.main.from;var to=typeof state.to==='number'?state.to:cm.state.selection.main.to;var docLen=cm.state.doc.length;from=Math.min(Math.max(from,0),docLen);to=Math.min(Math.max(to,from),docLen);var atBottom=to>=docLen;try{cm.dispatch({changes:{from:from,to:to,insert:text},selection:{anchor:from+text.length}});cm.focus();if(atBottom)scrollMarkdownToBottom()}catch(err){console.error('[joplock] autocomplete dispatch failed',err);var cur=cm.state.selection.main;cm.dispatch({changes:{from:cur.from,to:cur.to,insert:text},selection:{anchor:cur.from+text.length}});cm.focus();scrollMarkdownToBottom()}}
|
||
}else if(_activeRenderPopupKind==='tinymce-prose'){
|
||
var st=_activeRenderPopupState||{};
|
||
var t=(entry.text||entry.label||'').trim();
|
||
if(t){insertProseCompletionTinyMCE(t,st.editor,st.bookmark);_clientLog('expander.tinymce.ai.insert',{length:t.length});}
|
||
}else{
|
||
replaceRenderQueryWithLink(entry.id,entry.title);
|
||
}
|
||
}
|
||
|
||
function shouldScrollPreviewAfterAutocomplete(state){
|
||
var pv=getPV();
|
||
if(!pv||!state||!state.node)return false;
|
||
var node=state.node;
|
||
if(!pv.contains(node))return false;
|
||
if(typeof state.endIdx==='number'&&state.endIdx<(node.textContent||'').length)return false;
|
||
var cursor=node;
|
||
while(cursor&&cursor!==pv){
|
||
while(cursor.nextSibling){
|
||
cursor=cursor.nextSibling;
|
||
if((cursor.textContent||'').trim()||cursor.nodeName==='IMG')return false;
|
||
}
|
||
cursor=cursor.parentNode;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function scrollPreviewToBottom(){
|
||
var pv=getPV();
|
||
if(!pv)return;
|
||
setTimeout(function(){pv.scrollTop=pv.scrollHeight},0);
|
||
}
|
||
|
||
function scrollMarkdownToBottom(){
|
||
var cm=getCM();
|
||
if(cm&&cm.scrollDOM){setTimeout(function(){cm.scrollDOM.scrollTop=cm.scrollDOM.scrollHeight},0);return}
|
||
var ta=getTA();
|
||
if(ta)setTimeout(function(){ta.scrollTop=ta.scrollHeight},0);
|
||
}
|
||
|
||
// Handle a keydown while the autocomplete popup is open. Returns true if the
|
||
// key was consumed. Shared between the outer-document listener and the TinyMCE
|
||
// iframe keydown handler (iframe key events never reach the outer document).
|
||
function handleRenderPopupKey(e){
|
||
if(!_activeRenderPopup)return false;
|
||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'||e.key==='Tab'||e.key==='Escape'){
|
||
if(e.preventDefault)e.preventDefault();
|
||
if(e.stopPropagation)e.stopPropagation();
|
||
if(e.key==='ArrowDown'){
|
||
_popupSelectedIndex=(_popupSelectedIndex+1)%_popupItems.length;
|
||
updateRenderPopupSelection();
|
||
}else if(e.key==='ArrowUp'){
|
||
_popupSelectedIndex=(_popupSelectedIndex-1+_popupItems.length)%_popupItems.length;
|
||
updateRenderPopupSelection();
|
||
}else if(e.key==='Enter'||e.key==='Tab'){
|
||
applyActiveAutocompleteSelection(_popupItems[_popupSelectedIndex]);
|
||
hideRenderAutocompletePopup();
|
||
}else if(e.key==='Escape'){
|
||
hideRenderAutocompletePopup();
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
document.addEventListener('keydown',function(e){
|
||
handleRenderPopupKey(e);
|
||
},true);
|
||
|
||
function updateRenderPopupSelection() {
|
||
if (!_activeRenderPopup) return;
|
||
var items = _activeRenderPopup.querySelectorAll('.note-autocomplete-item');
|
||
items.forEach(function(item, idx) {
|
||
if (idx === _popupSelectedIndex) {
|
||
item.classList.add('active');
|
||
item.scrollIntoView({ block: 'nearest' });
|
||
} else {
|
||
item.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
function replaceRenderQueryWithText(text, state) {
|
||
var current = state || getRenderAutocompleteState();
|
||
if (!current) return;
|
||
var node = current.node;
|
||
if (node && node.nodeType === 1) {
|
||
while (node.firstChild) node.removeChild(node.firstChild);
|
||
var insertElementNode = document.createTextNode(text);
|
||
var afterElementNode = document.createTextNode('\u200b');
|
||
node.appendChild(insertElementNode);
|
||
node.appendChild(afterElementNode);
|
||
var selElement = window.getSelection();
|
||
var rangeElement = document.createRange();
|
||
rangeElement.setStart(afterElementNode, 1);
|
||
rangeElement.collapse(true);
|
||
selElement.removeAllRanges();
|
||
selElement.addRange(rangeElement);
|
||
if (window.syncPV) window.syncPV();
|
||
var pvElement = getPV();
|
||
if (pvElement) pvElement.focus();
|
||
return;
|
||
}
|
||
var fullText = node.textContent;
|
||
var beforeText = fullText.slice(0, current.startIdx);
|
||
var afterText = fullText.slice(current.endIdx);
|
||
var parent = node.parentNode;
|
||
if (!parent) return;
|
||
if (beforeText) {
|
||
parent.insertBefore(document.createTextNode(beforeText), node);
|
||
}
|
||
var insertNode = document.createTextNode(text);
|
||
parent.insertBefore(insertNode, node);
|
||
var afterNode = document.createTextNode(afterText || '\u200b');
|
||
parent.insertBefore(afterNode, node);
|
||
parent.removeChild(node);
|
||
var sel = window.getSelection();
|
||
var range = document.createRange();
|
||
range.setStart(afterNode, afterText ? 0 : 1);
|
||
range.collapse(true);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
if (window.syncPV) window.syncPV();
|
||
var pv = getPV();
|
||
if (pv) pv.focus();
|
||
}
|
||
|
||
function proseCompletionApply(text, state) {
|
||
replaceRenderQueryWithText(text, state);
|
||
}
|
||
|
||
function replaceRenderQueryWithLink(noteId, title) {
|
||
var state = getRenderQueryState();
|
||
if (!state) return;
|
||
var node = state.node;
|
||
var text = node.textContent;
|
||
var startIdx = state.startIdx;
|
||
var endIdx = state.endIdx;
|
||
var a = document.createElement('a');
|
||
a.href = '/resources/' + noteId + '?download=1';
|
||
a.setAttribute('data-resource-id', noteId);
|
||
a.textContent = title;
|
||
var beforeText = text.slice(0, startIdx);
|
||
var afterText = text.slice(endIdx);
|
||
var parent = node.parentNode;
|
||
if (beforeText) {
|
||
var beforeNode = document.createTextNode(beforeText);
|
||
parent.insertBefore(beforeNode, node);
|
||
}
|
||
parent.insertBefore(a, node);
|
||
var afterNode = null;
|
||
if (afterText) {
|
||
afterNode = document.createTextNode(afterText);
|
||
parent.insertBefore(afterNode, node);
|
||
} else {
|
||
afterNode = document.createTextNode('\u200b');
|
||
parent.insertBefore(afterNode, node);
|
||
}
|
||
parent.removeChild(node);
|
||
var sel = window.getSelection();
|
||
var range = document.createRange();
|
||
range.setStart(afterNode, afterNode.textContent.startsWith('\u200b') ? 1 : 0);
|
||
range.collapse(true);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
if (window.syncPV) window.syncPV();
|
||
var pv = getPV();
|
||
if (pv) pv.focus();
|
||
}
|
||
function _cmNormalizeLanguageSupport(support){
|
||
if(!support)return null;
|
||
if(support.language&&support.language.parser)return support;
|
||
if(support.parser)return {language:support};
|
||
return null;
|
||
}
|
||
function _cmLanguageDescription(C,name,alias,buildSupport){
|
||
var support=null;
|
||
try{support=_cmNormalizeLanguageSupport(buildSupport())}catch(_e){support=null}
|
||
if(!support)return null;
|
||
return C.LanguageDescription.of({
|
||
name:name,
|
||
alias:alias||[],
|
||
load:function(){return Promise.resolve(support)}
|
||
});
|
||
}
|
||
function initCM(host,content){
|
||
if(_cmView){_cmView.destroy();_cmView=null}
|
||
var C=window.CM;
|
||
var joplockTheme=C.EditorView.theme({
|
||
'&':{height:'100%'},
|
||
'.cm-scroller':{overflow:'auto',fontFamily:'"Cascadia Mono",monospace',lineHeight:'1.7'},
|
||
'.cm-content':{padding:'16px 20px',caretColor:'var(--accent)'},
|
||
'.cm-gutters':{display:'none'},
|
||
'.cm-search.cm-panel':{display:'none'},
|
||
'.cm-searchMatch':{backgroundColor:'#ffe066',color:'#111',borderRadius:'2px'},
|
||
'.cm-searchMatch.cm-searchMatch-selected':{backgroundColor:'#ff9800',color:'#111',borderRadius:'2px'},
|
||
'.cm-selectionBackground':{backgroundColor:'color-mix(in srgb, var(--accent) 25%, transparent) !important'},
|
||
'&.cm-focused .cm-selectionBackground':{backgroundColor:'color-mix(in srgb, var(--accent) 30%, transparent) !important'},
|
||
'.cm-cursor':{borderLeftColor:'var(--accent)'},
|
||
'.cm-matchingBracket':{backgroundColor:'color-mix(in srgb, var(--accent) 25%, transparent)'}
|
||
});
|
||
var joplockHighlight=C.HighlightStyle.define([
|
||
{tag:C.tags.heading1,fontWeight:'bold',fontSize:'1.6em',color:'var(--text-heading)'},
|
||
{tag:C.tags.heading2,fontWeight:'bold',fontSize:'1.35em',color:'var(--text-heading)'},
|
||
{tag:C.tags.heading3,fontWeight:'bold',fontSize:'1.15em',color:'var(--text-heading)'},
|
||
{tag:[C.tags.heading4,C.tags.heading5,C.tags.heading6],fontWeight:'bold',color:'var(--text-heading)'},
|
||
{tag:C.tags.strong,fontWeight:'bold',color:'var(--text-heading)'},
|
||
{tag:C.tags.emphasis,fontStyle:'italic'},
|
||
{tag:C.tags.strikethrough,textDecoration:'line-through'},
|
||
{tag:C.tags.link,color:'var(--accent)',textDecoration:'underline'},
|
||
{tag:C.tags.url,color:'var(--accent)',textDecoration:'underline'},
|
||
{tag:C.tags.processingInstruction,fontFamily:'"Cascadia Mono",monospace',color:'var(--accent)'},
|
||
{tag:C.tags.monospace,fontFamily:'"Cascadia Mono",monospace'},
|
||
{tag:C.tags.meta,color:'var(--text-dim)'},
|
||
{tag:C.tags.quote,color:'var(--text-dim)',fontStyle:'italic'},
|
||
{tag:C.tags.keyword,color:'#c678dd'},
|
||
{tag:[C.tags.string,C.tags.special(C.tags.brace)],color:'#98c379'},
|
||
{tag:C.tags.number,color:'#d19a66'},
|
||
{tag:C.tags.bool,color:'#d19a66'},
|
||
{tag:[C.tags.definition(C.tags.variableName),C.tags.function(C.tags.variableName)],color:'#61afef'},
|
||
{tag:C.tags.typeName,color:'#e5c07b'},
|
||
{tag:C.tags.comment,color:'var(--text-dim)',fontStyle:'italic'},
|
||
{tag:C.tags.operator,color:'#56b6c2'},
|
||
{tag:C.tags.className,color:'#e5c07b'},
|
||
{tag:C.tags.propertyName,color:'#e06c75'},
|
||
{tag:C.tags.attributeName,color:'#d19a66'},
|
||
{tag:C.tags.attributeValue,color:'#98c379'}
|
||
]);
|
||
var onUpdate=C.EditorView.updateListener.of(function(upd){
|
||
if(upd.docChanged){cmSyncToTA();var ta=getTA();if(ta)ta.dispatchEvent(new Event('input',{bubbles:true}));maybeTriggerManualProseFromCM(_cmView,upd)}
|
||
});
|
||
var codeLanguages=[];
|
||
[
|
||
_cmLanguageDescription(C,'javascript',['js','jsx'],function(){return C.javascript({jsx:true})}),
|
||
_cmLanguageDescription(C,'typescript',['ts','tsx'],function(){return C.javascript({typescript:true,jsx:true})}),
|
||
_cmLanguageDescription(C,'html',[],function(){return C.html()}),
|
||
_cmLanguageDescription(C,'css',[],function(){return C.css()}),
|
||
_cmLanguageDescription(C,'json',[],function(){return C.json()}),
|
||
_cmLanguageDescription(C,'sql',[],function(){return C.sql()}),
|
||
_cmLanguageDescription(C,'python',['py'],function(){return C.python()}),
|
||
_cmLanguageDescription(C,'xml',[],function(){return C.xml()}),
|
||
_cmLanguageDescription(C,'go',['golang'],function(){return C.go()}),
|
||
_cmLanguageDescription(C,'c++',['cpp','c'],function(){return C.cpp()}),
|
||
_cmLanguageDescription(C,'yaml',['yml','dockerfile','docker-compose'],function(){return C.yaml()}),
|
||
_cmLanguageDescription(C,'shell',['bash','sh','zsh'],function(){return C.StreamLanguage.define(C.shell)})
|
||
].forEach(function(desc){if(desc)codeLanguages.push(desc)});
|
||
_cmView=new C.EditorView({
|
||
state:C.EditorState.create({
|
||
doc:content||'',
|
||
extensions:[
|
||
C.markdown({base:C.markdownLanguage,codeLanguages:codeLanguages}),
|
||
C.syntaxHighlighting(joplockHighlight),
|
||
C.syntaxHighlighting(C.defaultHighlightStyle,{fallback:true}),
|
||
joplockTheme,
|
||
C.drawSelection(),
|
||
...(_highlightActiveLine?[C.highlightActiveLine()]:[]),
|
||
C.bracketMatching(),
|
||
C.highlightSelectionMatches(),
|
||
C.history(),
|
||
C.keymap.of([{key:'Mod-Space',run:function(){requestManualProseCompletion();return true}},{key:'Ctrl-Space',run:function(){requestManualProseCompletion();return true}},...C.defaultKeymap,...C.historyKeymap,...C.searchKeymap.filter(function(b){var k=b.key||'';return k!=='Mod-f'&&k!=='F3'&&k!=='Mod-g'}),C.indentWithTab]),
|
||
C.placeholder('Start writing...'),
|
||
C.autocompletion({ override: [manualProseCompletionSource,noteCompletionSource] }),
|
||
onUpdate,
|
||
C.EditorView.lineWrapping,
|
||
C.EditorView.domEventHandlers({click:function(e,view){if(!e.ctrlKey&&!e.metaKey)return false;var pos=view.posAtCoords({x:e.clientX,y:e.clientY});if(pos==null)return false;var line=view.state.doc.lineAt(pos);var text=line.text;var offset=pos-line.from;var m;var linkRe=/\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g;while((m=linkRe.exec(text))!==null){if(offset>=m.index&&offset<=m.index+m[0].length){window.open(m[2],'_blank','noopener');return true;}}var urlRe=/https?:\/\/[^\s)>\]]+/g;while((m=urlRe.exec(text))!==null){if(offset>=m.index&&offset<=m.index+m[0].length){window.open(m[0],'_blank','noopener');return true;}}return false;}})
|
||
]
|
||
}),
|
||
parent:host
|
||
});
|
||
if(_cmView.contentDOM){
|
||
// Ring buffer: capture raw input before CM processes it
|
||
_cmView.contentDOM.addEventListener('beforeinput',function(e){
|
||
_ringBufFedFromBeforeinput=false;
|
||
if(!_ringBufAccepts(e.inputType)){
|
||
if(e.inputType&&(e.inputType.indexOf('delete')>=0||e.inputType==='historyUndo'||e.inputType==='historyRedo'))_resetRingBuffer('cm-deletion-or-undo');
|
||
return;
|
||
}
|
||
if(e.data){_feedRingBuffer('cm',e.inputType,e.data);_ringBufFedFromBeforeinput=true}
|
||
});
|
||
// Ring buffer: consume pending trigger after CM has settled.
|
||
// Also feeds buffer as fallback when beforeinput didn't fire (iOS Safari autocorrect pipeline).
|
||
_cmView.contentDOM.addEventListener('input',function(e){
|
||
if(!_ringBufFedFromBeforeinput&&e&&e.data){_feedRingBuffer('cm-input-fallback',e.inputType||'insertText',e.data)}
|
||
_ringBufFedFromBeforeinput=false;
|
||
if(_pendingTextExpansion){consumePendingTextExpansion('cm');return}
|
||
});
|
||
_cmView.contentDOM.addEventListener('blur',function(){_resetRingBuffer('cm-blur')});
|
||
// Drag-and-drop file upload directly into markdown editor (no modal).
|
||
_cmView.contentDOM.addEventListener('dragover',function(e){
|
||
if(e.dataTransfer&&e.dataTransfer.types&&Array.prototype.indexOf.call(e.dataTransfer.types,'Files')>=0){
|
||
e.preventDefault();if(e.dataTransfer){try{e.dataTransfer.dropEffect='copy'}catch(_){}}
|
||
}
|
||
});
|
||
_cmView.contentDOM.addEventListener('drop',function(e){
|
||
var files=e.dataTransfer&&e.dataTransfer.files;
|
||
if(!files||!files.length)return;
|
||
e.preventDefault();e.stopPropagation();
|
||
var arr=Array.prototype.slice.call(files);
|
||
// Insert at drop position if resolvable, else at current selection.
|
||
try{var p=_cmView.posAtCoords({x:e.clientX,y:e.clientY});if(p!=null)_cmView.dispatch({selection:{anchor:p}})}catch(_){}
|
||
arr.reduce(function(pr,file){return pr.then(function(){return _uploadFileToCM(file)})},Promise.resolve()).then(function(){markEdited();scheduleSave()}).catch(function(err){console.error('CM drop upload failed:',err)});
|
||
});
|
||
}
|
||
}
|
||
var _editorMode='markdown';
|
||
function syncEditorModeButtons(){var mode=_editorMode||'rich';var isMd=mode==='markdown'||mode==='md';document.querySelectorAll('#markdown-toggle').forEach(function(btn){btn.classList.toggle('active',isMd)});document.querySelectorAll('#preview-toggle').forEach(function(btn){btn.classList.toggle('active',!isMd)});var mMd=document.getElementById('mobile-md-toggle');var mPv=document.getElementById('mobile-preview-toggle');if(mMd)mMd.classList.toggle('active',isMd);if(mPv)mPv.classList.toggle('active',!isMd);document.body.classList.toggle('mobile-markdown-mode',inMobileEditor()&&isMd);document.body.classList.toggle('editor-markdown-mode',isMd);document.body.classList.toggle('editor-rich-mode',!isMd)}
|
||
function applyEditorModeVisibility(mode,opts){
|
||
opts=opts||{};
|
||
var isMd=mode==='markdown'||mode==='md';
|
||
var ta=getTA();
|
||
var tb=queryActiveEditor('#editor-toolbar');
|
||
var cmHost=queryActiveEditor('#cm-host');
|
||
var slot=queryActiveEditor('#tinymce-slot');
|
||
var pv=queryActiveEditor('#note-preview');
|
||
if(tb&&!opts.preserveToolbarInline)tb.style.display='';
|
||
if(isMd){
|
||
hideTinyMCEHost();
|
||
// The tinymce-slot placeholder also has flex:1; collapse it in markdown mode so it
|
||
// does not steal vertical space from #cm-host (which would truncate the editor).
|
||
if(slot){slot.style.flex='0';slot.style.height='0';slot.style.minHeight='0';}
|
||
var haveCM=!!(cmHost&&window.CM);
|
||
if(cmHost)cmHost.style.display=haveCM?'':'none';
|
||
if(ta){
|
||
// When CM6 is available the textarea is a hidden sync target; otherwise it is the
|
||
// visible fallback editor.
|
||
if(haveCM){
|
||
ta.style.display='none';
|
||
}else{
|
||
ta.style.display='block';
|
||
ta.style.flex='1';
|
||
ta.style.minHeight='0';
|
||
if(opts.focusTextarea!==false)ta.focus();
|
||
}
|
||
}
|
||
if(pv)pv.style.display='none';
|
||
return;
|
||
}
|
||
// Rich mode: restore the slot so it anchors and sizes the TinyMCE host.
|
||
if(slot){slot.style.flex='';slot.style.height='';slot.style.minHeight='';}
|
||
if(ta)ta.style.display='none';
|
||
if(cmHost)cmHost.style.display='none';
|
||
if(pv)pv.style.display='none';
|
||
}
|
||
function activeSearchInput(){if(isMobileShellMode()){var mobileInput=document.getElementById('mobile-editor-search-input');if(mobileInput)return mobileInput}return document.getElementById('nav-search')}
|
||
function currentListSearchInput(){return document.getElementById('nav-search')||document.getElementById('mobile-search-input')}
|
||
function currentListSearchTerm(){var input=currentListSearchInput();return input&&typeof input.value==='string'?input.value:''}
|
||
function activeSearchTerm(){var input=activeSearchInput();return input&&typeof input.value==='string'?input.value:''}
|
||
var _cmSearchMatches=[];
|
||
function clearCodeMirrorSearch(){_cmSearchMatches=[];if(_cmView&&window.CM&&window.CM.SearchQuery&&window.CM.setSearchQuery){_cmView.dispatch({effects:window.CM.setSearchQuery.of(new window.CM.SearchQuery({search:'',caseSensitive:false}))});}}
|
||
function collectCodeMirrorSearchMatches(query){if(!_cmView||!query||!query.valid||!query.search)return[];var cursor=query.getCursor(_cmView.state.doc);var out=[];for(var next=cursor.next();!next.done;next=cursor.next())out.push({from:next.value.from,to:next.value.to});return out}
|
||
function setCodeMirrorSearchActive(idx){if(!_cmView||!_cmSearchMatches.length)return;_searchMarkIdx=((idx%_cmSearchMatches.length)+_cmSearchMatches.length)%_cmSearchMatches.length;var match=_cmSearchMatches[_searchMarkIdx];var Sel=_cmView.state.selection.constructor;_cmView.dispatch({selection:Sel.cursor(match.from),scrollIntoView:true});searchNavShow(_cmSearchMatches.length,_searchMarkIdx)}
|
||
function clearPreviewSearchMarks(root){if(!root)return;root.querySelectorAll('mark.search-highlight').forEach(function(m){var text=document.createTextNode(m.textContent);m.parentNode.replaceChild(text,m)});root.normalize()}
|
||
function applyMobileTitleMode(){var ti=queryActiveEditor('.editor-title');if(!ti)return;var mobile=isMobileShellMode();var inMobileEditor=!!ti.closest('#mobile-editor-body');ti.contentEditable=(mobile&&!inMobileEditor)?'false':'true';ti.classList.toggle('editor-title-mobile-readonly',mobile&&!inMobileEditor)}
|
||
var _pvSyncTimer=null;var _syncPVInFlight=false;
|
||
var _previewDirty=false;
|
||
function syncPV(){var pv=getPV(),ta=getTA();if(pv&&ta){var md=htmlToMarkdown(pv);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));_previewDirty=false;return true}}_previewDirty=false;return false}
|
||
function scheduleSyncPV(){if(_pvSyncTimer)clearTimeout(_pvSyncTimer);_pvSyncTimer=setTimeout(function(){_pvSyncTimer=null;_syncPVInFlight=true;var changed=syncPV();_syncPVInFlight=false;autoTitle();if(!changed){_log('scheduleSyncPV: no markdown change')}},150)}
|
||
// Auto-title: first line of body becomes title unless user manually edited it
|
||
var _titleManual=false;
|
||
var stripMdForTitle=window.joplockStripNoteTitle||function(s){return String(s||'').trim()};
|
||
function syncTitleToHidden(opts){opts=opts||{};var ti=queryActiveEditor('.editor-title');var hi=queryActiveEditor('.editor-title-hidden');var mobileTitle=document.getElementById('mobile-editor-title');if(!hi)return '';var raw=ti?ti.textContent:'';var plain=stripMdForTitle(raw);if(ti&&plain!==raw.trim())ti.textContent=plain;hi.value=plain;if(mobileTitle&&document.activeElement!==mobileTitle&&mobileTitle.textContent!==plain)mobileTitle.textContent=plain||'Note';if(!opts.silent){markEdited();scheduleSaveTitle()}return plain}
|
||
function syncTitle(){syncTitleToHidden()}
|
||
function mobileSyncTitle(){var mobileTitle=document.getElementById('mobile-editor-title');if(!mobileTitle)return;var plain=stripMdForTitle(mobileTitle.textContent);var hi=queryActiveEditor('.editor-title-hidden');var ti=queryActiveEditor('.editor-title');if(hi)hi.value=plain;if(ti)ti.textContent=plain;_titleManual=true;markEdited()}
|
||
function mobileSyncTitleAndSave(){mobileSyncTitle();scheduleSaveTitle()}
|
||
function initAutoTitle(){_titleManual=false;var ti=queryActiveEditor('.editor-title');if(ti&&ti.style.display!=='none'){ti.addEventListener('input',function(){_titleManual=true;syncTitle()})}}
|
||
function _autoTitleCandidate(line){var trimmed=(line||'').trim();if(!trimmed)return '';if(/^!\[[^\]]*\]\([^\)]+\)$/.test(trimmed))return '';if(/^<img\b[^>]*\/?>(?:<\/img>)?$/i.test(trimmed))return '';return stripMdForTitle(trimmed.replace(/^#+\s*/,''));}
|
||
function autoTitle(){if(_titleManual)return;var ta=getTA();var hi=queryActiveEditor('.editor-title-hidden');var ti=queryActiveEditor('.editor-title');var mobileTitle=document.getElementById('mobile-editor-title');if(!ta||!hi)return;var val=ta.value;var lines=val.split('\n');var firstPlain='';for(var i=0;i<lines.length;i++){var candidate=_autoTitleCandidate(lines[i]);if(candidate){firstPlain=candidate;break}}if(firstPlain&&firstPlain!==hi.value){if(ti)ti.textContent=firstPlain;// Don't clobber #mobile-editor-title while user is editing it
|
||
if(mobileTitle&&document.activeElement!==mobileTitle)mobileTitle.textContent=firstPlain;hi.value=firstPlain;hi.dispatchEvent(new Event('input',{bubbles:true}))}}function pad2(value){return String(value).padStart(2,'0')}
|
||
var _dateFmt=_cfg.dateFormat||'MMM-DD-YY';
|
||
var _datetimeFmt=_cfg.datetimeFormat||'YYYY-MM-DD HH:mm';
|
||
function formatStamp(kind){var d=new Date();var months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];var fmt=kind==='datetime'?_datetimeFmt:_dateFmt;var YYYY=String(d.getFullYear());var YY=YYYY.slice(-2);var MM=pad2(d.getMonth()+1);var MMM=months[d.getMonth()];var DD=pad2(d.getDate());var h24=d.getHours();var HH=pad2(h24);var h12=h24%12||12;var hh=pad2(h12);var A=h24<12?'AM':'PM';var mn=pad2(d.getMinutes());var ss=pad2(d.getSeconds());return fmt.replace('YYYY',YYYY).replace('YY',YY).replace('MMM',MMM).replace('MM',MM).replace('DD',DD).replace('HH',HH).replace('hh',hh).replace('mm',mn).replace('ss',ss).replace('A',A).replace('a',A.toLowerCase())}
|
||
function renderInlineMd(t){if(!t)return '';var h=t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');h=h.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');h=h.replace(/\*(.+?)\*/g,'<em>$1</em>');h=h.replace(/~~(.+?)~~/g,'<del>$1</del>');h=h.replace(/\+\+(.+?)\+\+/g,'<u>$1</u>');h=h.replace(/`([^`]+)`/g,'<code spellcheck="false">$1</code>');return h}
|
||
// Image resize via drag handles
|
||
var _resizing=null;
|
||
function initImgResize(pv){if(!pv||pv.dataset.imgResizeInit)return;pv.dataset.imgResizeInit='1';pv.addEventListener('mousedown',function(e){if(e.target.tagName==='IMG'&&e.target.classList.contains('preview-img')){var img=e.target,rect=img.getBoundingClientRect();var nearRight=e.clientX>rect.right-16,nearBottom=e.clientY>rect.bottom-16;if(nearRight||nearBottom){e.preventDefault();_resizing={img:img,startX:e.clientX,startY:e.clientY,startW:img.offsetWidth,startH:img.offsetHeight}}}})}
|
||
document.addEventListener('mousemove',function(e){if(!_resizing)return;e.preventDefault();var dx=e.clientX-_resizing.startX,dy=e.clientY-_resizing.startY;var nw=Math.max(32,_resizing.startW+dx);var ratio=_resizing.startH/_resizing.startW;_resizing.img.style.width=nw+'px';_resizing.img.style.height=Math.round(nw*ratio)+'px'});
|
||
document.addEventListener('mouseup',function(){if(_resizing){_resizing=null;syncPV()}});
|
||
function pvBlockText(block){if(!block)return '';var text=typeof block.innerText==='string'?block.innerText:(block.textContent||'');return text.replace(/\r/g,'')}
|
||
function insertPVText(text){var sel=window.getSelection();if(!sel||!sel.rangeCount)return false;var range=sel.getRangeAt(0);range.deleteContents();var node=document.createTextNode(text);range.insertNode(node);range.setStart(node,text.length);range.collapse(true);sel.removeAllRanges();sel.addRange(range);return true}
|
||
function setPVCaret(node,offset){var sel=window.getSelection();if(!sel)return;var range=document.createRange();if(node&&node.nodeType===3){range.setStart(node,Math.min(offset,node.textContent.length));range.collapse(true)}else{range.selectNodeContents(node);range.collapse(false)}sel.removeAllRanges();sel.addRange(range)}
|
||
function _createPVLink(url,label){var a=document.createElement('a');a.href=url;a.target='_blank';a.rel='noopener';a.textContent=label||url;return a}
|
||
function _replaceTextNodeWithAutoLinks(node){if(!node||node.nodeType!==3||!node.parentNode)return null;var text=node.textContent||'';var urlRe=/https?:\/\/[^\s<]+/g;var match=urlRe.exec(text);if(!match)return null;var frag=document.createDocumentFragment();var last=0;var created=[];while(match){if(match.index>last)frag.appendChild(document.createTextNode(text.slice(last,match.index)));var url=match[0];var link=_createPVLink(url,url);frag.appendChild(link);created.push(link);last=match.index+url.length;match=urlRe.exec(text)}var tailNode=document.createTextNode(text.slice(last));frag.appendChild(tailNode);node.parentNode.replaceChild(frag,node);return {links:created,tailNode:tailNode}}
|
||
function _autoLinkPVSelection(opts){var pv=getPV();var sel=window.getSelection();if(!pv||!sel||!sel.rangeCount||!sel.isCollapsed)return false;var range=sel.getRangeAt(0);var node=range.startContainer;if(!node||node.nodeType!==3||!pv.contains(node))return false;if(node.parentElement&&node.parentElement.closest('a,pre,code'))return false;var text=node.textContent||'';var before=text.slice(0,range.startOffset);var after=text.slice(range.startOffset);var allowEnd=!!(opts&&opts.allowEnd);var trimmedBefore=before;var trailingWs=0;if(/\s$/.test(before)){trimmedBefore=before.replace(/\s+$/,'');trailingWs=before.length-trimmedBefore.length}else if(!(allowEnd&&(!after||/^\s*$/.test(after))))return false;var match=trimmedBefore.match(/https?:\/\/[^\s<]+$/);if(!match)return false;var replaced=_replaceTextNodeWithAutoLinks(node);if(!replaced)return false;setPVCaret(replaced.tailNode,trailingWs);return true}
|
||
function _autoLinkPVBlock(block){if(!block||block.nodeType!==1)return false;var walker=document.createTreeWalker(block,NodeFilter.SHOW_TEXT,{acceptNode:function(n){return n.parentElement&&n.parentElement.closest('a,pre,code,script,style,button')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}},false);var nodes=[];var node;while((node=walker.nextNode()))nodes.push(node);var changed=false;nodes.forEach(function(textNode){if(_replaceTextNodeWithAutoLinks(textNode))changed=true});return changed}
|
||
function replacePVBlock(buildNode){var pv=getPV();if(!pv)return false;var sel=window.getSelection();if(!sel||!sel.rangeCount)return false;var range=sel.getRangeAt(0);if(!pv.contains(range.commonAncestorContainer))return false;var block=range.startContainer;while(block&&block!==pv&&block.nodeType!==1)block=block.parentNode;if(!block||block===pv)block=range.startContainer.parentNode;while(block&&block!==pv&&block.nodeType===1&&!/^(P|DIV|LI|BLOCKQUOTE|PRE|H[1-6])$/.test(block.nodeName))block=block.parentNode;var neo=buildNode(block,sel.toString(),range,pv);if(!neo)return false;if(block&&block.parentNode&&block!==pv){block.parentNode.replaceChild(neo,block)}else{range.deleteContents();range.insertNode(neo)}var focusNode=neo.querySelector?neo.querySelector('code'):null;if(!focusNode)focusNode=neo;var textNode=focusNode.firstChild&&focusNode.firstChild.nodeType===3?focusNode.firstChild:null;setPVCaret(textNode||focusNode,textNode?textNode.textContent.length:0);syncPV();pv.focus();return true}
|
||
function transformPVBlock(tagName,defaultText){return replacePVBlock(function(block,selectedText,range,pv){var text=(!range.collapsed&&selectedText?selectedText:(block&&block!==pv?pvBlockText(block):selectedText))||defaultText;var neo=document.createElement(tagName);if(tagName==='pre'){neo.spellcheck=false;var code=document.createElement('code');code.textContent=text;neo.appendChild(code)}else{neo.textContent=text}return neo})}
|
||
function clearFormat(){var pv=getPV();if(pv){document.execCommand('removeFormat',false,null);var sel=window.getSelection();if(sel&&sel.rangeCount){var range=sel.getRangeAt(0);var block=range.startContainer;while(block&&block!==pv&&block.nodeType!==1)block=block.parentNode;if(block&&block!==pv&&/^(H[1-6]|BLOCKQUOTE|PRE)$/.test(block.nodeName)){var p=document.createElement('p');p.textContent=block.textContent;block.parentNode.replaceChild(p,block);var r=document.createRange();r.selectNodeContents(p);sel.removeAllRanges();sel.addRange(r)}}syncPV();pv.focus();return}var cm=getCM();if(cm){var s=cm.state.selection.main;var from=s.from,to=s.to,sel=cm.state.sliceDoc(from,to);sel=sel.replace(/(\*{1,2}|~~|\+\+|`)(.*?)\1/g,'$2');sel=sel.replace(/^#{1,6}\s+/gm,'');sel=sel.replace(/^>\s?/gm,'');sel=sel.replace(/^[-*]\s/gm,'');sel=sel.replace(/^\d+\.\s/gm,'');cm.dispatch({changes:{from:from,to:to,insert:sel},selection:{anchor:from,head:from+sel.length}});cm.focus()}}
|
||
function wrapSel(a,b){var pv=getPV();if(pv){var fenced=String.fromCharCode(10)+String.fromCharCode(96,96,96)+String.fromCharCode(10);var inlineCode=String.fromCharCode(96);if(a===fenced&&b===fenced&&transformPVBlock('pre','code'))return;if(a===inlineCode&&b===inlineCode){document.execCommand('insertHTML',false,'<code spellcheck="false">'+(window.getSelection().toString()||'code')+'</code>');syncPV();pv.focus();return}var cmdMap={'**':'bold','*':'italic','~~':'strikethrough','++':'underline'};var cmd=cmdMap[a];if(cmd){document.execCommand(cmd,false,null);syncPV();pv.focus();return}}var cm=getCM();if(cm){var s=cm.state.selection.main;var from=s.from,to=s.to,sel=cm.state.sliceDoc(from,to)||'text';var ins=a+sel+b;cm.dispatch({changes:{from:from,to:to,insert:ins},selection:{anchor:from+a.length,head:from+a.length+sel.length}});cm.focus()}}
|
||
function insertPfx(p){var pv=getPV();if(pv){var sel=window.getSelection();if(sel.rangeCount){var range=sel.getRangeAt(0);var block=range.startContainer;while(block&&block!==pv&&block.nodeType!==1)block=block.parentNode;if(!block||block===pv)block=range.startContainer.parentNode;var hm=p.match(/^(#{1,6})\s/);if(hm){var lvl=hm[1].length;var tag='h'+lvl;if(block&&block.parentNode&&block!==pv){var neo=document.createElement(tag);neo.textContent=block.textContent;block.parentNode.replaceChild(neo,block)}else{document.execCommand('insertHTML',false,'<'+tag+'>'+(sel.toString()||'Heading')+'</'+tag+'>')}setTimeout(function(){syncPV();pv.focus()},0);return}if(p==='- [ ] '){var neo=document.createElement('div');neo.className='md-checkbox';var iconSpan=document.createElement('span');iconSpan.className='md-cb-icon';iconSpan.textContent='\u2610';neo.appendChild(iconSpan);var nbsp=document.createTextNode('\u00a0');neo.appendChild(nbsp);var sel2=window.getSelection();var range2=sel2.rangeCount?sel2.getRangeAt(0):null;if(range2){range2.deleteContents();range2.insertNode(neo);var r=document.createRange();r.setStart(nbsp,1);r.collapse(true);sel2.removeAllRanges();sel2.addRange(r)}else{pv.appendChild(neo)}neo.scrollIntoView({block:'nearest'});syncPV();pv.focus();return}if(p==='- '){document.execCommand('insertUnorderedList',false,null);syncPV();pv.focus();return}if(p==='1. '){document.execCommand('insertOrderedList',false,null);syncPV();pv.focus();return}if(p==='> '&&transformPVBlock('blockquote','Quote'))return}return}var cm=getCM();if(cm){var s=cm.state.selection.main;var line=cm.state.doc.lineAt(s.from);cm.dispatch({changes:{from:line.from,to:line.from,insert:p}});cm.focus()}}
|
||
function insertTxt(x){var pv=getPV();if(pv){if(x==='\n---\n'){document.execCommand('insertHorizontalRule',false,null);syncPV();pv.focus();return}document.execCommand('insertText',false,x);syncPV();pv.focus();return}var cm=getCM();if(cm){var s=cm.state.selection.main;cm.dispatch({changes:{from:s.from,to:s.to,insert:x},selection:{anchor:s.from+x.length}});cm.focus()}}
|
||
function insertStamp(kind){insertTxt(formatStamp(kind))}
|
||
var _linkSavedRange=null;var _linkSavedTA=null;
|
||
function closeLinkModal(){var modal=document.getElementById('link-modal');var backdrop=document.getElementById('link-modal-backdrop');if(modal)modal.hidden=true;if(backdrop)backdrop.hidden=true}
|
||
function openLinkModal(){var pv=getPV();var cm=getCM();if(pv){var sel=window.getSelection();_linkSavedRange=sel&&sel.rangeCount?sel.getRangeAt(0).cloneRange():null;var labelInput=document.getElementById('link-edit-label');if(labelInput)labelInput.value=(sel&&sel.toString())||''}else if(cm){var s=cm.state.selection.main;var labelInput=document.getElementById('link-edit-label');if(labelInput)labelInput.value=cm.state.sliceDoc(s.from,s.to)}var modal=document.getElementById('link-modal');var backdrop=document.getElementById('link-modal-backdrop');var urlInput=document.getElementById('link-edit-url');if(urlInput)urlInput.value='';if(modal)modal.hidden=false;if(backdrop)backdrop.hidden=false;if(urlInput)urlInput.focus()}
|
||
function submitLink(event){if(event)event.preventDefault();var url=document.getElementById('link-edit-url');var label=document.getElementById('link-edit-label');var u=(url?url.value:'').trim();if(!u)return false;var t=(label?label.value:'').trim()||u;closeLinkModal();var pv=getPV();if(pv){if(_linkSavedRange){var sel=window.getSelection();sel.removeAllRanges();sel.addRange(_linkSavedRange)}_linkSavedRange=null;var sel=window.getSelection();var range=sel&&sel.rangeCount?sel.getRangeAt(0):null;var link=_createPVLink(u,t);if(range){range.deleteContents();range.insertNode(link);range.setStartAfter(link);range.collapse(true);sel.removeAllRanges();sel.addRange(range)}syncPV();pv.focus();return false}var cm=getCM();if(cm){var md='['+t+']('+u+')';var s=cm.state.selection.main;cm.dispatch({changes:{from:s.from,to:s.to,insert:md},selection:{anchor:s.from+md.length}});cm.focus()}return false}
|
||
function insertLink(){openLinkModal()}
|
||
var _codeSavedSel=null;
|
||
var _codeSavedRange=null;
|
||
var _codeEditPre=null;
|
||
var _codeModalCM=null;
|
||
var _codeTinyMCE=false;
|
||
var _codeTinyMCEBookmark=null;
|
||
var _codeLangMap={'javascript':function(C){return C.javascript({jsx:true})},'typescript':function(C){return C.javascript({typescript:true,jsx:true})},'html':function(C){return C.html()},'css':function(C){return C.css()},'json':function(C){return C.json()},'sql':function(C){return C.sql()},'python':function(C){return C.python()},'xml':function(C){return C.xml()},'go':function(C){return C.go()},'c':function(C){return C.cpp()},'cpp':function(C){return C.cpp()},'yaml':function(C){return C.yaml()},'bash':function(C){return C.StreamLanguage.define(C.shell)}};
|
||
function _codeModalLangExt(lang){var C=window.CM;var fn=_codeLangMap[lang];return fn?fn(C):[]}
|
||
function _initCodeModalCM(host,content,lang){if(_codeModalCM){_codeModalCM.destroy();_codeModalCM=null}var C=window.CM;var codeModalTheme=C.EditorView.theme({'&':{height:'100%',fontSize:'13px',color:'var(--text)'},'.cm-scroller':{overflow:'auto',fontFamily:'"Cascadia Mono",monospace',lineHeight:'1.5'},'.cm-content':{padding:'12px',caretColor:'var(--accent)'},'.cm-gutters':{display:'none'},'.cm-selectionBackground':{backgroundColor:'color-mix(in srgb, var(--accent) 25%, transparent)!important'},'&.cm-focused .cm-selectionBackground':{backgroundColor:'color-mix(in srgb, var(--accent) 30%, transparent)!important'},'.cm-cursor':{borderLeftColor:'var(--accent)'}});var codeModalHighlight=C.HighlightStyle.define([{tag:C.tags.keyword,color:'#c678dd'},{tag:[C.tags.string,C.tags.special(C.tags.brace)],color:'#98c379'},{tag:C.tags.number,color:'#d19a66'},{tag:C.tags.bool,color:'#d19a66'},{tag:[C.tags.definition(C.tags.variableName),C.tags.function(C.tags.variableName)],color:'#61afef'},{tag:C.tags.typeName,color:'#e5c07b'},{tag:C.tags.comment,color:'#5c6370',fontStyle:'italic'},{tag:C.tags.operator,color:'#56b6c2'},{tag:C.tags.className,color:'#e5c07b'},{tag:C.tags.propertyName,color:'#e06c75'},{tag:C.tags.attributeName,color:'#d19a66'},{tag:C.tags.attributeValue,color:'#98c379'},{tag:C.tags.meta,color:'var(--text-dim)'},{tag:C.tags.processingInstruction,fontFamily:'"Cascadia Mono",monospace',color:'var(--accent)'},{tag:C.tags.monospace,fontFamily:'"Cascadia Mono",monospace'}]);_codeModalCM=new C.EditorView({state:C.EditorState.create({doc:content||'',extensions:[_codeModalLangExt(lang),C.syntaxHighlighting(codeModalHighlight),C.syntaxHighlighting(C.defaultHighlightStyle,{fallback:true}),codeModalTheme,C.drawSelection(),C.bracketMatching(),C.history(),C.keymap.of([...C.defaultKeymap,...C.historyKeymap,C.indentWithTab]),C.placeholder('Paste or type code here...'),C.EditorView.lineWrapping]}),parent:host});_codeModalCM.focus()}
|
||
function _updateCodeModalLang(lang){if(!_codeModalCM)return;var C=window.CM;var doc=_codeModalCM.state.doc.toString();var host=_codeModalCM.dom.parentElement;_codeModalCM.destroy();_initCodeModalCM(host,doc,lang)}
|
||
function closeCodeModal(){if(_codeModalCM){_codeModalCM.destroy();_codeModalCM=null}var modal=document.getElementById('code-modal');if(modal)modal.hidden=true}
|
||
function openCodeModal(editPre){var pv=getPV();var cm=getCM();var tmce=(!_isMarkdownModeActive()&&getTinyMCE())?getTinyMCE():null;var sel='';var lang='';_codeSavedSel=null;_codeSavedRange=null;_codeEditPre=editPre||null;_codeTinyMCE=!!tmce;_codeTinyMCEBookmark=null;if(_codeEditPre){var codeEl=_codeEditPre.querySelector('code');var readEl=codeEl||_codeEditPre;var _clone=readEl.cloneNode(true);var _cb=_clone.querySelector&&_clone.querySelector('.pre-copy-btn');if(_cb&&_cb.parentNode)_cb.parentNode.removeChild(_cb);sel=_clone.textContent||'';var langSrc=(codeEl&&(codeEl.className||'').indexOf('language-')!==-1)?codeEl:_codeEditPre;var classes=((langSrc.getAttribute&&langSrc.getAttribute('class'))||'').split(' ');for(var i=0;i<classes.length;i++){if(classes[i].indexOf('language-')===0){lang=classes[i].slice(9);break}}}else if(tmce){try{_codeTinyMCEBookmark=tmce.selection&&tmce.selection.getBookmark?tmce.selection.getBookmark(2,true):null}catch(_e){_codeTinyMCEBookmark=null}sel=(tmce.selection&&tmce.selection.getContent)?(tmce.selection.getContent({format:'text'})||''):''}else if(pv){var s=window.getSelection();_codeSavedRange=s&&s.rangeCount?s.getRangeAt(0).cloneRange():null;sel=(s&&s.toString())||''}else if(cm){var s=cm.state.selection.main;_codeSavedSel={from:s.from,to:s.to};sel=cm.state.sliceDoc(s.from,s.to)}var langEl=document.getElementById('code-lang');if(langEl){langEl.value=lang;langEl.onchange=function(){_updateCodeModalLang(langEl.value)}}var title=document.getElementById('code-modal-title');if(title)title.textContent=_codeEditPre?'Edit code block':'Insert code block';var submitBtn=document.getElementById('code-modal-submit');if(submitBtn)submitBtn.textContent=_codeEditPre?'Save':'Insert';var modal=document.getElementById('code-modal');if(modal)modal.hidden=false;var host=document.getElementById('code-input');if(host){host.innerHTML='';_initCodeModalCM(host,sel,lang)}}
|
||
function submitCode(event){if(event)event.preventDefault();var lang=document.getElementById('code-lang');var l=(lang?lang.value:'');var code=_codeModalCM?_codeModalCM.state.doc.toString():'';var wasTinyMCE=_codeTinyMCE;var bookmark=_codeTinyMCEBookmark;closeCodeModal();if(wasTinyMCE){var ed=getTinyMCE();if(ed){var escapedT=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');var preHTML='<pre class="language-'+(l||'')+'">'+escapedT+'</pre>';if(_codeEditPre){try{ed.selection.select(_codeEditPre)}catch(_e){}}else{try{if(bookmark&&ed.selection&&ed.selection.moveToBookmark)ed.selection.moveToBookmark(bookmark)}catch(_e2){}}ed.focus();ed.insertContent(preHTML);ed.focus();var _finishTinyMCECode=function(){ensureTinyMCEEditableAfterPre(ed);initTinyMCECodeCopyButtons(ed);tinyMCESyncToTA()};_finishTinyMCECode();setTimeout(_finishTinyMCECode,0);_codeSavedSel=null;_codeSavedRange=null;_codeEditPre=null;_codeTinyMCE=false;_codeTinyMCEBookmark=null;return false}}var pv=getPV();if(pv&&_codeEditPre){var codeEl=_codeEditPre.querySelector('code');if(!codeEl){codeEl=document.createElement('code');_codeEditPre.appendChild(codeEl)}codeEl.textContent=code;codeEl.className=l?'language-'+l:'';if(codeEl.dataset.highlighted)delete codeEl.dataset.highlighted;_codeEditPre=null;initCopyButtons(pv);highlightCodeBlocks(pv);ensureEditableAfterPre(pv);syncPV();pv.focus();return false}if(pv){if(_codeSavedRange){var sel=window.getSelection();sel.removeAllRanges();sel.addRange(_codeSavedRange)}_codeSavedRange=null;var escaped=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');var cls=l?' class="language-'+l+'"':''; document.execCommand('insertHTML',false,'<pre'+cls+'><code>'+escaped+'</code></pre>');initCopyButtons(pv);highlightCodeBlocks(pv);ensureEditableAfterPre(pv);syncPV();pv.focus();return false}var cm=getCM();if(cm){var s=_codeSavedSel||cm.state.selection.main;var md='\n```'+l+'\n'+code+'\n```\n';cm.dispatch({changes:{from:s.from,to:s.to,insert:md},selection:{anchor:s.from+md.length}});cm.focus()}_codeSavedSel=null;_codeSavedRange=null;_codeEditPre=null;_codeTinyMCE=false;_codeTinyMCEBookmark=null;return false}
|
||
function insertImg(){var pv=getPV();if(pv){var u=prompt('Image URL:');if(!u)return;document.execCommand('insertHTML',false,'<img src="'+u+'" alt="image" class="preview-img" />');syncPV();pv.focus();return}var u=prompt('Image URL:');if(u)insertTxt('')}
|
||
var _uploadInsertTarget=null;
|
||
function _captureUploadInsertTarget(){var pv=getPV();if(pv){var sel=window.getSelection();var range=sel&&sel.rangeCount?sel.getRangeAt(0):null;if(range&&pv.contains(range.commonAncestorContainer))return {mode:'preview',range:range.cloneRange()};var fallback=document.createRange();fallback.selectNodeContents(pv);fallback.collapse(false);return {mode:'preview',range:fallback}}var cm=getCM();if(cm&&isMarkdownVisible()){var s=cm.state.selection.main;return {mode:'cm',from:s.from,to:s.to}}var ta=getTA();if(!ta)return null;var current=ta.value||'';var start=typeof ta.selectionStart==='number'?ta.selectionStart:current.length;var end=typeof ta.selectionEnd==='number'?ta.selectionEnd:start;return {mode:'textarea',start:start,end:end}}
|
||
function _normalizeUploadInsert(markdown){return (markdown||'').trim()}
|
||
function _buildMarkdownInsert(current,start,end,markdown){var insert=_normalizeUploadInsert(markdown);if(!insert)return null;start=Math.max(0,Math.min(typeof start==='number'?start:current.length,current.length));end=Math.max(start,Math.min(typeof end==='number'?end:start,current.length));var before=current.slice(0,start);var after=current.slice(end);var prefix='';var suffix='';if(before&&before.charAt(before.length-1)!=='\n')prefix='\n';if(after&&after.charAt(0)!=='\n')suffix='\n';return {from:start,to:end,insert:prefix+insert+suffix,caret:start+prefix.length+insert.length}}
|
||
function _setUploadInsertTargetFromTextarea(start,end){_uploadInsertTarget={mode:'textarea',start:start,to:end,end:end}}
|
||
function _insertMarkdownAtTextareaTarget(markdown,target){var ta=getTA();if(!ta)return false;var current=ta.value||'';var insertOp=_buildMarkdownInsert(current,target&&target.start,target&&target.end,markdown);if(!insertOp)return false;var next=current.slice(0,insertOp.from)+insertOp.insert+current.slice(insertOp.to);if(next===current)return false;ta.value=next;ta.selectionStart=ta.selectionEnd=insertOp.caret;ta.dispatchEvent(new Event('input',{bubbles:true}));_setUploadInsertTargetFromTextarea(insertOp.caret,insertOp.caret);return true}
|
||
function _insertMarkdownAtCodeMirrorTarget(markdown,target){var cm=getCM();if(!cm)return false;var current=cm.state.doc.toString();var insertOp=_buildMarkdownInsert(current,target&&target.from,target&&target.to,markdown);if(!insertOp)return false;cm.dispatch({changes:{from:insertOp.from,to:insertOp.to,insert:insertOp.insert},selection:{anchor:insertOp.caret}});cm.focus();_uploadInsertTarget={mode:'cm',from:insertOp.caret,to:insertOp.caret};return true}
|
||
function _insertMarkdownAtPreviewTarget(markdown,target){var pv=getPV();if(!pv)return false;var insert=_normalizeUploadInsert(markdown);if(!insert)return false;var sel=window.getSelection();var range=target&&target.range?target.range.cloneRange():null;if(range&&pv.contains(range.commonAncestorContainer)){sel.removeAllRanges();sel.addRange(range)}else{range=document.createRange();range.selectNodeContents(pv);range.collapse(false);sel.removeAllRanges();sel.addRange(range)}if(!insertPVText(insert))return false;syncPV();var ta=getTA();var current=ta?ta.value||'':'';var idx=current.lastIndexOf(insert);if(idx>=0&&ta){var caret=idx+insert.length;ta.selectionStart=ta.selectionEnd=caret;_setUploadInsertTargetFromTextarea(caret,caret)}return true}
|
||
function _insertUploadedMarkdown(markdown){if(_uploadInsertTarget&&_uploadInsertTarget.mode==='preview'&&getPV())return _insertMarkdownAtPreviewTarget(markdown,_uploadInsertTarget);if(_uploadInsertTarget&&_uploadInsertTarget.mode==='cm'&&getCM()&&isMarkdownVisible())return _insertMarkdownAtCodeMirrorTarget(markdown,_uploadInsertTarget);if(_uploadInsertTarget&&_uploadInsertTarget.mode==='textarea')return _insertMarkdownAtTextareaTarget(markdown,_uploadInsertTarget);_uploadInsertTarget=_captureUploadInsertTarget();if(_uploadInsertTarget&&_uploadInsertTarget.mode==='preview'&&getPV())return _insertMarkdownAtPreviewTarget(markdown,_uploadInsertTarget);if(_uploadInsertTarget&&_uploadInsertTarget.mode==='cm'&&getCM()&&isMarkdownVisible())return _insertMarkdownAtCodeMirrorTarget(markdown,_uploadInsertTarget);if(_uploadInsertTarget&&_uploadInsertTarget.mode==='textarea')return _insertMarkdownAtTextareaTarget(markdown,_uploadInsertTarget);return false}
|
||
function openFilePicker(){_uploadInsertTarget=_captureUploadInsertTarget();var input=document.getElementById('file-upload');if(input)input.click()}
|
||
function handleFilePicker(input){
|
||
if(!input||!input.files||!input.files.length)return;
|
||
var files=Array.prototype.slice.call(input.files);
|
||
input.value='';
|
||
uploadFiles(files).catch(function(){});
|
||
}
|
||
var _uploadBatchDepth=0;
|
||
var _uploadBatchChanged=false;
|
||
function uploadFiles(files){
|
||
if(!files||!files.length)return Promise.resolve();
|
||
_uploadBatchDepth++;
|
||
var queue=Promise.resolve();
|
||
for(var i=0;i<files.length;i++){
|
||
(function(file){queue=queue.then(function(){return uploadFile(file)})})(files[i]);
|
||
}
|
||
return queue.finally(function(){
|
||
_uploadBatchDepth=Math.max(0,_uploadBatchDepth-1);
|
||
if(_uploadBatchDepth===0&&_uploadBatchChanged){
|
||
_uploadBatchChanged=false;
|
||
scheduleSave();
|
||
}
|
||
if(_uploadBatchDepth===0)_uploadInsertTarget=null;
|
||
});
|
||
}
|
||
function _appendMarkdownAtCursor(markdown){
|
||
var ta=getTA();
|
||
if(!ta)return false;
|
||
var current=ta.value||'';
|
||
var insertOp=_buildMarkdownInsert(current,typeof ta.selectionStart==='number'?ta.selectionStart:current.length,typeof ta.selectionEnd==='number'?ta.selectionEnd:(typeof ta.selectionStart==='number'?ta.selectionStart:current.length),markdown);
|
||
if(!insertOp)return false;
|
||
var next=current.slice(0,insertOp.from)+insertOp.insert+current.slice(insertOp.to);
|
||
var caret=insertOp.caret;
|
||
if(next!==current){
|
||
ta.value=next;
|
||
ta.selectionStart=ta.selectionEnd=caret;
|
||
ta.dispatchEvent(new Event('input',{bubbles:true}));
|
||
}
|
||
return true;
|
||
}
|
||
function _refreshPreviewFromMarkdown(resolve){
|
||
var ta=getTA();
|
||
var pv=getPV();
|
||
if(ta&&pv){
|
||
fetch('/fragments/preview',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'body='+encodeURIComponent(ta.value)}).then(function(r){return r.text()}).then(function(h){pv.innerHTML=h;activatePV(pv);resolve();}).catch(function(){resolve();});
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
function uploadFile(f){
|
||
if(!f)return;
|
||
if((!f.name||f.name==='')&&f.type){
|
||
var ext=f.type.split('/')[1]||'bin';
|
||
f=new File([f],'pasted-image-'+Date.now()+'.'+ext,{type:f.type});
|
||
}
|
||
var s=document.getElementById('autosave-status');
|
||
var fd=new FormData();
|
||
fd.append('file',f);
|
||
var xhr=new XMLHttpRequest();
|
||
xhr.upload.onprogress=function(e){
|
||
if(e.lengthComputable){
|
||
var pct=Math.round(e.loaded/e.total*100);
|
||
setSaveState('<span class="autosave-saving">Uploading '+pct+'%</span>','Uploading');
|
||
}
|
||
};
|
||
var done=false;
|
||
return new Promise(function(resolve,reject){
|
||
xhr.onload=function(){
|
||
_log('uploadFile onload status',xhr.status,xhr.responseText.slice(0,120));
|
||
setSaveState('','');
|
||
var d;
|
||
try{d=JSON.parse(xhr.responseText)}catch(e){alert('Upload failed');reject(e);return}
|
||
if(d.error){alert(d.error);reject(new Error(d.error));return}
|
||
var changed=_insertUploadedMarkdown(d.markdown);
|
||
if(changed){markEdited();if(_uploadBatchDepth>0)_uploadBatchChanged=true;else scheduleSave();}
|
||
if(_refreshPreviewFromMarkdown(resolve))return;
|
||
resolve();
|
||
};
|
||
xhr.onerror=function(){setSaveState('','');alert('Upload failed');reject(new Error('Upload failed'))};
|
||
if(s)setSaveState('<span class="autosave-saving">Uploading 0%</span>','Uploading');
|
||
xhr.open('POST','/fragments/upload');
|
||
xhr.send(fd);
|
||
});
|
||
}
|
||
// --- history modal ---
|
||
var _historyNoteId=null;var _historySnapshotId=null;
|
||
function openHistoryModal(noteId){_historyNoteId=noteId;_historySnapshotId=null;var modal=document.getElementById('history-modal');var backdrop=document.getElementById('history-modal-backdrop');var inner=document.getElementById('history-modal-inner');if(!modal||!backdrop||!inner)return;inner.innerHTML='<div class="history-loading">Loading...</div>';if(modal)modal.hidden=false;if(backdrop)backdrop.hidden=false;htmx.ajax('GET','/fragments/history/'+encodeURIComponent(noteId),{target:'#history-modal-inner',swap:'innerHTML'})}
|
||
function closeHistoryModal(){var modal=document.getElementById('history-modal');var backdrop=document.getElementById('history-modal-backdrop');if(modal)modal.hidden=true;if(backdrop)backdrop.hidden=true}
|
||
function selectHistorySnapshot(id){_historySnapshotId=id;document.querySelectorAll('.history-item').forEach(function(el){el.classList.toggle('history-item-active',el.dataset.snapshotId===id)});var label=document.getElementById('history-selected-label');var preview=document.getElementById('history-preview');if(preview)preview.innerHTML='<div class="history-loading">Loading...</div>';if(label)label.textContent='Loading...';htmx.ajax('GET','/fragments/history-snapshot/'+encodeURIComponent(id),{target:'#history-preview',swap:'innerHTML'}).then(function(){var d=new Date(parseInt(id)*1||0);var label=document.getElementById('history-selected-label');if(label)label.textContent=''});_log('selectHistorySnapshot',id)}
|
||
function restoreHistorySnapshot(noteId){var sid=_historySnapshotId;if(!sid){alert('Select a snapshot first.');return}if(!confirm('Restore this version? The current note will be overwritten.'))return;var form=activeEditorForm();var cfi=(form&&form.querySelector('[name="currentFolderId"]'))?form.querySelector('[name="currentFolderId"]').value:'';closeHistoryModal();_log('restoreHistorySnapshot',noteId,sid);htmx.ajax('POST','/fragments/history/'+encodeURIComponent(noteId)+'/restore/'+encodeURIComponent(sid),{target:'#autosave-status',swap:'innerHTML',values:{currentFolderId:cfi}}).then(function(){var s=queryActiveEditor('#autosave-status');if(s&&!s.querySelector('.autosave-error'))s.innerHTML='<span class="autosave-ok">Restored</span>';_snapshots=[];_log('restore done')}).catch(function(e){alert('Restore failed: '+e.message)})}
|
||
// --- client ring buffer (in-session undo) ---
|
||
var _snapshots=[];var _snapshotMaxCount=20;var _undoTimer=null;
|
||
function pushSnapshot(){var ta=getTA();var title=queryActiveEditor('[name="title"]');var body=ta?ta.value:'';var t=title?title.value:'';if(_snapshots.length>0&&_snapshots[_snapshots.length-1].body===body&&_snapshots[_snapshots.length-1].title===t)return;_snapshots.push({body:body,title:t,ts:Date.now()});if(_snapshots.length>_snapshotMaxCount)_snapshots.shift();var btn=queryActiveEditor('#undo-save-btn');if(btn)btn.hidden=_snapshots.length<2;_log('pushSnapshot count',_snapshots.length)}
|
||
function undoSnapshot(){if(_snapshots.length<2){_log('undoSnapshot: nothing to undo');return}if(_undoTimer){clearTimeout(_undoTimer);_undoTimer=null}_snapshots.pop();var snap=_snapshots[_snapshots.length-1];var btn=queryActiveEditor('#undo-save-btn');if(btn)btn.hidden=_snapshots.length<2;_log('undoSnapshot restoring ts',snap.ts);var ta=getTA();var titleInput=queryActiveEditor('[name="title"]');var titleDiv=queryActiveEditor('.editor-title');var pv=getPV();if(ta)ta.value=snap.body;if(titleInput)titleInput.value=snap.title;if(titleDiv)titleDiv.textContent=snap.title;var cm=getCM();if(cm&&!pv)cmSetVal(snap.body);if(pv&&pv.style.display!=='none'){fetch('/fragments/preview',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'body='+encodeURIComponent(snap.body)}).then(function(r){return r.text()}).then(function(h){pv.innerHTML=h;syncPV()}).catch(function(){})}if(ta)ta.dispatchEvent(new Event('input',{bubbles:true}));scheduleSave();var s=queryActiveEditor('#autosave-status');if(s){s.innerHTML='<span class="autosave-edited">Undone</span>';clearTimeout(_undoTimer);_undoTimer=setTimeout(function(){var s2=queryActiveEditor('#autosave-status');if(s2&&s2.querySelector('.autosave-edited'))s2.innerHTML='<span class="autosave-ok">Saved</span>'},3000)}}
|
||
function handleDrop(e){e.preventDefault();var files=e.dataTransfer&&e.dataTransfer.files;if(!files||!files.length)return;uploadFiles(files).catch(function(){})}
|
||
function _escapeHtmlAttr(s){return(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>')}
|
||
// Keep in sync with appSettings.maxUploadMb (admin) and Joplin's own 200MB cap.
|
||
function _maxUploadBytes(){var mb=(window._joplockConfig&&window._joplockConfig.maxUploadMb)||200;return Math.min(mb,200)*1024*1024}
|
||
function _fileTooLarge(file){return!!(file&&typeof file.size==='number'&&file.size>_maxUploadBytes())}
|
||
function _tooLargeMsg(file){var mb=Math.round((file&&file.size||0)/1048576);var lim=Math.round(_maxUploadBytes()/1048576);return'"'+((file&&file.name)||'file')+'" is too large ('+mb+'MB). Maximum upload size is '+lim+'MB.'}
|
||
function _uploadFileToTinyMCE(file,editor){
|
||
if(!file)return Promise.resolve();
|
||
if(_fileTooLarge(file)){alert(_tooLargeMsg(file));return Promise.reject(new Error('File too large'))}
|
||
// Rename unnamed blobs
|
||
if((!file.name||file.name==='')&&file.type){
|
||
var ext=file.type.split('/')[1]||'bin';
|
||
file=new File([file],'dropped-'+Date.now()+'.'+ext,{type:file.type});
|
||
}
|
||
var fd=new FormData();
|
||
fd.append('file',file);
|
||
return fetch('/fragments/upload',{method:'POST',body:fd,credentials:'same-origin'})
|
||
.then(function(r){return r.json()})
|
||
.then(function(data){
|
||
var id=data.resourceId;
|
||
var name=_escapeHtmlAttr(file.name||'file');
|
||
if(file.type&&file.type.startsWith('image/')){
|
||
// Insert the image on its own line/paragraph and ALWAYS follow it
|
||
// with a blank paragraph, so the caret lands on a fresh empty line
|
||
// and typed text is never glued to the image. The trailing empty
|
||
// <p> serialises to a blank markdown line via the emptyP rule.
|
||
editor.insertContent('<p><img src="/resources/'+id+'" data-resource-id="'+id+'" alt="'+name+'" /></p><p></p>');
|
||
}else{
|
||
editor.insertContent('<a href="/resources/'+id+'" data-resource-id="'+id+'">'+name+'</a>');
|
||
}
|
||
// Sync textarea immediately so the source-of-truth (note-body) has
|
||
// the resource reference even if onEdit fires asynchronously.
|
||
var ta=getTA();
|
||
if(ta){
|
||
var md=tinymceToMarkdown(editor.getContent());
|
||
if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));}
|
||
}
|
||
});
|
||
}
|
||
// Upload a file and insert its markdown reference at the CodeMirror cursor.
|
||
function _uploadFileToCM(file){
|
||
if(!file||!_cmView)return Promise.resolve();
|
||
if(_fileTooLarge(file)){alert(_tooLargeMsg(file));return Promise.reject(new Error('File too large'))}
|
||
if((!file.name||file.name==='')&&file.type){
|
||
var ext=file.type.split('/')[1]||'bin';
|
||
file=new File([file],'dropped-'+Date.now()+'.'+ext,{type:file.type});
|
||
}
|
||
var fd=new FormData();
|
||
fd.append('file',file);
|
||
return fetch('/fragments/upload',{method:'POST',body:fd,credentials:'same-origin'})
|
||
.then(function(r){return r.json()})
|
||
.then(function(data){
|
||
if(data&&data.error){alert(data.error);throw new Error(data.error)}
|
||
var isImage=!!(file.type&&file.type.indexOf('image/')===0);
|
||
var md=data.markdown||((isImage?'!':'')+'['+(file.name||'file')+'](:/'+data.resourceId+')');
|
||
// Always add a blank line after an inserted image so the caret lands on
|
||
// a fresh empty line and typed text is never glued to the image.
|
||
if(isImage)md=md+'\n\n';
|
||
if(_cmView){
|
||
var pos=_cmView.state.selection.main.head;
|
||
_cmView.dispatch({changes:{from:pos,insert:md},selection:{anchor:pos+md.length}});
|
||
cmSyncToTA();
|
||
}
|
||
});
|
||
}
|
||
// --- upload modal ---
|
||
var _uploadModalFiles=[];
|
||
var _uploadModalInsertRng=null;
|
||
function openUploadModal(){
|
||
// Capture editor insert position (TinyMCE only)
|
||
_uploadModalInsertRng=null;
|
||
if(_tinymceEditor){
|
||
try{var rng=_tinymceEditor.selection.getRng();if(rng)_uploadModalInsertRng=rng.cloneRange()}catch(e){}
|
||
}
|
||
_uploadModalFiles=[];
|
||
var list=document.getElementById('upload-file-list');if(list)list.innerHTML='';
|
||
var btn=document.getElementById('upload-insert-btn');if(btn)btn.disabled=true;
|
||
var backdrop=document.getElementById('upload-modal-backdrop');if(backdrop)backdrop.hidden=false;
|
||
var modal=document.getElementById('upload-modal');if(modal)modal.hidden=false;
|
||
// Wire drop zone events (once)
|
||
var dz=document.getElementById('upload-drop-zone');
|
||
if(dz&&!dz._dropWired){
|
||
dz._dropWired=true;
|
||
dz.addEventListener('dragover',function(e){e.preventDefault();e.stopPropagation();dz.classList.add('drag-over')});
|
||
dz.addEventListener('dragleave',function(e){dz.classList.remove('drag-over')});
|
||
dz.addEventListener('drop',function(e){
|
||
e.preventDefault();e.stopPropagation();
|
||
dz.classList.remove('drag-over');
|
||
var files=e.dataTransfer&&e.dataTransfer.files;
|
||
if(files&&files.length)uploadModalFiles(Array.prototype.slice.call(files));
|
||
});
|
||
}
|
||
}
|
||
function closeUploadModal(){
|
||
var backdrop=document.getElementById('upload-modal-backdrop');if(backdrop)backdrop.hidden=true;
|
||
var modal=document.getElementById('upload-modal');if(modal)modal.hidden=true;
|
||
}
|
||
function handleUploadModalFiles(input){
|
||
if(!input||!input.files||!input.files.length)return;
|
||
var files=Array.prototype.slice.call(input.files);
|
||
input.value='';
|
||
uploadModalFiles(files);
|
||
}
|
||
function uploadModalFiles(files){
|
||
var list=document.getElementById('upload-file-list');if(!list)return;
|
||
for(var i=0;i<files.length;i++){
|
||
var file=files[i];
|
||
var entry={file:file,resourceId:null,markdown:null,state:'uploading'};
|
||
_uploadModalFiles.push(entry);
|
||
_renderUploadFileItem(entry,list);
|
||
}
|
||
_uploadModalFilesSeq(0);
|
||
}
|
||
function _uploadModalFilesSeq(idx){
|
||
if(idx>=_uploadModalFiles.length){
|
||
var active=_uploadModalFiles.filter(function(e){return!e._deleted});
|
||
var any=active.some(function(e){return e.state==='done'});
|
||
var anyError=active.some(function(e){return e.state==='error'});
|
||
var allDone=active.length>0&&active.every(function(e){return e.state==='done'});
|
||
var btn=document.getElementById('upload-insert-btn');if(btn)btn.disabled=!any;
|
||
// Auto-insert and dismiss when every file uploaded cleanly. If any failed,
|
||
// keep the modal open so the user can see which file errored.
|
||
if(allDone&&!anyError){insertUploadedFiles();}
|
||
return;
|
||
}
|
||
var entry=_uploadModalFiles[idx];
|
||
if(entry._deleted||entry.state!=='uploading'){_uploadModalFilesSeq(idx+1);return}
|
||
var file=entry.file;
|
||
if(_fileTooLarge(file)){entry.state='error';entry._errMsg='Too large (max '+Math.round(_maxUploadBytes()/1048576)+'MB)';_refreshUploadFileItem(idx);_uploadModalFilesSeq(idx+1);return}
|
||
if((!file.name||file.name==='')&&file.type){
|
||
var ext=file.type.split('/')[1]||'bin';
|
||
file=new File([file],'upload-'+Date.now()+'.'+ext,{type:file.type});
|
||
entry.file=file;
|
||
}
|
||
var fd=new FormData();fd.append('file',file);
|
||
var xhr=new XMLHttpRequest();
|
||
entry._xhr=xhr;
|
||
xhr.upload.onprogress=function(e){
|
||
if(e.lengthComputable)_updateUploadFileProgress(idx,Math.round(e.loaded/e.total*100));
|
||
};
|
||
xhr.onload=function(){
|
||
entry._xhr=null;
|
||
if(entry._deleted){_uploadModalFilesSeq(idx+1);return}
|
||
try{var d=JSON.parse(xhr.responseText);
|
||
if(d.error){entry.state='error';entry._errMsg=d.error}else{entry.state='done';entry.resourceId=d.resourceId;entry.markdown=d.markdown}
|
||
}catch(e2){entry.state='error';entry._errMsg='Upload failed'}
|
||
_refreshUploadFileItem(idx);
|
||
_uploadModalFilesSeq(idx+1);
|
||
};
|
||
xhr.onerror=function(){
|
||
entry._xhr=null;
|
||
if(entry._deleted){_uploadModalFilesSeq(idx+1);return}
|
||
entry.state='error';_refreshUploadFileItem(idx);_uploadModalFilesSeq(idx+1)
|
||
};
|
||
xhr.open('POST','/fragments/upload');
|
||
xhr.send(fd);
|
||
}
|
||
function _renderUploadFileItem(entry,list){
|
||
var idx=_uploadModalFiles.indexOf(entry);
|
||
var div=document.createElement('div');div.className='upload-file-item';div.id='upload-file-'+idx;
|
||
var name=_escapeHtmlAttr(entry.file.name||'file');
|
||
div.innerHTML='<span class="upload-file-name">'+name+'</span>'+'<span class="upload-file-size">'+_fmtFileSize(entry.file.size)+'</span>'+'<span class="upload-file-status">Uploading</span>'+'<div class="upload-progress-bar"><div class="upload-progress-bar-fill" style="width:0%"></div></div>'+'<button type="button" class="upload-file-del" title="Delete this file" onclick="event.stopPropagation();deleteUploadedResource('+idx+')" style="display:none">×</button>';
|
||
list.appendChild(div);
|
||
}
|
||
function _updateUploadFileProgress(idx,pct){
|
||
var item=document.getElementById('upload-file-'+idx);if(!item)return;
|
||
var fill=item.querySelector('.upload-progress-bar-fill');if(fill)fill.style.width=pct+'%';
|
||
}
|
||
function _refreshUploadFileItem(idx){
|
||
var entry=_uploadModalFiles[idx];var item=document.getElementById('upload-file-'+idx);if(!item||!entry)return;
|
||
var statusEl=item.querySelector('.upload-file-status');
|
||
if(entry.state==='done'){
|
||
item.classList.add('done');
|
||
if(statusEl){statusEl.textContent='\u2713';statusEl.className='upload-file-status ok'}
|
||
}else if(entry.state==='error'){
|
||
item.classList.add('error');
|
||
if(statusEl){statusEl.textContent=entry._errMsg||'Failed';statusEl.className='upload-file-status err';statusEl.title=entry._errMsg||'Failed'}
|
||
}
|
||
var bar=item.querySelector('.upload-progress-bar');if(bar)bar.remove();
|
||
// Show delete button once uploaded (done or error)
|
||
var delBtn=item.querySelector('.upload-file-del');
|
||
if(delBtn)delBtn.style.display='';
|
||
}
|
||
function deleteUploadedResource(idx){
|
||
if(idx<0||idx>=_uploadModalFiles.length)return;
|
||
var entry=_uploadModalFiles[idx];
|
||
entry._deleted=true;
|
||
// Abort in-progress upload
|
||
if(entry._xhr){try{entry._xhr.abort()}catch(e){}entry._xhr=null}
|
||
// If already uploaded, delete from server
|
||
if(entry.resourceId){
|
||
fetch('/resources/'+entry.resourceId,{method:'DELETE',credentials:'same-origin'}).catch(function(){});
|
||
}
|
||
// Remove from array and from DOM
|
||
var item=document.getElementById('upload-file-'+idx);
|
||
if(item)item.remove();
|
||
// Re-index remaining DOM items
|
||
var list=document.getElementById('upload-file-list');
|
||
if(list){
|
||
var items=list.querySelectorAll('.upload-file-item');
|
||
for(var i=0;i<items.length;i++){
|
||
items[i].id='upload-file-'+i;
|
||
var delBtn=items[i].querySelector('.upload-file-del');
|
||
if(delBtn)delBtn.setAttribute('onclick','event.stopPropagation();deleteUploadedResource('+i+')');
|
||
}
|
||
}
|
||
_uploadModalFiles.splice(idx,1);
|
||
// Update insert button state
|
||
var any=_uploadModalFiles.some(function(e){return e.state==='done'&&!e._deleted});
|
||
var btn=document.getElementById('upload-insert-btn');if(btn)btn.disabled=!any;
|
||
}
|
||
function _fmtFileSize(bytes){if(!bytes&&bytes!==0)return'';if(bytes<1024)return bytes+' B';if(bytes<1048576)return(bytes/1024).toFixed(1)+' KB';return(bytes/1048576).toFixed(1)+' MB'}
|
||
function _mdToTinyMCEInsert(entry){
|
||
var id=entry.resourceId||'';
|
||
var name=_escapeHtmlAttr((entry.file&&entry.file.name)||'file');
|
||
if(entry.file&&entry.file.type&&entry.file.type.startsWith('image/'))return'<img src="/resources/'+id+'" data-resource-id="'+id+'" alt="'+name+'" />';
|
||
return'<a href="/resources/'+id+'" data-resource-id="'+id+'">'+name+'</a>';
|
||
}
|
||
function insertUploadedFiles(){
|
||
var done=_uploadModalFiles.filter(function(e){return e.state==='done'&&e.markdown});
|
||
if(!done.length)return;
|
||
if(_tinymceEditor&&_uploadModalInsertRng){
|
||
try{_tinymceEditor.selection.setRng(_uploadModalInsertRng)}catch(e){}
|
||
var html='';
|
||
for(var i=0;i<done.length;i++){if(i>0)html+=' ';html+=_mdToTinyMCEInsert(done[i])}
|
||
_tinymceEditor.insertContent(html);
|
||
}else{
|
||
_uploadInsertTarget=_captureUploadInsertTarget();
|
||
for(var j=0;j<done.length;j++)_insertUploadedMarkdown(done[j].markdown);
|
||
}
|
||
closeUploadModal();
|
||
markEdited();
|
||
scheduleSave();
|
||
}
|
||
var _tdService=null;
|
||
function getTurndown(){
|
||
if(_tdService)return _tdService;
|
||
var td=new TurndownService({headingStyle:'atx',hr:'---',codeBlockStyle:'fenced',bulletListMarker:'-',emDelimiter:'*',strongDelimiter:'**',br:''});
|
||
// Preserve fenced code language from TinyMCE codesample blocks.
|
||
td.addRule('fencedCodeLanguage',{filter:function(n){return n.nodeName==='PRE'&&(!!n.querySelector('code')||/(?:^|\s)language-/.test(n.getAttribute('class')||''))},replacement:function(_c,n){
|
||
var codeEl=n.querySelector('code');
|
||
var cls=((codeEl&&codeEl.getAttribute('class'))||n.getAttribute('class')||'');
|
||
var m=cls.match(/(?:^|\s)language-([\w-]+)/);
|
||
var lang=m?m[1]:'';
|
||
var code=(codeEl?codeEl.textContent:n.textContent)||'';
|
||
code=code.replace(/\n+$/,'');
|
||
var ticks='```';
|
||
var runs=code.match(/`{3,}/g)||[];
|
||
runs.forEach(function(run){if(run.length>=ticks.length)ticks='`'.repeat(run.length+1)});
|
||
return '\n'+ticks+lang+'\n'+code+'\n'+ticks+'\n';
|
||
}});
|
||
// Joplin resource images (with optional resize dimensions)
|
||
td.addRule('joplinImg',{filter:function(n){return n.nodeName==='IMG'},replacement:function(c,n){
|
||
var alt=n.getAttribute('alt')||'';var src=n.getAttribute('src')||'';
|
||
var w=n.style.width||n.getAttribute('width');var h=n.style.height||n.getAttribute('height');
|
||
var rm=src.match(/^\/?resources\/([0-9a-zA-Z]{32})$/);
|
||
// Never embed data: URIs into markdown — they corrupt note storage
|
||
if(src.startsWith('data:'))return alt?'['+alt+']':'';
|
||
if(w||h){var iSrc=rm?':/'+rm[1]:src;return '<img src="'+iSrc+'" alt="'+alt+'"'+(w?' width="'+parseInt(w)+'"':'')+(h?' height="'+parseInt(h)+'"':'')+' />'}
|
||
if(rm)return '';return ''}});
|
||
// Joplin resource links
|
||
td.addRule('joplinLink',{filter:function(n){return n.nodeName==='A'&&/^\/?resources\/[0-9a-zA-Z]{32}(?:\?download=1)?$/.test((n.getAttribute('href')||'').split('#')[0])},
|
||
replacement:function(c,n){var m=(n.getAttribute('href')||'').match(/^\/?resources\/([0-9a-zA-Z]{32})/);return '['+c+'](:/'+m[1]+')'}});
|
||
// Preserve external links created in rendered mode instead of collapsing same-label links back to plain text.
|
||
td.addRule('externalLink',{filter:function(n){var href=(n.getAttribute('href')||'').trim();return n.nodeName==='A'&&!!href&&!/^\/?resources\//.test(href)},replacement:function(c,n){var href=(n.getAttribute('href')||'').trim();var label=(c||'').trim()||href;return '['+label+']('+href+')'}});
|
||
// md-blank-line markers — use placeholder to survive <br> normalization.
|
||
// Emitted by the renderer as <p class="md-blank-line"><br></p> (TinyMCE-stable);
|
||
// also accept the legacy <div class="md-blank-line"> shape. After
|
||
// <br>→sentinel conversion, textContent is the sentinel string; accept that too.
|
||
td.addRule('blankLine',{filter:function(n){return (n.nodeName==='DIV'||n.nodeName==='P')&&n.classList.contains('md-blank-line')&&!n.querySelector('img,a,pre,code,ul,ol,blockquote,table')&&(!n.textContent.trim()||n.textContent.trim()==='\u2764BR\u2764')},replacement:function(){return '\x00BL\x00'}});
|
||
// md-checkbox divs
|
||
td.addRule('checkbox',{filter:function(n){return n.nodeName==='DIV'&&n.classList.contains('md-checkbox')},
|
||
replacement:function(c,n){var checked=n.classList.contains('checked');var txt=c.replace(/^[\u2611\u2610\u2612\u2705\u00a0 ]+/,'');return (checked?'- [x] ':'- [ ] ')+txt+'\n'}});
|
||
// Strikethrough
|
||
td.addRule('strikethrough',{filter:['del','s','strike'],replacement:function(c){return c.trim()?'~~'+c.trim()+'~~':''}});
|
||
// Underline
|
||
td.addRule('underline',{filter:'u',replacement:function(c){return c.trim()?'++'+c.trim()+'++':''}});
|
||
// Empty divs from contenteditable (Enter key creates <div><br></div>) — emit BL sentinel so
|
||
// line 616 converts it to one extra newline (\n\n\n), which injectBlankLineBlocks turns into
|
||
// exactly one md-blank-line div. Using '<br>' caused line 611 to produce 4 newlines (two divs).
|
||
// Using '' made blank-line edits invisible to Turndown (hash never changed, note never saved).
|
||
td.addRule('emptyDiv',{filter:function(n){return n.nodeName==='DIV'&&!n.classList.length&&!n.querySelector('img,a,pre,code,ul,ol,blockquote,table')&&(!n.textContent.trim()||n.innerHTML==='<br>')},replacement:function(){return '\x00BL\x00'}});
|
||
// Empty paragraphs TinyMCE inserts when user presses Enter for a blank line (<p><br></p>).
|
||
// Each one = one extra blank line → \x00BL\x00 sentinel → post-processing converts to \n\n\n.
|
||
td.addRule('emptyP',{filter:function(n){return n.nodeName==='P'&&!n.querySelector('img')&&(!n.textContent.trim()||n.innerHTML==='<br>'||n.innerHTML==='\u2764BR\u2764')},replacement:function(){return '\x00BL\x00'}});
|
||
_tdService=td;return td}
|
||
function htmlToMarkdown(el){
|
||
var root=el.cloneNode(true);
|
||
root.querySelectorAll('.pre-copy-btn').forEach(function(btn){btn.remove()});
|
||
root.querySelectorAll('.preview-img-download-btn').forEach(function(btn){btn.remove()});
|
||
root.querySelectorAll('.preview-img-download-wrap').forEach(function(wrap){var img=wrap.querySelector('img');if(img)wrap.replaceWith(img)});
|
||
root.querySelectorAll('p[data-pv-trail]').forEach(function(p){p.remove()});
|
||
var md=getTurndown().turndown(root.innerHTML);
|
||
var nbsp=String.fromCharCode(160);
|
||
while(md.indexOf(nbsp)>=0)md=md.split(nbsp).join(' ');
|
||
var nl=String.fromCharCode(10);
|
||
var headingGapRe=new RegExp('^(#{1,6}[^'+nl+']*)'+nl+'{2,}(?=\\S)','gm');
|
||
var headingLeadRe=new RegExp('([^'+nl+'])'+nl+'{2,}(#{1,6}\\s)','g');
|
||
md=md.split('<br/>').join('<br>');
|
||
md=md.split('<br>'+nl).join(nl);
|
||
while(md.indexOf('<br><br>')>=0)md=md.split('<br><br>').join('<br>'+nl);
|
||
md=md.replace(headingLeadRe,'$1'+nl+'$2');
|
||
md=md.replace(headingGapRe,'$1'+nl);
|
||
md=md.replace(new RegExp(nl+nl+'<br>$'),'');
|
||
md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)});
|
||
var out='';
|
||
for(var i=0;i<md.length;i++){
|
||
var ch=md.charAt(i),nx=md.charAt(i+1);
|
||
if(ch.charCodeAt(0)===92&&(nx==='['||nx===']'||nx.charCodeAt(0)===96||nx==='*'||nx==='_'||nx.charCodeAt(0)===92||nx==='$')){out+=nx;i++;continue}
|
||
out+=ch
|
||
}
|
||
return out
|
||
}
|
||
function tinymceToMarkdown(html){
|
||
if(!html)return '';
|
||
html=html.replace(/\u200b/g,'');
|
||
// Normalise blank-line markers. TinyMCE strips the <br> from
|
||
// <p class="md-blank-line"><br></p> on setContent, leaving an empty
|
||
// <p class="md-blank-line"></p> — which Turndown drops entirely (empty block
|
||
// = no output), swallowing the blank line. Rewrite any md-blank-line paragraph
|
||
// (empty or not) to the sentinel shape the blankLine rule reliably matches.
|
||
html=html.replace(/<p([^>]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>[\s\S]*?<\/p>/gi,'<p$1>\u2764BR\u2764</p>');
|
||
// tinyMCE appends a trailing <br> to every non-empty <p> block. Strip before conversion
|
||
// so it doesn't become a spurious newline. Do NOT strip from <p><br></p> (blank lines).
|
||
html=html.replace(/<p>((?:[^<]|<(?!\/p>))+?)<br\s*\/?>\s*<\/p>/gi,'<p>$1</p>');
|
||
// Convert <br> (line breaks within paragraphs and blank-line divs) to a
|
||
// sentinel Turndown won't touch. Restore as \n after conversion.
|
||
// For md-blank-line divs the blankLine rule matches the sentinel text.
|
||
var BR='\u2764BR\u2764';
|
||
html=html.replace(/<br\s*\/?>/gi,BR);
|
||
html=html.replace(/src="\/?resources\/([0-9a-fA-F]{32})"/g,'src=":/$1"');
|
||
html=html.replace(/href="\/?resources\/([0-9a-fA-F]{32})"/g,'href=":/$1"');
|
||
html=html.replace(/<span style="text-decoration: underline;">([\s\S]*?)<\/span>/g,'++$1++');
|
||
html=html.replace(/<span style="text-decoration: line-through;">([\s\S]*?)<\/span>/g,'~~$1~~');
|
||
var td=getTurndown();
|
||
var md=td.turndown(html);
|
||
var nl=String.fromCharCode(10);
|
||
var headingGapRe=new RegExp('^(#{1,6}[^'+nl+']*)'+nl+'{2,}(?=\\S)','gm');
|
||
var headingLeadRe=new RegExp('([^'+nl+'])'+nl+'{2,}(#{1,6}\\s)','g');
|
||
md=md.replace(headingLeadRe,'$1'+nl+'$2');
|
||
md=md.replace(headingGapRe,'$1'+nl);
|
||
// Normalise blank-line sentinels (md-blank-line divs + empty paragraphs)
|
||
md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)});
|
||
// Restore line-break sentinels as \n (soft breaks within paragraphs)
|
||
md=md.split('\u2764BR\u2764').join(nl);
|
||
var out='';
|
||
for(var i=0;i<md.length;i++){
|
||
var ch=md.charAt(i),nx=md.charAt(i+1);
|
||
if(ch.charCodeAt(0)===92&&(nx==='['||nx===']'||nx.charCodeAt(0)===96||nx==='*'||nx==='_'||nx.charCodeAt(0)===92||nx==='$')){out+=nx;i++;continue}
|
||
out+=ch
|
||
}
|
||
return out
|
||
}
|
||
function setEditorMode(mode){
|
||
var ta=getTA();
|
||
var form=activeEditorForm();
|
||
if(form)form.dataset.editorMode=mode;
|
||
// View/mode switches are transient: looking at one note in markdown does
|
||
// not mean you want markdown for every note. The persisted note-open
|
||
// preference is changed only in Settings. Track the current view locally so
|
||
// entering edit mode can honor the preference, but never write it back.
|
||
if(mode==='markdown'||mode==='md'){
|
||
// Markdown mode: sync latest rich content back to the textarea, then mount CM6.
|
||
tinyMCESyncToTA();
|
||
applyEditorModeVisibility('markdown');
|
||
mountMarkdownEditor(ta?ta.value:'');
|
||
_editorMode='markdown';
|
||
syncEditorModeButtons();
|
||
return;
|
||
}
|
||
// Rich mode: sync CM6 content back to the textarea, hide markdown editor, show TinyMCE.
|
||
cmSyncToTA();
|
||
var mdVal=ta?ta.value:'';
|
||
applyEditorModeVisibility('rich',{focusTextarea:false});
|
||
_editorMode='rich';
|
||
syncEditorModeButtons();
|
||
if(!_tinymceEditor){
|
||
// TinyMCE not ready yet — start init, it will call refreshTinyMCEForActiveNote on init.
|
||
initPersistentTinyMCE();
|
||
return;
|
||
}
|
||
// Re-render current textarea content and push into the persistent editor.
|
||
fetch('/fragments/preview',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'body='+encodeURIComponent(mdVal)}).then(function(r){return r.text()}).then(function(h){
|
||
if(!_tinymceEditor)return;
|
||
_setTinyMCEContent(h);
|
||
showTinyMCEHost();
|
||
_applyTinyMCEReadonly(_tinymceEditor);
|
||
if(!_tinymceReadonly)_tinymceEditor.focus();
|
||
});
|
||
}
|
||
// Mount (or remount) the CodeMirror 6 markdown editor into #cm-host, seeded from `content`.
|
||
// Falls back gracefully to the raw textarea if the CM6 bundle failed to load.
|
||
function mountMarkdownEditor(content){
|
||
var host=queryActiveEditor('#cm-host');
|
||
if(!host||!window.CM||typeof initCM!=='function'){
|
||
// No CM bundle / host — leave the textarea visible as a fallback.
|
||
var ta=getTA();
|
||
if(ta){ta.style.display='block';ta.style.flex='1';ta.style.minHeight='0';}
|
||
return;
|
||
}
|
||
host.style.display='';
|
||
initCM(host,content||'');
|
||
if(_cmView){
|
||
// Recompute layout after the host has its final flex height, then focus.
|
||
if(_cmView.requestMeasure)_cmView.requestMeasure();
|
||
requestAnimationFrame(function(){if(_cmView&&_cmView.requestMeasure)_cmView.requestMeasure();});
|
||
if(_cmView.focus)_cmView.focus();
|
||
}
|
||
}
|
||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden){searchNavDismiss();return}}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
|
||
document.addEventListener('click',function(e){var menu=document.getElementById('folder-context-menu');if(menu&&!menu.hidden&&!menu.contains(e.target))closeFolderContextMenu()});
|
||
function highlightCodeBlocks(container){if(!window.hljs||!container)return;container.querySelectorAll('pre code[class*="language-"]').forEach(function(el){if(el.dataset.highlighted)return;window.hljs.highlightElement(el)})}
|
||
function ensureEditableAfterPre(pv){if(!pv)return;var pres=pv.querySelectorAll('pre');pres.forEach(function(pre){var next=pre.nextElementSibling;if(!next){var p=document.createElement('p');p.innerHTML='<br>';p.dataset.pvTrail='1';pv.appendChild(p)}})}
|
||
function initCopyButtons(pv){if(!pv)return;pv.querySelectorAll('pre').forEach(function(pre){pre.contentEditable='false';pre.style.cursor='pointer';if(pre.querySelector('.pre-copy-btn'))return;var btn=document.createElement('button');btn.type='button';btn.className='pre-copy-btn';btn.title='Copy code';btn.textContent='Copy';btn.addEventListener('click',function(e){e.stopPropagation();var code=pre.querySelector('code');var text=code?code.textContent:(pre.textContent||'');navigator.clipboard.writeText(text).then(function(){btn.textContent='Copied!';setTimeout(function(){btn.textContent='Copy'},1500)}).catch(function(){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);btn.textContent='Copied!';setTimeout(function(){btn.textContent='Copy'},1500)})});pre.insertBefore(btn,pre.firstChild);pre.addEventListener('click',function(e){if(e.target.closest('.pre-copy-btn'))return;e.preventDefault();e.stopPropagation();if(!tinyMCEInsertCodeBlock(pre))openCodeModal(pre)})})}
|
||
function _isIOSWebKit(){var ua=navigator.userAgent||'';var platform=navigator.platform||'';var touchMac=platform==='MacIntel'&&navigator.maxTouchPoints>1;return /iP(ad|hone|od)/.test(ua)||touchMac}
|
||
function _isStandalonePWA(){return !!((window.matchMedia&&window.matchMedia('(display-mode: standalone)').matches)||window.navigator.standalone===true)}
|
||
function _openResourceInNewContext(url){var a=document.createElement('a');a.href=url;a.target='_blank';a.rel='noopener';document.body.appendChild(a);a.click();document.body.removeChild(a)}
|
||
function _resourceFilenameFromHeaders(headers){
|
||
var cd=headers.get('Content-Disposition')||'';
|
||
var mStar=cd.match(/filename\*=([^;]+)/i);
|
||
if(mStar){var v=mStar[1].trim();var idx=v.indexOf("''");if(idx>=0)v=v.slice(idx+2);try{return decodeURIComponent(v.replace(/^"|"$/g,''))}catch(_e){return v.replace(/^"|"$/g,'')}}
|
||
var m=cd.match(/filename="([^"]+)"/i);if(m)return m[1];
|
||
var m2=cd.match(/filename=([^;]+)/i);if(m2)return m2[1].trim();
|
||
return '';
|
||
}
|
||
function _fetchResourceBlob(resourceId){
|
||
var url='/resources/'+encodeURIComponent(resourceId);
|
||
return fetch(url,{credentials:'same-origin'}).then(function(r){
|
||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||
var mime=r.headers.get('Content-Type')||'application/octet-stream';
|
||
var filename=_resourceFilenameFromHeaders(r.headers)||resourceId;
|
||
return r.blob().then(function(blob){return {blob:blob,mime:mime,filename:filename,url:url}})
|
||
})
|
||
}
|
||
function _openResourceView(resourceId){
|
||
var id=resourceId||'';if(!id)return;
|
||
var url='/resources/'+encodeURIComponent(id)+(_isStandalonePWA()?'?viewer=1':'');
|
||
_openResourceInNewContext(url);
|
||
}
|
||
function _canPreviewResourceMime(mime){
|
||
var lower=(mime||'').toLowerCase();
|
||
if(_isStandalonePWA())return lower.indexOf('image/')===0||lower.indexOf('text/')===0||lower==='application/pdf';
|
||
return lower.indexOf('image/')===0||lower==='application/pdf'||lower==='text/plain';
|
||
}
|
||
function _fetchResourceMeta(resourceId){
|
||
var url='/resources/'+encodeURIComponent(resourceId);
|
||
return fetch(url,{method:'HEAD',credentials:'same-origin'}).then(function(r){
|
||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||
return {
|
||
mime:r.headers.get('Content-Type')||'application/octet-stream',
|
||
filename:_resourceFilenameFromHeaders(r.headers)||resourceId,
|
||
disposition:r.headers.get('Content-Disposition')||''
|
||
}
|
||
})
|
||
}
|
||
function _triggerResourceDownload(resourceId){
|
||
var id=resourceId||'';if(!id)return;
|
||
var url='/resources/'+encodeURIComponent(id)+'?download=1';
|
||
if(_isIOSWebKit()&&!isDesktopMode()){window.location.assign(url);return}
|
||
var a=document.createElement('a');a.href=url;a.setAttribute('download','');a.rel='noopener';document.body.appendChild(a);a.click();document.body.removeChild(a)
|
||
}
|
||
function _shouldUseResourceActions(){return _isStandalonePWA()||isDesktopMode()}
|
||
function _triggerBlobDownload(blob,filename){
|
||
var u=URL.createObjectURL(blob);
|
||
var a=document.createElement('a');a.href=u;a.setAttribute('download',filename||'download');a.rel='noopener';document.body.appendChild(a);a.click();document.body.removeChild(a);
|
||
setTimeout(function(){URL.revokeObjectURL(u)},10000);
|
||
}
|
||
function _shareBlob(blob,filename,mime){
|
||
try{
|
||
if(typeof File!=='function')return Promise.resolve(false);
|
||
var file=new File([blob],filename||'download',{type:mime||blob.type||'application/octet-stream'});
|
||
if(navigator.canShare&&navigator.canShare({files:[file]})&&navigator.share){
|
||
return navigator.share({files:[file]}).then(function(){return true}).catch(function(){return false})
|
||
}
|
||
}catch(_e){}
|
||
return Promise.resolve(false);
|
||
}
|
||
function _closeResourceViewer(){
|
||
var v=document.getElementById('resource-viewer');
|
||
if(v){var u=v.getAttribute('data-blob-url');if(u)try{URL.revokeObjectURL(u)}catch(_e){}v.remove()}
|
||
}
|
||
function _openResourceViewer(blob,mime,filename){
|
||
_closeResourceViewer();
|
||
var url=URL.createObjectURL(blob);
|
||
var overlay=document.createElement('div');
|
||
overlay.id='resource-viewer';
|
||
overlay.className='resource-viewer';
|
||
overlay.setAttribute('data-blob-url',url);
|
||
var bar=document.createElement('div');bar.className='resource-viewer-bar';
|
||
var title=document.createElement('span');title.className='resource-viewer-title';title.textContent=filename||'';
|
||
var saveBtn=document.createElement('button');saveBtn.type='button';saveBtn.className='resource-viewer-btn';saveBtn.textContent='Save';
|
||
saveBtn.addEventListener('click',function(){_shareBlob(blob,filename,mime).then(function(ok){if(!ok)_triggerBlobDownload(blob,filename)})});
|
||
var closeBtn=document.createElement('button');closeBtn.type='button';closeBtn.className='resource-viewer-btn';closeBtn.textContent='Close';
|
||
closeBtn.addEventListener('click',_closeResourceViewer);
|
||
bar.appendChild(title);bar.appendChild(saveBtn);bar.appendChild(closeBtn);
|
||
var body=document.createElement('div');body.className='resource-viewer-body';
|
||
var lower=(mime||'').toLowerCase();
|
||
if(lower.indexOf('image/')===0){var img=document.createElement('img');img.src=url;img.className='resource-viewer-img';img.alt=filename||'';body.appendChild(img)}
|
||
else if(lower==='application/pdf'||lower.indexOf('text/')===0||lower.indexOf('video/')===0||lower.indexOf('audio/')===0){var iframe=document.createElement('iframe');iframe.src=url;iframe.className='resource-viewer-frame';iframe.setAttribute('title',filename||'');body.appendChild(iframe)}
|
||
else {var msg=document.createElement('div');msg.className='resource-viewer-msg';msg.textContent='This file type cannot be previewed. Use Save to download it.';body.appendChild(msg)}
|
||
overlay.appendChild(bar);overlay.appendChild(body);
|
||
document.body.appendChild(overlay);
|
||
}
|
||
var _resourceActionViewportHandler=null;
|
||
function _positionResourceActions(anchorEl){
|
||
var s=document.getElementById('resource-action-sheet');
|
||
if(!s)return;
|
||
var vv=window.visualViewport;
|
||
var height=window.innerHeight||0;
|
||
var top=12;
|
||
var left=12;
|
||
var bottomLimit=(vv?vv.height:height)||height;
|
||
var rightLimit=(vv?vv.width:(window.innerWidth||0))||(window.innerWidth||0);
|
||
var anchorRect=anchorEl&&anchorEl.getBoundingClientRect?anchorEl.getBoundingClientRect():null;
|
||
if(vv)height=Math.round(vv.height||height);
|
||
if(!_isStandalonePWA()&&isDesktopMode()){
|
||
s.style.width='200px';
|
||
s.style.right='auto';
|
||
}else{
|
||
s.style.width='';
|
||
s.style.left='12px';
|
||
s.style.right='12px';
|
||
}
|
||
if(anchorRect){
|
||
top=Math.round(anchorRect.bottom+12);
|
||
var sheetH=s.offsetHeight||0;
|
||
if(!_isStandalonePWA()&&isDesktopMode()){
|
||
var sheetW=s.offsetWidth||200;
|
||
left=Math.max(12,Math.min(Math.round(anchorRect.left),Math.max(12,rightLimit-sheetW-12)));
|
||
s.style.left=left+'px';
|
||
}
|
||
if(top+sheetH>bottomLimit-12){
|
||
var aboveTop=Math.round(anchorRect.top-sheetH-12);
|
||
if(aboveTop>=12)top=aboveTop;else top=Math.max(12,bottomLimit-sheetH-12);
|
||
}
|
||
}
|
||
s.style.top=top+'px';
|
||
s.style.maxHeight=Math.max(140,Math.floor(height*0.6))+'px';
|
||
}
|
||
function _closeResourceActions(){
|
||
var s=document.getElementById('resource-action-sheet');
|
||
var b=document.getElementById('resource-action-backdrop');
|
||
if(window.visualViewport&&_resourceActionViewportHandler){
|
||
window.visualViewport.removeEventListener('resize',_resourceActionViewportHandler);
|
||
window.visualViewport.removeEventListener('scroll',_resourceActionViewportHandler);
|
||
}
|
||
_resourceActionViewportHandler=null;
|
||
if(s)s.remove();if(b)b.remove();
|
||
}
|
||
function presentResourceActions(resourceId,anchorEl){
|
||
if(!resourceId)return;
|
||
_closeResourceActions();
|
||
var metaRequest=_fetchResourceMeta(resourceId).catch(function(){return null});
|
||
try{if(document.activeElement&&document.activeElement.blur)document.activeElement.blur()}catch(_e){}
|
||
var backdrop=document.createElement('div');backdrop.id='resource-action-backdrop';backdrop.className='resource-action-backdrop';backdrop.addEventListener('click',_closeResourceActions);
|
||
var sheet=document.createElement('div');sheet.id='resource-action-sheet';sheet.className='resource-action-sheet';
|
||
var mkBtn=function(label,handler){var b=document.createElement('button');b.type='button';b.className='resource-action-btn';b.textContent=label;b.addEventListener('click',handler);return b};
|
||
var saveBtn=mkBtn('Save',function(){_closeResourceActions();if(_isStandalonePWA()){_fetchResourceBlob(resourceId).then(function(r){return _shareBlob(r.blob,r.filename,r.mime).then(function(ok){if(!ok)_triggerBlobDownload(r.blob,r.filename)})}).catch(function(){alert('Failed to load resource')});return}_triggerResourceDownload(resourceId)});
|
||
var cancelBtn=mkBtn('Cancel',_closeResourceActions);cancelBtn.classList.add('resource-action-btn-cancel');
|
||
sheet.appendChild(saveBtn);sheet.appendChild(cancelBtn);
|
||
document.body.appendChild(backdrop);document.body.appendChild(sheet);
|
||
_resourceActionViewportHandler=_positionResourceActions;
|
||
if(window.visualViewport){
|
||
window.visualViewport.addEventListener('resize',_resourceActionViewportHandler);
|
||
window.visualViewport.addEventListener('scroll',_resourceActionViewportHandler);
|
||
}
|
||
_positionResourceActions(anchorEl);
|
||
metaRequest.then(function(meta){
|
||
if(!meta||!document.body.contains(sheet)||!_canPreviewResourceMime(meta.mime))return;
|
||
var viewBtn=mkBtn('View',function(){_closeResourceActions();_openResourceView(resourceId)});
|
||
sheet.insertBefore(viewBtn,saveBtn);
|
||
_positionResourceActions(anchorEl);
|
||
});
|
||
}
|
||
function downloadResource(resourceId,anchorEl){var id=resourceId||'';if(!id)return;if(_shouldUseResourceActions()){presentResourceActions(id,anchorEl);return}_triggerResourceDownload(id)}
|
||
function initResourceImageDownloadButtons(pv){if(!pv)return;pv.querySelectorAll('img.preview-img[data-resource-id]').forEach(function(img){var wrap=img.parentElement;if(!(wrap&&wrap.classList&&wrap.classList.contains('preview-img-download-wrap'))){wrap=document.createElement('span');wrap.className='preview-img-download-wrap';img.parentNode.insertBefore(wrap,img);wrap.appendChild(img)}if(wrap.querySelector('.preview-img-download-btn'))return;var btn=document.createElement('button');btn.type='button';btn.className='preview-img-download-btn';btn.title='Download image';btn.setAttribute('aria-label','Download image');btn.setAttribute('contenteditable','false');btn.textContent='⬇️';btn.addEventListener('click',function(e){e.preventDefault();e.stopPropagation();var resourceId=img.getAttribute('data-resource-id')||'';if(!resourceId)return;downloadResource(resourceId,btn)});wrap.appendChild(btn)})}
|
||
function activatePV(pv){if(!pv)return;pv.contentEditable='true';initImgResize(pv);initCopyButtons(pv);initResourceImageDownloadButtons(pv);highlightCodeBlocks(pv);ensureEditableAfterPre(pv);pv.oninput=function(){_previewDirty=true;scheduleSyncPV();
|
||
var state=getRenderAutocompleteState();
|
||
if(state&&state.kind==='note'){
|
||
var coords=getCaretCoordinates();
|
||
if(coords){fetchNoteHeaders().then(function(headers){var query=state.query.toLowerCase();var filtered=headers.filter(function(h){return h.title.toLowerCase().indexOf(query)>=0});if(filtered.length>0){showRenderAutocompletePopup(coords,filtered,'note',state)}else{hideRenderAutocompletePopup()}})}
|
||
return;
|
||
}
|
||
hideRenderAutocompletePopup();
|
||
};pv.onkeyup=null;if(pv.dataset.pvInit)return;pv.dataset.pvInit='1';
|
||
pv.addEventListener('keydown',function(e){if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();e.stopPropagation();requestManualProseCompletion();return}});
|
||
// Ring buffer: capture raw input before contenteditable normalizes spaces/characters
|
||
pv.addEventListener('beforeinput',function(e){
|
||
_ringBufFedFromBeforeinput=false;
|
||
if(!_ringBufAccepts(e.inputType)){
|
||
if(e.inputType&&(e.inputType.indexOf('delete')>=0||e.inputType==='historyUndo'||e.inputType==='historyRedo'))_resetRingBuffer('pv-deletion-or-undo');
|
||
return;
|
||
}
|
||
if(e.data){_feedRingBuffer('preview',e.inputType,e.data);_ringBufFedFromBeforeinput=true}
|
||
});
|
||
// Ring buffer: consume pending trigger after DOM has settled.
|
||
// Also feeds buffer as fallback when beforeinput didn't fire (iOS Safari autocorrect pipeline).
|
||
pv.addEventListener('input',function(e){
|
||
if(!_ringBufFedFromBeforeinput&&e&&e.data){_feedRingBuffer('preview-input-fallback',e.inputType||'insertText',e.data)}
|
||
_ringBufFedFromBeforeinput=false;
|
||
if(_pendingTextExpansion){consumePendingTextExpansion('preview');return}
|
||
});
|
||
pv.addEventListener('blur',function(){_resetRingBuffer('pv-blur')});
|
||
pv.addEventListener('click',function(){hideRenderAutocompletePopup()});
|
||
pv.addEventListener('blur',function(){setTimeout(hideRenderAutocompletePopup,200)});
|
||
pv.addEventListener('click',function(e){var img=e.target.closest('img.preview-img[data-resource-id]');if(!img||!pv.contains(img))return;if(e.target.closest('.preview-img-download-btn'))return;if(!isDesktopMode())return;var resourceId=img.getAttribute('data-resource-id')||'';if(!resourceId)return;e.preventDefault();_openResourceView(resourceId)});
|
||
pv.addEventListener('click',function(e){var link=e.target.closest('a');if(link&&pv.contains(link)){var resId=link.getAttribute('data-resource-id')||'';if(resId&&_shouldUseResourceActions()){e.preventDefault();presentResourceActions(resId,link);return}var href=link.getAttribute('href')||'';if(href){e.preventDefault();window.open(href,'_blank','noopener');return}}});
|
||
// Click checkbox icon to toggle checked state
|
||
pv.addEventListener('click',function(e){var cb=e.target.closest('.md-checkbox');if(!cb)return;var iconEl=cb.querySelector('.md-cb-icon');if(!iconEl){var txt=cb.firstChild;if(!txt||txt.nodeType!==3)return;var icon=txt.textContent.charAt(0);if(icon!=='\u2610'&&icon!=='\u2611')return;var r=document.createRange();r.setStart(txt,0);r.setEnd(txt,Math.min(2,txt.textContent.length));var iconRect=r.getBoundingClientRect();if(e.clientX>iconRect.right)return;e.preventDefault();var checked=!cb.classList.contains('checked');cb.classList.toggle('checked',checked);txt.textContent=(checked?'\u2611':'\u2610')+txt.textContent.slice(1);syncPV();return}var iconRect=iconEl.getBoundingClientRect();if(e.clientX>iconRect.right)return;e.preventDefault();var checked=!cb.classList.contains('checked');cb.classList.toggle('checked',checked);iconEl.textContent=checked?'\u2611':'\u2610';syncPV()});
|
||
// Enter inside code blocks should stay in the same block; Enter after checkbox creates new checkbox
|
||
pv.addEventListener('keydown',function(e){if(e.key==='Enter'){var sel=window.getSelection();if(!sel.rangeCount)return;var range=sel.getRangeAt(0);var node=range.startContainer;var el=node.nodeType===3?node.parentElement:node;var pre=el&&el.closest?el.closest('pre'):null;if(pre&&pv.contains(pre)){e.preventDefault();var code=pre.querySelector('code')||pre;var txt=code.textContent||'';var atEnd=(node===code||node.parentElement===code)&&range.startOffset===(node.nodeType===3?node.textContent.length:code.childNodes.length)&&!range.toString();if(atEnd&&txt.endsWith('\n')){code.textContent=txt.slice(0,-1);var np=document.createElement('p');np.innerHTML='<br>';pre.parentNode.insertBefore(np,pre.nextSibling);var nr=document.createRange();nr.setStart(np,0);nr.collapse(true);sel.removeAllRanges();sel.addRange(nr);np.scrollIntoView({block:'nearest'});syncPV();return}if(insertPVText('\n'))syncPV();return}var cb=el&&el.closest?el.closest('.md-checkbox'):null;if(!cb&&node.nodeType===1&&range.startOffset>0){var prev=node.childNodes[range.startOffset-1];if(prev&&prev.nodeType===1&&prev.classList&&prev.classList.contains('md-checkbox'))cb=prev}if(!cb)return;e.preventDefault();var label=(cb.textContent||'').replace(/^[\u2610\u2611][\u00a0 ]*/,'').replace(/\u00a0|\s/g,'');if(!label){var para=document.createElement('p');para.innerHTML='<br>';if(cb.parentNode)cb.parentNode.replaceChild(para,cb);var rp=document.createRange();rp.setStart(para,0);rp.collapse(true);sel.removeAllRanges();sel.addRange(rp);para.scrollIntoView({block:'nearest'});syncPV();return}var neo=document.createElement('div');neo.className='md-checkbox';var iconSpan2=document.createElement('span');iconSpan2.className='md-cb-icon';iconSpan2.textContent='\u2610';neo.appendChild(iconSpan2);var tn=document.createTextNode('\u00a0');neo.appendChild(tn);cb.parentNode.insertBefore(neo,cb.nextSibling);var r=document.createRange();r.setStart(tn,1);r.collapse(true);sel.removeAllRanges();sel.addRange(r);neo.scrollIntoView({block:'nearest'});syncPV()}});
|
||
pv.addEventListener('keydown',function(e){if(e.key==='Enter'&&_autoLinkPVSelection({allowEnd:true}))syncPV()});
|
||
pv.addEventListener('keyup',function(e){if(e.key===' '||e.key==='Spacebar'||e.key==='Tab'){if(_autoLinkPVSelection())syncPV()}});
|
||
pv.addEventListener('blur',function(e){var target=e.target&&e.target.nodeType===1?e.target:(e.target&&e.target.parentElement?e.target.parentElement:null);var block=target&&target.closest?target.closest('p,div,li,blockquote,h1,h2,h3,h4,h5,h6'):null;if(block&&pv.contains(block)&&_autoLinkPVBlock(block))syncPV()},true);
|
||
// Scroll to keep cursor visible while typing
|
||
pv.addEventListener('input',function(){var sel=window.getSelection();if(sel&&sel.rangeCount){var r=sel.getRangeAt(0).getBoundingClientRect();var pr=pv.getBoundingClientRect();if(r.bottom>pr.bottom-8)pv.scrollTop+=r.bottom-pr.bottom+24}});
|
||
// Force plain-text paste — if inside <pre>, insert raw text directly; otherwise wrap leading-space content in <pre><code>
|
||
pv.addEventListener('paste',function(e){
|
||
// Image paste: upload and insert as resource
|
||
var items=e.clipboardData&&e.clipboardData.items;
|
||
_log('paste event, items:',(items?items.length:0));
|
||
if(items){for(var i=0;i<items.length;i++){_log('paste item['+i+'] kind='+items[i].kind+' type='+items[i].type);}}
|
||
if(items){for(var i=0;i<items.length;i++){if(items[i].type.startsWith('image/')){e.preventDefault();var f=items[i].getAsFile();_log('paste image file:',f&&f.name,'size:',f&&f.size,'type:',f&&f.type);
|
||
if(f)uploadFile(f);return;}}}
|
||
e.preventDefault();var text=(e.clipboardData||window.clipboardData).getData('text/plain');if(!text)return;var sel=window.getSelection();var inPre=false;if(sel&&sel.rangeCount){var node=sel.getRangeAt(0).startContainer;while(node&&node!==pv){if(node.nodeName==='PRE'||node.nodeName==='CODE'){inPre=true;break}node=node.parentNode}}if(inPre){insertPVText(text);syncPV();return}var trimmed=text.trim();if(/^https?:\/\/\S+$/.test(trimmed)&&trimmed.indexOf('\n')<0){var hasSelection=sel&&sel.rangeCount&&!sel.getRangeAt(0).collapsed;var label=hasSelection?sel.getRangeAt(0).toString()||trimmed:trimmed;var a=_createPVLink(trimmed,label);if(sel&&sel.rangeCount){var range=sel.getRangeAt(0);range.deleteContents();range.insertNode(a);range.setStartAfter(a);range.collapse(true);sel.removeAllRanges();sel.addRange(range)}syncPV();return}document.execCommand('insertText',false,text);syncPV()})}
|
||
function djb2(str){var h=5381;for(var i=0;i<str.length;i++)h=((h<<5)+h+str.charCodeAt(i))>>>0;return h}
|
||
var _formHashExclude={baseUpdatedTime:true,forceSave:true,createCopy:true};function formHash(form){if(!form)return 0;var parts=[];var els=form.elements;for(var i=0;i<els.length;i++){var el=els[i];if(el.name&&!_formHashExclude[el.name])parts.push(el.name+'='+el.value)}return djb2(parts.join('&'))}
|
||
var _savedHash=0;
|
||
var _saveTimer=null;
|
||
var _saveTitleTimer=null;
|
||
function _anyModalOpen(){var ids=['code-modal','link-modal','folder-modal','history-modal','empty-trash-modal','upload-modal','vault-modal','new-folder-modal'];for(var i=0;i<ids.length;i++){var el=document.getElementById(ids[i]);if(el&&!el.hidden)return true}return false}
|
||
function scheduleSave(){if(_saveTimer)clearTimeout(_saveTimer);_saveTimer=setTimeout(function(){_saveTimer=null;if(_syncPVInFlight||_pvSyncTimer){_log('scheduleSave deferred, syncPV in flight');scheduleSave();return}if(_anyModalOpen()){_log('scheduleSave deferred, modal open');scheduleSave();return}var form=activeEditorForm();if(!form)return;var h=formHash(form);if(h===_savedHash){_log('scheduleSave skip, hash unchanged',h);setSaveState('<span class="autosave-ok">Saved</span>','Saved');return}_log('scheduleSave firing, hash',_savedHash,'->',h);htmx.trigger(form,'joplock:save')},2000)}
|
||
function scheduleSaveTitle(){var mobileTitle=document.getElementById('mobile-editor-title');if(mobileTitle&&document.activeElement===mobileTitle)return;// Don't save while user is still editing title
|
||
if(_saveTitleTimer)clearTimeout(_saveTitleTimer);if(_saveTimer)clearTimeout(_saveTimer);_saveTimer=null;_saveTitleTimer=setTimeout(function(){_saveTitleTimer=null;if(_anyModalOpen()){_log('scheduleSaveTitle deferred, modal open');scheduleSave();return}var form=activeEditorForm();if(!form)return;var h=formHash(form);if(h===_savedHash){_log('scheduleSaveTitle skip, hash unchanged',h);setSaveState('<span class="autosave-ok">Saved</span>','Saved');return}_log('scheduleSaveTitle firing');htmx.trigger(form,'joplock:save')},2000)}
|
||
function snapshotHash(){var form=activeEditorForm();_savedHash=formHash(form);_log('snapshotHash',_savedHash)}
|
||
function _isLockedOverlayEventTarget(target){return !!(target&&target.closest&&target.closest('#editor-locked'))}
|
||
function initEditorPanel(){var form=activeEditorForm();if(!form||form.dataset.editorInit)return;form.dataset.editorInit='1';_resetRingBuffer('note-switch');_log('initEditorPanel',form.getAttribute('hx-put'));if(isMobileShellMode())closeNav();_previewDirty=false;setSaveState('','');snapshotHash();_snapshots=[];var undoBtn=queryActiveEditor('#undo-save-btn');if(undoBtn)undoBtn.hidden=true;pushSnapshot();form.addEventListener('input',function(e){if(_isLockedOverlayEventTarget(e.target))return;markEdited();scheduleSave()});form.addEventListener('change',function(e){if(_isLockedOverlayEventTarget(e.target))return;markEdited();scheduleSave()});initAutoTitle();applyMobileTitleMode();renderNoteMeta(); var ta=getTA();if(ta){ta.addEventListener('input',function(){autoTitle()});ta.addEventListener('keydown',function(e){if(_editorMode!=='markdown'&&_editorMode!=='md')return;if(e.key!=='Enter')return;var mac=navigator.platform&&navigator.platform.indexOf('Mac')!==-1;var mod=mac?e.metaKey:e.ctrlKey;if(mod){// Ctrl/Cmd+Enter = soft break (\n, same paragraph)
|
||
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+1;ta.dispatchEvent(new Event('input',{bubbles:true}))}else{// Enter = new paragraph (\n\n)
|
||
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+2;ta.dispatchEvent(new Event('input',{bubbles:true}))}})}var pendingSearch=(window._pendingNoteSearchTerm||'').trim();var mobileEditor=inMobileEditor();if(mobileEditor&&pendingSearch){var header=document.getElementById('mobile-editor-header');var searchHeader=document.getElementById('mobile-editor-search-header');if(header)header.style.display='none';if(searchHeader)searchHeader.style.display=''}var searchInput=activeSearchInput();if(searchInput&&pendingSearch&&!searchInput.value)searchInput.value=pendingSearch;window._pendingNoteSearchTerm='';/* Persistent TinyMCE: refresh content for this note (skip locked encrypted notes) */if(form.dataset.encrypted!=='1'){var _mobileRO=_tinymceReadonlyDefault();_editorMode=(_mobileRO?false:_defaultNoteOpenMode==='markdown')?'markdown':'rich';_tinymceReadonly=_mobileRO;syncEditorModeButtons();if(_editorMode==='markdown'){hideTinyMCEHost();applyEditorModeVisibility('markdown');var mdta=getTA();mountMarkdownEditor(mdta?mdta.value:'');initPersistentTinyMCE()}else{initPersistentTinyMCE();refreshTinyMCEForActiveNote()}}else{hideTinyMCEHost()}}
|
||
function applySearchHighlight(){var term=activeSearchTerm();var bar=document.getElementById('search-nav-bar');if(bar)bar.hidden=true;_searchMarks=[];_searchMarkIdx=0;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);if(!term||!term.trim()){clearCodeMirrorSearch();return}term=term.trim();if(_editorMode==='preview'&&pv){clearCodeMirrorSearch();var savedHandler=pv.oninput;pv.oninput=null;highlightInPreview(pv,term);pv.oninput=savedHandler}else if(_editorMode==='markdown'&&_cmView&&window.CM&&window.CM.SearchQuery&&window.CM.setSearchQuery){ window.CM.openSearchPanel(_cmView);var q=new window.CM.SearchQuery({search:term,caseSensitive:false});_cmView.dispatch({effects:window.CM.setSearchQuery.of(q)});_cmSearchMatches=collectCodeMirrorSearchMatches(q);if(_cmSearchMatches.length)setCodeMirrorSearchActive(0);else searchNavShow(0,0)}}
|
||
function escapeRegex(s){var specials=['.','+','*','?','^','$','(',')','{','}','[',']','|','\\'];return s.split('').map(function(c){return specials.indexOf(c)>=0?'\\'+c:c}).join('')}
|
||
var _searchMarks=[];var _searchMarkIdx=0;
|
||
function searchNavShow(total,idx){var bar=document.getElementById('search-nav-bar');var counter=document.getElementById('search-nav-counter');if(bar){if(total===0){bar.hidden=true}else{bar.hidden=false;if(counter)counter.textContent=(idx+1)+' / '+total}}var mobileCounter=document.getElementById('mobile-search-nav-counter');var mobilePrev=document.getElementById('mobile-search-prev-btn');var mobileNext=document.getElementById('mobile-search-next-btn');if(mobileCounter){mobileCounter.hidden=total===0;if(total>0)mobileCounter.textContent=(idx+1)+' / '+total}if(mobilePrev)mobilePrev.hidden=total===0;if(mobileNext)mobileNext.hidden=total===0}
|
||
function searchNavSetActive(idx){_searchMarks.forEach(function(m,i){m.classList.toggle('search-highlight-active',i===idx)});var m=_searchMarks[idx];if(m)m.scrollIntoView({block:'center',behavior:'smooth'})}
|
||
function searchNavStep(dir){if(_editorMode==='markdown'&&_cmSearchMatches.length){setCodeMirrorSearchActive(_searchMarkIdx+dir);return}if(!_searchMarks.length)return;_searchMarkIdx=(_searchMarkIdx+dir+_searchMarks.length)%_searchMarks.length;searchNavSetActive(_searchMarkIdx);searchNavShow(_searchMarks.length,_searchMarkIdx)}
|
||
function searchNavDismiss(){var bar=document.getElementById('search-nav-bar');var mobileCounter=document.getElementById('mobile-search-nav-counter');var mobilePrev=document.getElementById('mobile-search-prev-btn');var mobileNext=document.getElementById('mobile-search-next-btn');if(bar)bar.hidden=true;if(mobileCounter)mobileCounter.hidden=true;if(mobilePrev)mobilePrev.hidden=true;if(mobileNext)mobileNext.hidden=true;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);_searchMarks=[];_searchMarkIdx=0;clearCodeMirrorSearch()}
|
||
function highlightInPreview(pv,term){if(!pv||!term)return;_searchMarks=[];_searchMarkIdx=0;var walker=document.createTreeWalker(pv,NodeFilter.SHOW_TEXT,{acceptNode:function(n){return n.parentElement&&n.parentElement.closest('script,style,mark')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}},false);var nodes=[];var node;while((node=walker.nextNode()))nodes.push(node);var re=new RegExp(escapeRegex(term),'gi');nodes.forEach(function(n){var matches=[];var m;re.lastIndex=0;while((m=re.exec(n.textContent))!==null)matches.push({start:m.index,end:m.index+m[0].length});if(!matches.length)return;var frag=document.createDocumentFragment();var last=0;matches.forEach(function(r){if(r.start>last)frag.appendChild(document.createTextNode(n.textContent.slice(last,r.start)));var mark=document.createElement('mark');mark.className='search-highlight';mark.textContent=n.textContent.slice(r.start,r.end);_searchMarks.push(mark);frag.appendChild(mark);last=r.end});if(last<n.textContent.length)frag.appendChild(document.createTextNode(n.textContent.slice(last)));n.parentNode.replaceChild(frag,n)});if(_searchMarks.length){searchNavSetActive(0);searchNavShow(_searchMarks.length,0)}else{searchNavShow(0,0)}}
|
||
function initNavPanel(){_log('initNavPanel');var state=navFolderState();var selectedEl=document.querySelector('.nav-folder[data-selected="1"]');var hasSelected=!!selectedEl;var selectedId=selectedEl?selectedEl.getAttribute('data-folder-id'):'';document.querySelectorAll('.nav-folder').forEach(function(el){var id=el.getAttribute('data-folder-id');var selected=el.getAttribute('data-selected')==='1';var isAllNotes=el.getAttribute('data-all-notes')==='1';var open=state[id]===true||state[id]==='1'||state[id]===1;if(state[id]===undefined){// No explicit state: use heuristics
|
||
if(isAllNotes)open=!hasSelected||(selectedId===id);else if(selected)open=true;else open=false;}// Always trust explicit localStorage state — do not override with data-selected or all-notes heuristics
|
||
el.classList.toggle('collapsed',!open);// Lazy-load if expanded and not yet loaded
|
||
if(open){var notesDiv=el.querySelector('.nav-folder-notes[data-folder-id]');if(notesDiv&&!notesDiv.getAttribute('data-loaded')){notesDiv.setAttribute('data-loaded','1');var folderId=notesDiv.getAttribute('data-folder-id');htmx.ajax('GET','/fragments/folder-notes?folderId='+encodeURIComponent(folderId),{target:notesDiv,swap:'innerHTML'})}}})}
|
||
var _folderSelectValue=null;var _folderSelectNoteId=null;
|
||
document.body.addEventListener('htmx:beforeSwap',function(e){var sel=document.getElementById('editor-folder-select');var form=document.getElementById('note-editor-form');if(sel){_folderSelectValue=sel.value;_folderSelectNoteId=form?form.getAttribute('hx-put'):''}var target=e.detail&&e.detail.target;if(target&&(target.id==='editor-panel'||target.id==='mobile-editor-body')){hideTinyMCEHost()}});
|
||
document.body.addEventListener('htmx:afterSettle',function(){initNavPanel();initEditorPanel();refreshAllVaultIcons();positionTinyMCEHost();
|
||
if(_folderSelectValue){var sel=document.getElementById('editor-folder-select');var form=document.getElementById('note-editor-form');var currentNoteId=form?form.getAttribute('hx-put'):'';if(sel&¤tNoteId&¤tNoteId===_folderSelectNoteId){sel.value=_folderSelectValue}_folderSelectValue=null;_folderSelectNoteId=null}});
|
||
// Also refresh on initial SSR page load (htmx:afterSettle only fires after htmx swaps)
|
||
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){refreshAllVaultIcons();initPersistentTinyMCE();setTimeout(positionTinyMCEHost,0)})}else{refreshAllVaultIcons();initPersistentTinyMCE();setTimeout(positionTinyMCEHost,0)}
|
||
document.body.addEventListener('htmx:confirm',function(e){var elt=e.detail&&e.detail.elt;if(!elt)return;var msg=elt.getAttribute('data-confirm-trash');if(msg){e.preventDefault();if(_cfg.confirmTrash===false){e.detail.issueRequest(true);return}if(confirm(msg))e.detail.issueRequest(true)}});
|
||
function showNoteOverlay(){var o=document.getElementById('note-loading-overlay');if(o)o.classList.add('active')}
|
||
function hideNoteOverlay(){var o=document.getElementById('note-loading-overlay');if(o)o.classList.remove('active')}
|
||
document.body.addEventListener('click',function(e){var btn=e.target.closest('.notelist-item');if(btn&&!e.defaultPrevented)showNoteOverlay()},true);
|
||
document.body.addEventListener('htmx:beforeRequest',function(e){var elt=e.detail&&e.detail.elt;_log('htmx:beforeRequest',elt&&elt.id,elt&&elt.getAttribute&&elt.getAttribute('hx-get'),elt&&elt.getAttribute&&elt.getAttribute('hx-put'));});
|
||
document.body.addEventListener('htmx:afterRequest',function(e){var xhr=e.detail&&e.detail.xhr;_log('htmx:afterRequest',e.detail&&e.detail.successful,xhr&&xhr.status,xhr&&typeof xhr.responseText==='string'?xhr.responseText.slice(0,120):'');var elt=e.detail&&e.detail.elt;if(e.detail&&e.detail.successful){invalidateNotesCache()}if(elt&&elt.classList&&elt.classList.contains('notelist-item')&&!e.detail.successful)hideNoteOverlay();if(elt&&elt.id==='note-editor-form'&&e.detail.successful){var conflict=xhr&&xhr.getResponseHeader&&xhr.getResponseHeader('X-Note-Conflict')==='1';if(conflict){// Server rejected the save because the row moved underneath us. Do NOT
|
||
// snapshotHash (edits are still pending), do NOT overwrite the conflict UI
|
||
// that just got swapped into #autosave-status, and surface the prominent
|
||
// banner so the user can't miss it.
|
||
_log('afterRequest detected save conflict');showRemoteUpdateBanner('changed')}else{snapshotHash();pushSnapshot();setSaveState('<span class="autosave-ok">Saved</span>','Saved');dismissRemoteUpdateBanner();_log('afterRequest snapshotHash after save')}}if(e.detail&&e.detail.successful&&document.body.classList.contains('is-offline')){clearOffline()}});
|
||
document.body.addEventListener('htmx:afterSwap',function(e){var target=e.detail&&e.detail.target;_log('htmx:afterSwap',target&&target.id);if(target&&(target.id==='editor-panel'||target.id==='mobile-editor-body')){hideNoteOverlay();dismissRemoteUpdateBanner();if(_cmView){_cmView.destroy();_cmView=null}_searchMarks=[];_searchMarkIdx=0;/* Persistent TinyMCE: no destroy; reposition + refresh on next tick */setTimeout(positionTinyMCEHost,0)}});
|
||
function showOffline(){setSaveState('<span class="autosave-offline">Offline</span>','Offline');document.body.classList.add('is-offline');_log('offline indicator shown');showDisconnected()}
|
||
function clearOffline(){document.body.classList.remove('is-offline');_log('offline indicator cleared')}
|
||
document.body.addEventListener('htmx:sendError',function(e){var elt=e.detail&&e.detail.elt;_log('htmx:sendError',elt&&elt.id);if(elt&&elt.id==='note-editor-form')showOffline()});
|
||
document.body.addEventListener('htmx:responseError',function(e){var elt=e.detail&&e.detail.elt;var xhr=e.detail&&e.detail.xhr;_log('htmx:responseError',elt&&elt.id,xhr&&xhr.status);if(xhr&&xhr.status===401){_log('htmx 401, session invalid, logging out');window.location.assign('/logout');return;}if(elt&&elt.id==='note-editor-form')showOffline()});
|
||
// --- Disconnected overlay (server unreachable) ---
|
||
var _dcFailCount=0;
|
||
var _dcFailThreshold=1;
|
||
var _dcRetryIntervalSec=15;
|
||
var _dcRetryCountdown=0;
|
||
var _dcRetryTimer=null;
|
||
var _dcOverlay=null;
|
||
var _dcVisible=false;
|
||
|
||
function _createDcOverlay(){
|
||
if(_dcOverlay)return _dcOverlay;
|
||
var o=document.createElement('div');
|
||
o.className='disconnected-overlay';
|
||
o.innerHTML='<img src="/icon.svg" class="disconnected-logo" alt="" />'
|
||
+'<div class="disconnected-card">'
|
||
+'<div class="disconnected-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">'
|
||
+'<path d="M6.5 19H5a4 4 0 0 1-.98-7.88A5.5 5.5 0 0 1 15.9 8.7 4 4 0 0 1 19 16h-1"/>'
|
||
+'<line x1="2" y1="2" x2="22" y2="22"/></svg></div>'
|
||
+'<div class="disconnected-title">Connection lost</div>'
|
||
+'<div class="disconnected-sub">Unable to reach the server.</div>'
|
||
+'<div class="disconnected-countdown" id="dc-countdown"></div>'
|
||
+'<div class="disconnected-actions">'
|
||
+'<button class="disconnected-retry" id="dc-retry-btn" type="button">Retry now</button>'
|
||
+'<button class="disconnected-logout" type="button" onclick="window.location.assign(\'/logout\')">Log out</button>'
|
||
+'</div></div>';
|
||
document.body.appendChild(o);
|
||
o.querySelector('#dc-retry-btn').addEventListener('click',_dcRetryNow);
|
||
_dcOverlay=o;
|
||
return o;
|
||
}
|
||
|
||
function _dcUpdateCountdown(){
|
||
var el=document.getElementById('dc-countdown');
|
||
if(el)el.textContent='Retrying in '+_dcRetryCountdown+'s\u2026';
|
||
}
|
||
|
||
function _dcPing(){
|
||
_log('connectivity ping');
|
||
return fetch('/heartbeat',{method:'POST',credentials:'same-origin'}).then(function(r){
|
||
if(r.status===401){
|
||
_log('ping 401, session expired');
|
||
window.location.assign('/logout');
|
||
return false;
|
||
}
|
||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function showDisconnected(){
|
||
if(_dcVisible)return;
|
||
_dcVisible=true;
|
||
_log('showDisconnected');
|
||
var o=_createDcOverlay();
|
||
o.style.display='';
|
||
document.body.classList.add('is-disconnected');
|
||
_dcRetryCountdown=_dcRetryIntervalSec;
|
||
_dcUpdateCountdown();
|
||
if(_dcRetryTimer)clearInterval(_dcRetryTimer);
|
||
_dcRetryTimer=setInterval(function(){
|
||
_dcRetryCountdown--;
|
||
if(_dcRetryCountdown<=0){
|
||
_dcRetryCountdown=_dcRetryIntervalSec;
|
||
_dcPing().then(function(ok){if(ok)clearDisconnected()}).catch(function(){});
|
||
}
|
||
_dcUpdateCountdown();
|
||
},1000);
|
||
}
|
||
|
||
function clearDisconnected(){
|
||
if(!_dcVisible)return;
|
||
_dcVisible=false;
|
||
_dcFailCount=0;
|
||
_log('clearDisconnected, reconnected');
|
||
if(_dcOverlay)_dcOverlay.style.display='none';
|
||
document.body.classList.remove('is-disconnected');
|
||
if(_dcRetryTimer){clearInterval(_dcRetryTimer);_dcRetryTimer=null}
|
||
clearOffline();
|
||
// Re-save if dirty
|
||
var status=queryActiveEditor('#autosave-status');
|
||
var dirty=status&&status.querySelector('.autosave-edited');
|
||
if(dirty){_log('clearDisconnected: re-saving dirty note');scheduleSave()}
|
||
}
|
||
|
||
function _dcRetryNow(){
|
||
var btn=document.getElementById('dc-retry-btn');
|
||
if(btn){btn.disabled=true;btn.textContent='Connecting\u2026'}
|
||
_dcRetryCountdown=_dcRetryIntervalSec;
|
||
_dcPing().then(function(ok){if(ok)clearDisconnected()}).catch(function(){});
|
||
setTimeout(function(){if(btn){btn.disabled=false;btn.textContent='Retry now'}},2000);
|
||
}
|
||
|
||
function _dcOnFetchFail(){
|
||
_dcFailCount++;
|
||
if(_dcFailCount>=_dcFailThreshold)showDisconnected();
|
||
}
|
||
|
||
function _dcOnFetchOk(){
|
||
_dcFailCount=0;
|
||
if(_dcVisible)clearDisconnected();
|
||
}
|
||
|
||
window.addEventListener('online',function(){_log('browser online event');if(_dcVisible){_dcPing().then(function(ok){if(ok)clearDisconnected()}).catch(function(){})}if(document.body.classList.contains('is-offline')){var s=document.getElementById('autosave-status');var dirty=s&&s.querySelector('.autosave-edited');if(dirty){scheduleSave()}else if(s){setSaveState('<span class="autosave-ok">Reconnected</span>','Saved')}clearOffline()}});
|
||
window.addEventListener('offline',function(){_log('browser offline event');showDisconnected()});
|
||
// --- Cross-browser note sync (passive freshness probe) ---
|
||
// On each connectivity-ping tick (and on tab-visible), ask the server for the
|
||
// current note's updatedTime. If it advanced past our baseUpdatedTime, the
|
||
// note was edited in another browser/device. When the editor is clean, we
|
||
// silently swap in the fresh fragment. When dirty, we surface a banner so the
|
||
// user can choose to discard their edits or keep them (the existing Conflict
|
||
// flow then handles the save).
|
||
var _noteFreshnessBusy=false;
|
||
function _activeEditorNoteId(){var form=activeEditorForm();if(!form)return '';var hx=form.getAttribute('hx-put')||'';var m=hx.match(/\/fragments\/editor\/([0-9a-zA-Z]{32})/);return m?m[1]:''}
|
||
function _activeEditorCurrentFolderId(){var form=activeEditorForm();if(!form)return '';var el=form.querySelector('[name="currentFolderId"]');return el?el.value:''}
|
||
function _activeEditorBaseUpdatedTime(){var form=activeEditorForm();if(!form)return 0;var el=form.querySelector('[name="baseUpdatedTime"]');return el?Number(el.value||0):0}
|
||
// Dirty = there are user edits that differ from what we last saved/loaded.
|
||
// Don't trust the transient autosave-status text (which flips to "Saved" after
|
||
// each successful autosave even if the server has since moved past us). Use
|
||
// the durable signals instead:
|
||
// - _previewDirty / _pvSyncTimer: preview-mode edits not yet flushed to textarea
|
||
// - title contenteditable: text differs from hidden input
|
||
// - formHash != _savedHash: textarea/folder/title fields differ from last save
|
||
function _activeEditorIsDirty(){var form=activeEditorForm();if(!form)return false;if(_previewDirty)return true;if(typeof _pvSyncTimer!=='undefined'&&_pvSyncTimer)return true;var ti=form.querySelector('.editor-title');var hi=form.querySelector('.editor-title-hidden');if(ti&&hi){var raw=ti.textContent||'';if(typeof stripMdForTitle==='function'){if(stripMdForTitle(raw)!==(hi.value||''))return true}else if(raw!==(hi.value||''))return true}return formHash(form)!==_savedHash}
|
||
function dismissRemoteUpdateBanner(){var bar=document.getElementById('remote-update-bar');if(bar)bar.hidden=true}
|
||
function showRemoteUpdateBanner(kind){var bar=document.getElementById('remote-update-bar');if(!bar)return;var text=document.getElementById('remote-update-text');var useBtn=document.getElementById('remote-update-use-server-btn');var owBtn=document.getElementById('remote-update-overwrite-btn');if(kind==='deleted'){if(text)text.textContent='This note was deleted in another window.';if(useBtn)useBtn.hidden=true;if(owBtn)owBtn.hidden=true}else{if(text)text.textContent='A newer version of this note exists on the server.';if(useBtn)useBtn.hidden=false;if(owBtn)owBtn.hidden=false}bar.hidden=false}
|
||
function reloadCurrentNoteFromServer(){var noteId=_activeEditorNoteId();if(!noteId)return;var folderId=_activeEditorCurrentFolderId();var targetSel=inMobileEditor()?'#mobile-editor-body':'#editor-panel';var target=document.querySelector(targetSel);if(!target)return;dismissRemoteUpdateBanner();var url='/fragments/editor/'+encodeURIComponent(noteId)+(folderId?'?currentFolderId='+encodeURIComponent(folderId):'');_log('reloadCurrentNoteFromServer',url);htmx.ajax('GET',url,{target:targetSel,swap:'innerHTML'}).catch(function(){})}
|
||
function overwriteWithLocalEdits(){var form=activeEditorForm();if(!form)return;dismissRemoteUpdateBanner();// Sync preview/CM into the textarea so the body in the form is current
|
||
var pv=getPV();if(pv)syncPV();else if(_editorMode!=='markdown'&&_editorMode!=='md')tinyMCESyncToTA();syncTitleToHidden({silent:true});// Set forceSave=1 so the server skips the optimistic-concurrency guard
|
||
var fs=form.querySelector('[name="forceSave"]');if(fs)fs.value='1';setSaveState('<span class="autosave-saving">Saving...</span>','Saving...');_log('overwriteWithLocalEdits forcing save');htmx.trigger(form,'joplock:save')}
|
||
function checkNoteFreshness(){if(_noteFreshnessBusy)return;var noteId=_activeEditorNoteId();if(!noteId)return;if(document.hidden)return;if(_anyModalOpen())return;var form=activeEditorForm();if(!form||form.dataset.encrypted==='1')return;// Skip while a vault note is locked or unlock UI is showing
|
||
if(form.dataset.unlocking==='1')return;var base=_activeEditorBaseUpdatedTime();if(!base)return;// Editor doesn't have a baseline yet (fresh new note); nothing to compare
|
||
_noteFreshnessBusy=true;fetch('/api/web/notes/'+encodeURIComponent(noteId)+'/freshness',{credentials:'same-origin',cache:'no-store'}).then(function(r){if(r.status===404){showRemoteUpdateBanner('deleted');return null}if(!r.ok)return null;return r.json()}).then(function(data){if(!data)return;if(_activeEditorNoteId()!==noteId)return;var remote=Number(data.updatedTime||0);if(!remote||remote<=base)return;// Remote advanced past our baseline
|
||
if(data.deletedTime&&data.deletedTime>0){showRemoteUpdateBanner('deleted');return}if(_activeEditorIsDirty()){showRemoteUpdateBanner('changed');return}// Clean editor: silently reload from server
|
||
reloadCurrentNoteFromServer()}).catch(function(){}).then(function(){_noteFreshnessBusy=false})}
|
||
// Always-on connectivity ping (every 30s) — triggers disconnected overlay on failure and probes note freshness
|
||
(function(){var _cpMs=30000;function _connectivityPing(){_dcPing().then(function(ok){if(ok){_dcOnFetchOk();checkNoteFreshness()}else _dcOnFetchFail()}).catch(function(){_dcOnFetchFail()})}var _cpInterval=setInterval(_connectivityPing,_cpMs);_connectivityPing()})();
|
||
document.addEventListener('visibilitychange',function(){if(document.hidden){_log('visibilitychange hidden, flushing dirty note');flushSave(function(){})}else{checkNoteFreshness()}});
|
||
window.addEventListener('load',function(){if(isMobileShellMode())return;initNavPanel();initEditorPanel()});
|
||
window.addEventListener('resize',applyMobileTitleMode);
|
||
document.addEventListener('keydown',function(e){var mac=navigator.platform&&navigator.platform.indexOf('Mac')!==-1;var mod=mac?e.metaKey:e.ctrlKey;if(mod&&e.shiftKey&&e.key.toLowerCase()==='z'){e.preventDefault();undoSnapshot()}});
|
||
function flushSave(callback){var form=activeEditorForm();if(!form){_log('flushSave skip (no form)');if(callback)callback(true);return}if(_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}if(_saveTitleTimer){clearTimeout(_saveTitleTimer);_saveTitleTimer=null}if(_pvSyncTimer){clearTimeout(_pvSyncTimer);_pvSyncTimer=null;_syncPVInFlight=true;syncPV();_syncPVInFlight=false}else{var pv=getPV();if(pv&&_previewDirty)syncPV();else if(!pv&&_editorMode!=='markdown'&&_editorMode!=='md')tinyMCESyncToTA()}syncTitleToHidden({silent:true});var h=formHash(form);if(h===_savedHash){_log('flushSave skip (hash unchanged)',h);if(callback)callback(true);return}setSaveState('<span class="autosave-saving">Saving...</span>','Saving...');var restoreReq=function(){};buildFlushRequest(form).then(function(req){if(!req){if(callback)callback(true);return}restoreReq=req.restore||restoreReq;_log('flushSave',req.url);return fetch(req.url,{method:'PUT',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:req.body}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.text()}).then(function(html){restoreReq();_log('flushSave ok',html.slice(0,80));snapshotHash();window._mobileNewNoteId=null;setSaveState('<span class="autosave-ok">Saved</span>','Saved');if(callback)callback(true)})}).catch(function(err){restoreReq();_log('flushSave error',err);showOffline();if(callback)callback(false)})}
|
||
function shouldInterceptNavigationClick(target){var navTarget=target&&target.closest?target.closest('.notelist-item,.sidebar-item,.nav-folder-row,[hx-get],[hx-post],[hx-delete]'):null;if(!navTarget)return null;if(navTarget.closest&&navTarget.closest('#note-editor-form'))return null;if(navTarget.closest&&navTarget.closest('#folder-context-menu,#folder-modal,#link-modal,#history-modal,#code-modal,#new-folder-modal,#vault-modal,#empty-trash-modal'))return null;return navTarget}
|
||
document.addEventListener('click',function(e){var navTarget=shouldInterceptNavigationClick(e.target);if(!navTarget)return;var form=activeEditorForm();var status=queryActiveEditor('#autosave-status');var dirty=status&&status.querySelector('.autosave-edited');if(!form||!dirty)return;_log('navigation click intercepted, flushing save',navTarget.className||navTarget.id||navTarget.tagName);e.preventDefault();e.stopImmediatePropagation();flushSave(function(saved){if(saved){_log('flushSave done, re-clicking navigation target');navTarget.click()}})},true);
|
||
window.joplockLiveSearch=_cfg.liveSearch||false;
|
||
(function(){var _navSearchSavedValue=null;function enableLiveSearch(){var el=document.getElementById('nav-search');if(!el||!window.joplockLiveSearch||el.dataset.liveSearch)return;el.dataset.liveSearch='1';el.setAttribute('hx-trigger','search-submit, input changed delay:300ms');el.addEventListener('htmx:beforeRequest',function(e){var v=el.value;if(v.length>0&&v.length<3){e.preventDefault();return}});htmx.process(el)}function restoreNavSearch(){if(_navSearchSavedValue===null)return;var el=document.getElementById('nav-search');if(!el){_navSearchSavedValue=null;return;}el.value=_navSearchSavedValue;el.selectionStart=el.selectionEnd=el.value.length;_navSearchSavedValue=null}enableLiveSearch();document.body.addEventListener('htmx:beforeSwap',function(e){var target=e.detail&&e.detail.target;if(target&&target.id==='nav-panel'){var el=document.getElementById('nav-search');if(el)_navSearchSavedValue=el.value}});document.body.addEventListener('htmx:afterSettle',function(){enableLiveSearch();restoreNavSearch()})})();
|
||
function confirmLogout(event){
|
||
var ok=window.confirm('Log out?\n\nThis clears local data on this device, including the current session and saved UI state. Your notes and other server data remain on the server.');
|
||
if(!ok&&event)event.preventDefault();
|
||
return ok;
|
||
}
|
||
// --- Mobile navigation ---
|
||
// SINGLE-SCREEN INVARIANT: Exactly one .mobile-screen carries .mobile-screen-active at any time.
|
||
// All transitions MUST go through setMobileState(). Direct DOM toggling is forbidden.
|
||
// renderMobile() is the only function that writes .mobile-screen-active and screen-driven UI
|
||
// (titles, FAB). assertSingleActiveScreen() enforces the invariant after every render.
|
||
(function(){
|
||
// Canonical state. Mutated only by setMobileState() (which calls renderMobile()).
|
||
var _state={screen:'folders',folderId:'',folderTitle:'',noteId:'',noteTitle:''};
|
||
var _prevRenderedScreen=null;
|
||
var _mobileInitDone=false;
|
||
var _lastSyncWasMobile=null;// null=first call, true/false=previous syncResponsiveMode result
|
||
function isMobile(){return isMobileShellMode()}
|
||
function mobileScreenId(name){return'mobile-'+name+'-screen'}
|
||
function assertSingleActiveScreen(){
|
||
var active=document.querySelectorAll('.mobile-screen.mobile-screen-active');
|
||
if(active.length===1)return;
|
||
_trace('mobile-invariant-violation',{count:active.length,expected:_state.screen,ids:Array.prototype.map.call(active,function(e){return e.id})});
|
||
// Self-heal: force exactly one active.
|
||
var screens=['folders','notes','editor'];
|
||
screens.forEach(function(s){
|
||
var el=document.getElementById(mobileScreenId(s));
|
||
if(el)el.classList.toggle('mobile-screen-active',s===_state.screen);
|
||
});
|
||
}
|
||
// The ONLY function that writes .mobile-screen-active and screen-driven UI.
|
||
function renderMobile(){
|
||
if(_prevRenderedScreen!==null&&_prevRenderedScreen!==_state.screen)hideRenderAutocompletePopup();
|
||
var screens=['folders','notes','editor'];
|
||
screens.forEach(function(s){
|
||
var el=document.getElementById(mobileScreenId(s));
|
||
if(!el)return;
|
||
el.classList.remove('mobile-screen-left','mobile-screen-right');
|
||
el.classList.toggle('mobile-screen-active',s===_state.screen);
|
||
});
|
||
// Hide the persistent TinyMCE host and reparented toolbar whenever the editor screen is not active.
|
||
if(_state.screen!=='editor'){hideTinyMCEHost()}else{setTimeout(positionTinyMCEHost,0)}
|
||
// Titles
|
||
var notesTitle=document.getElementById('mobile-notes-title');
|
||
if(notesTitle&&_state.folderTitle)notesTitle.textContent=_state.folderTitle;
|
||
var editorTitle=document.getElementById('mobile-editor-title');
|
||
if(editorTitle&&_state.noteTitle&&_prevRenderedScreen!=='editor')editorTitle.textContent=_state.noteTitle;
|
||
// FAB
|
||
var fab=document.getElementById('mobile-fab');
|
||
if(fab){
|
||
var fabVisible=_state.screen==='folders'||_state.screen==='notes';
|
||
fab.style.display=fabVisible?'flex':'none';
|
||
if(!fabVisible)mobileFabClose();
|
||
}
|
||
// Editor search header should not persist across screen changes
|
||
if(_state.screen!=='editor'&&_prevRenderedScreen==='editor'){
|
||
window.mobileEditorSearchClose&&window.mobileEditorSearchClose();
|
||
}
|
||
_prevRenderedScreen=_state.screen;
|
||
assertSingleActiveScreen();
|
||
}
|
||
// THE one entry point for all mobile screen transitions.
|
||
function setMobileState(patch){
|
||
if(!patch)return;
|
||
Object.keys(patch).forEach(function(k){_state[k]=patch[k]});
|
||
renderMobile();
|
||
}
|
||
// Read-only state access for debugging.
|
||
window.joplockMobileState=function(){return JSON.parse(JSON.stringify(_state))};
|
||
window.mobilePushNotes=function(folderId,folderTitle){
|
||
if(!isMobile())return;
|
||
setMobileState({screen:'notes',folderId:folderId,folderTitle:folderTitle||'Notes'});
|
||
var body=document.getElementById('mobile-notes-body');if(body)body.innerHTML='<div class="empty-hint" style="padding:16px">Loading...</div>';
|
||
htmx.ajax('GET','/fragments/mobile/notes?folderId='+encodeURIComponent(folderId),{target:'#mobile-notes-body',swap:'innerHTML'});
|
||
};
|
||
window.mobilePushEditor=function(noteId,folderId){
|
||
if(!isMobile())return;
|
||
setMobileState({screen:'editor',noteId:noteId,folderId:folderId||_state.folderId});
|
||
_trace('mobilePushEditor-start',{noteId:noteId,folderId:folderId||_state.folderId});
|
||
var body=document.getElementById('mobile-editor-body');if(body)body.innerHTML='<div class="editor-empty mobile-loading-note"><div class="note-loading-ring"></div></div>';
|
||
htmx.ajax('GET','/fragments/editor/'+encodeURIComponent(noteId)+'?currentFolderId='+encodeURIComponent(folderId||_state.folderId),{target:'#mobile-editor-body',swap:'innerHTML'}).then(function(){_trace('mobilePushEditor-ok',{noteId:noteId});hideNoteOverlay()}).catch(function(err){_trace('mobilePushEditor-err',{noteId:noteId,error:err&&err.message?err.message:String(err)});hideNoteOverlay()});
|
||
};
|
||
// Back-navigation: deterministic editor->notes->folders.
|
||
function mobileBack(){
|
||
if(_state.screen==='editor'){
|
||
setMobileState({screen:_state.folderId?'notes':'folders'});
|
||
return'notes-or-folders';
|
||
}
|
||
if(_state.screen==='notes'){
|
||
setMobileState({screen:'folders'});
|
||
return'folders';
|
||
}
|
||
return'folders';
|
||
}
|
||
window.mobilePopScreen=function(){
|
||
if(!isMobile())return;
|
||
var prev=_state.screen;
|
||
var dest=mobileBack();
|
||
if(prev==='editor'&&dest==='folders'){
|
||
// flush any dirty save when leaving editor
|
||
flushSave(function(){})
|
||
}
|
||
};
|
||
window.mobileEditorBack=function(){
|
||
var form=document.getElementById('note-editor-form');
|
||
if(form&&form.dataset.encrypted==='1'){
|
||
setMobileState({screen:'folders'});
|
||
return;
|
||
}
|
||
var titleEl=form&&form.querySelector('.editor-title');
|
||
var bodyEl=form&&form.querySelector('#note-body');
|
||
var noteId=_state.noteId;
|
||
var title=((titleEl&&titleEl.textContent)||'').trim();
|
||
var body=((bodyEl&&bodyEl.value)||'').trim();
|
||
var shouldDiscard=!!(window._mobileNewNoteId&¬eId===window._mobileNewNoteId&&!body&&(title===''||title==='Untitled note'));
|
||
if(shouldDiscard){
|
||
fetch('/fragments/notes/'+encodeURIComponent(noteId),{method:'DELETE',headers:{'hx-request':'true','hx-params':'none'}})
|
||
.then(function(){window._mobileNewNoteId=null;mobileRefreshNotes();mobilePopScreen()})
|
||
.catch(function(){mobilePopScreen()});
|
||
return;
|
||
}
|
||
flushSave(function(){mobileRefreshNotes();mobilePopScreen()});
|
||
};
|
||
// Wire mobile delete button after editor loads
|
||
function wireMobileDeleteBtn(noteId,isDeleted){
|
||
var btn=document.getElementById('mobile-delete-btn');
|
||
if(!btn)return;
|
||
btn.onclick=function(){
|
||
var msg=isDeleted?'Permanently delete this note?':'Move this note to trash?';
|
||
if(!confirm(msg))return;
|
||
fetch('/fragments/notes/'+encodeURIComponent(noteId),{method:'DELETE',headers:{'hx-request':'true','hx-params':'none'}})
|
||
.then(function(){mobilePopScreen();mobileRefreshNotes()});
|
||
};
|
||
}
|
||
function mobileRefreshNotes(){
|
||
if(_state.folderId){
|
||
var body=document.getElementById('mobile-notes-body');
|
||
if(body)htmx.ajax('GET','/fragments/mobile/notes?folderId='+encodeURIComponent(_state.folderId),{target:'#mobile-notes-body',swap:'innerHTML'});
|
||
}
|
||
}
|
||
window.mobileNewNote=function(){
|
||
var fid=_state.screen==='notes'?_state.folderId:'';
|
||
htmx.ajax('POST','/fragments/mobile/notes/new',{target:'#mobile-notes-body',swap:'innerHTML',values:{folderId:fid||''}});
|
||
};
|
||
window.mobileFabOpen=function(){
|
||
if(_state.screen==='notes') return mobileNewNote();
|
||
var b=document.getElementById('mobile-fab-menu-backdrop');
|
||
var m=document.getElementById('mobile-fab-menu');
|
||
if(b)b.style.display='';
|
||
if(m)m.style.display='';
|
||
};
|
||
window.mobileFabClose=function(){
|
||
var b=document.getElementById('mobile-fab-menu-backdrop');
|
||
var m=document.getElementById('mobile-fab-menu');
|
||
if(b)b.style.display='none';
|
||
if(m)m.style.display='none';
|
||
};
|
||
window.mobileFabNewNote=function(){
|
||
mobileFabClose();
|
||
setMobileState({screen:'notes',folderId:'__all__',folderTitle:'All Notes'});
|
||
mobileNewNote();
|
||
};
|
||
window.mobileFabNewFolder=function(){
|
||
mobileFabClose();
|
||
openNewFolderModal('mobile');
|
||
};
|
||
window.mobileNewNoteInFolder=function(folderId,folderTitle,event){
|
||
if(event){event.preventDefault();event.stopPropagation();}
|
||
setMobileState({screen:'notes',folderId:folderId,folderTitle:folderTitle||'Notes'});
|
||
mobileNewNote();
|
||
};
|
||
// Context menu (long-press on note rows)
|
||
var _ctxNoteId=null,_ctxNoteTitle=null,_ctxLongPressTimer=null;
|
||
function mobileCtxOpen(noteId,noteTitle){
|
||
_ctxNoteId=noteId;_ctxNoteTitle=noteTitle;
|
||
var backdrop=document.getElementById('mobile-ctx-backdrop');
|
||
var sheet=document.getElementById('mobile-ctx-sheet');
|
||
var titleEl=document.getElementById('mobile-ctx-title');
|
||
var metaEl=document.getElementById('mobile-ctx-meta');
|
||
var moveBtn=document.getElementById('mobile-ctx-move');
|
||
var delBtn=document.getElementById('mobile-ctx-delete');
|
||
if(titleEl)titleEl.textContent=noteTitle||'Untitled';
|
||
if(metaEl){
|
||
var mbody=document.getElementById('mobile-editor-body');
|
||
var metaSrc=mbody?mbody.querySelector('#note-meta'):null;
|
||
if(!metaSrc)metaSrc=document.getElementById('status-note-meta');
|
||
var c=metaSrc?Number(metaSrc.getAttribute('data-created-time')||0):0;
|
||
var u=metaSrc?Number(metaSrc.getAttribute('data-updated-time')||0):0;
|
||
if(c||u){
|
||
var months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
var fmt=function(ts){if(!ts)return '';var d=new Date(ts);return String(d.getDate()).padStart(2,'0')+'-'+months[d.getMonth()]+'-'+String(d.getFullYear()).slice(-2)};
|
||
metaEl.textContent='Created '+fmt(c)+' \u2022 Edited '+fmt(u);
|
||
metaEl.style.display='';
|
||
}else{
|
||
metaEl.textContent='';
|
||
metaEl.style.display='none';
|
||
}
|
||
}
|
||
if(moveBtn)moveBtn.onclick=function(){mobileCtxMove()};
|
||
if(delBtn)delBtn.onclick=function(){mobileCtxDelete()};
|
||
if(backdrop)backdrop.style.display='';
|
||
if(sheet)sheet.style.display='';
|
||
}
|
||
window.mobileCtxClose=function(){
|
||
var backdrop=document.getElementById('mobile-ctx-backdrop');
|
||
var sheet=document.getElementById('mobile-ctx-sheet');
|
||
if(backdrop)backdrop.style.display='none';
|
||
if(sheet)sheet.style.display='none';
|
||
_ctxNoteId=null;_ctxNoteTitle=null;
|
||
};
|
||
window.mobileFolderPickerClose=function(){
|
||
var backdrop=document.getElementById('mobile-folder-picker-backdrop');
|
||
var sheet=document.getElementById('mobile-folder-picker-sheet');
|
||
var list=document.getElementById('mobile-folder-picker-list');
|
||
if(backdrop)backdrop.style.display='none';
|
||
if(sheet)sheet.style.display='none';
|
||
if(list)list.innerHTML='';
|
||
};
|
||
function mobileCtxDelete(){
|
||
if(!_ctxNoteId)return;
|
||
var id=_ctxNoteId;
|
||
mobileCtxClose();
|
||
if(_cfg.confirmTrash!==false&&!confirm('Move this note to trash?'))return;
|
||
fetch('/fragments/notes/'+encodeURIComponent(id),{method:'DELETE',headers:{'hx-request':'true','hx-params':'none'}})
|
||
.then(function(){mobileRefreshNotes()});
|
||
}
|
||
function mobileCtxMove(){
|
||
var form=activeEditorForm();
|
||
var select=form&&form.querySelector?form.querySelector('#editor-folder-select'):null;
|
||
if(!form||!select)return;
|
||
mobileCtxClose();
|
||
var options=Array.prototype.slice.call(select.options||[]);
|
||
if(!options.length)return;
|
||
var current=select.value||'';
|
||
var backdrop=document.getElementById('mobile-folder-picker-backdrop');
|
||
var sheet=document.getElementById('mobile-folder-picker-sheet');
|
||
var list=document.getElementById('mobile-folder-picker-list');
|
||
if(!backdrop||!sheet||!list)return;
|
||
list.innerHTML='';
|
||
options.forEach(function(opt){
|
||
var btn=document.createElement('button');
|
||
btn.type='button';
|
||
btn.className='mobile-ctx-btn mobile-folder-picker-btn'+(opt.value===current?' is-current':'');
|
||
btn.textContent=opt.text+(opt.value===current?' (current)':'');
|
||
btn.disabled=opt.value===current;
|
||
btn.onclick=function(){
|
||
window.mobileFolderPickerClose();
|
||
select.value=opt.value;
|
||
select.dispatchEvent(new Event('change',{bubbles:true}));
|
||
};
|
||
list.appendChild(btn);
|
||
});
|
||
backdrop.style.display='';
|
||
sheet.style.display='';
|
||
}
|
||
window.mobileEditorMenuOpen=function(){
|
||
var form=activeEditorForm();
|
||
if(!form)return;
|
||
var titleInput=form.querySelector('.editor-title');
|
||
mobileCtxOpen(form.dataset.noteId||_state.noteId,(titleInput&&titleInput.textContent)||document.getElementById('mobile-editor-title')&&document.getElementById('mobile-editor-title').textContent||'Untitled');
|
||
};
|
||
function wireNoteRowLongPress(container){
|
||
if(!container)return;
|
||
container.querySelectorAll('.mobile-note-row[data-note-id]').forEach(function(row){
|
||
if(row.dataset.lpWired)return;
|
||
row.dataset.lpWired='1';
|
||
row.addEventListener('touchstart',function(e){
|
||
var id=row.dataset.noteId,title=row.dataset.noteTitle;
|
||
_ctxLongPressTimer=setTimeout(function(){
|
||
e.preventDefault();
|
||
mobileCtxOpen(id,title);
|
||
},500);
|
||
},{passive:true});
|
||
row.addEventListener('touchend',function(){if(_ctxLongPressTimer){clearTimeout(_ctxLongPressTimer);_ctxLongPressTimer=null}});
|
||
row.addEventListener('touchmove',function(){if(_ctxLongPressTimer){clearTimeout(_ctxLongPressTimer);_ctxLongPressTimer=null}});
|
||
});
|
||
wireFolderRowLongPress(container);
|
||
}
|
||
var _folderCtxId=null,_folderCtxTitle=null,_folderCtxLongPressTimer=null;
|
||
function mobileFolderCtxOpen(folderId,folderTitle){
|
||
_folderCtxId=folderId;_folderCtxTitle=folderTitle||'Untitled';
|
||
var backdrop=document.getElementById('mobile-folder-ctx-backdrop');
|
||
var sheet=document.getElementById('mobile-folder-ctx-sheet');
|
||
var titleEl=document.getElementById('mobile-folder-ctx-title');
|
||
var renameBtn=document.getElementById('mobile-folder-ctx-rename');
|
||
var delBtn=document.getElementById('mobile-folder-ctx-delete');
|
||
if(titleEl)titleEl.textContent=_folderCtxTitle;
|
||
if(renameBtn)renameBtn.onclick=function(){mobileFolderCtxRename()};
|
||
if(delBtn)delBtn.onclick=function(){mobileFolderCtxDelete()};
|
||
if(backdrop)backdrop.style.display='';
|
||
if(sheet)sheet.style.display='';
|
||
}
|
||
window.mobileFolderCtxClose=function(){
|
||
var backdrop=document.getElementById('mobile-folder-ctx-backdrop');
|
||
var sheet=document.getElementById('mobile-folder-ctx-sheet');
|
||
if(backdrop)backdrop.style.display='none';
|
||
if(sheet)sheet.style.display='none';
|
||
};
|
||
function mobileFolderCtxRename(){
|
||
if(!_folderCtxId)return;
|
||
_folderMenuState={id:_folderCtxId,title:_folderCtxTitle};
|
||
window.mobileFolderCtxClose();
|
||
openFolderModal();
|
||
}
|
||
function mobileFolderCtxDelete(){
|
||
if(!_folderCtxId)return;
|
||
var id=_folderCtxId,title=_folderCtxTitle;
|
||
window.mobileFolderCtxClose();
|
||
if(!confirm('Delete notebook "'+(title||'Untitled')+'"?'))return;
|
||
fetch('/fragments/folders/'+encodeURIComponent(id),{method:'DELETE',headers:{'hx-request':'true'}})
|
||
.then(function(){htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'})});
|
||
}
|
||
function wireFolderRowLongPress(container){
|
||
if(!container)return;
|
||
container.querySelectorAll('.mobile-folder-row').forEach(function(row){
|
||
if(row.dataset.flpWired)return;
|
||
var onclickAttr=row.getAttribute('onclick')||'';
|
||
var m=onclickAttr.match(/mobilePushNotes\(\s*(?:"([^"]+)"|'([^']+)')\s*,\s*(?:"([^"]*)"|'([^']*)')/);
|
||
if(!m)return;
|
||
var fid=m[1]||m[2]||'';
|
||
var ftitle=m[3]||m[4]||'';
|
||
if(!fid||fid==='__all__')return;
|
||
row.dataset.flpWired='1';
|
||
row.addEventListener('touchstart',function(e){
|
||
_folderCtxLongPressTimer=setTimeout(function(){
|
||
e.preventDefault();
|
||
mobileFolderCtxOpen(fid,ftitle);
|
||
},500);
|
||
},{passive:true});
|
||
row.addEventListener('touchend',function(){if(_folderCtxLongPressTimer){clearTimeout(_folderCtxLongPressTimer);_folderCtxLongPressTimer=null}});
|
||
row.addEventListener('touchmove',function(){if(_folderCtxLongPressTimer){clearTimeout(_folderCtxLongPressTimer);_folderCtxLongPressTimer=null}});
|
||
row.addEventListener('contextmenu',function(e){e.preventDefault();mobileFolderCtxOpen(fid,ftitle)});
|
||
});
|
||
}
|
||
// Search
|
||
var _mobileSearchTimer=null;
|
||
window.mobileSearchOpen=function(){
|
||
var fh=document.getElementById('mobile-folders-header');
|
||
var sh=document.getElementById('mobile-search-header');
|
||
var inp=document.getElementById('mobile-search-input');
|
||
if(fh)fh.style.display='none';
|
||
if(sh)sh.style.display='';
|
||
if(inp){inp.value='';inp.focus()}
|
||
var body=document.getElementById('mobile-folders-body');
|
||
if(body)body.innerHTML='';
|
||
};
|
||
window.mobileSearchClose=function(){
|
||
var fh=document.getElementById('mobile-folders-header');
|
||
var sh=document.getElementById('mobile-search-header');
|
||
if(fh)fh.style.display='';
|
||
if(sh)sh.style.display='none';
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
};
|
||
window.mobileSearchQuery=function(q){
|
||
if(_mobileSearchTimer)clearTimeout(_mobileSearchTimer);
|
||
if(!q||!q.trim()){var body=document.getElementById('mobile-folders-body');if(body)body.innerHTML='';return}
|
||
if(q.trim().length<2)return;
|
||
_mobileSearchTimer=setTimeout(function(){
|
||
htmx.ajax('GET','/fragments/mobile/search?q='+encodeURIComponent(q.trim()),{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
},300);
|
||
};
|
||
window.mobileEditorSearchOpen=function(){
|
||
var header=document.getElementById('mobile-editor-header');
|
||
var searchHeader=document.getElementById('mobile-editor-search-header');
|
||
var input=document.getElementById('mobile-editor-search-input');
|
||
if(header)header.style.display='none';
|
||
if(searchHeader)searchHeader.style.display='';
|
||
if(input&&!input.value){var pending=window._pendingNoteSearchTerm||'';var listTerm=currentListSearchTerm();var seed=(pending&&pending.trim())||(listTerm&&listTerm.trim())||'';if(seed)input.value=seed;window._pendingNoteSearchTerm=''}
|
||
if(input){input.focus();input.select();applySearchHighlight()}
|
||
};
|
||
window.mobileEditorSearchClose=function(){
|
||
var header=document.getElementById('mobile-editor-header');
|
||
var searchHeader=document.getElementById('mobile-editor-search-header');
|
||
var mobileBar=document.getElementById('mobile-search-nav-bar');
|
||
var input=document.getElementById('mobile-editor-search-input');
|
||
if(input)input.value='';
|
||
if(searchHeader)searchHeader.style.display='none';
|
||
if(mobileBar)mobileBar.hidden=true;
|
||
if(header)header.style.display='';
|
||
searchNavDismiss();
|
||
};
|
||
window.mobileEditorSearchQuery=function(){applySearchHighlight()};
|
||
function mobileInit(){
|
||
if(!isMobile())return;
|
||
_trace('mobileInit-start',{initDone:_mobileInitDone});
|
||
document.getElementById('mobile-app').setAttribute('aria-hidden','false');
|
||
// Reset any stale active classes; renderMobile() (via setMobileState below) sets the correct one.
|
||
['folders','notes','editor'].forEach(function(name){
|
||
var screen=document.getElementById(mobileScreenId(name));
|
||
if(!screen)return;
|
||
screen.classList.remove('mobile-screen-active','mobile-screen-left','mobile-screen-right');
|
||
screen.style.pointerEvents='';
|
||
});
|
||
if(_mobileInitDone){renderMobile();return}
|
||
_mobileInitDone=true;
|
||
// Check if server pre-rendered a note into mobile-editor-body (resumeLastNote)
|
||
var startup=_mobileStartup;
|
||
if(startup&&startup.noteId){
|
||
setMobileState({
|
||
screen:'editor',
|
||
folderId:startup.folderId||'',
|
||
folderTitle:startup.folderTitle||'Notes',
|
||
noteId:startup.noteId,
|
||
noteTitle:startup.noteTitle||'Note'
|
||
});
|
||
// SSR already rendered editor content — init it directly, fetch lists in background
|
||
initEditorPanel();
|
||
initMobileToolbar();
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
if(_state.folderId)htmx.ajax('GET','/fragments/mobile/notes?folderId='+encodeURIComponent(_state.folderId),{target:'#mobile-notes-body',swap:'innerHTML'});
|
||
}else{
|
||
// Fresh load: start at folders screen
|
||
setMobileState({screen:'folders'});
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
}
|
||
var fab=document.getElementById('mobile-fab');
|
||
if(fab&&!fab.dataset.debugWired)fab.dataset.debugWired='1';
|
||
// Swipe right to go back
|
||
var startX=0,startY=0,swiping=false;
|
||
document.getElementById('mobile-app').addEventListener('touchstart',function(e){startX=e.touches[0].clientX;startY=e.touches[0].clientY;swiping=true},{passive:true});
|
||
document.getElementById('mobile-app').addEventListener('touchend',function(e){
|
||
if(!swiping)return;swiping=false;
|
||
var dx=e.changedTouches[0].clientX-startX;
|
||
var dy=e.changedTouches[0].clientY-startY;
|
||
if(Math.abs(dx)>Math.abs(dy)*1.5&&dx>60&&_state.screen!=='folders'){mobileEditorBack()}
|
||
},{passive:true});
|
||
}
|
||
// Redraw the current mobile screen after a shell switch (no reload needed)
|
||
function redrawMobileUI(){
|
||
if(!isMobile())return;
|
||
_trace('redrawMobileUI',{state:_state});
|
||
// Re-assert current state (renderMobile picks up DOM that may have been stale).
|
||
renderMobile();
|
||
if(_state.screen==='editor'&&_state.noteId){
|
||
// Re-fetch editor; lists refresh after editor settles
|
||
var body=document.getElementById('mobile-editor-body');if(body)body.innerHTML='<div class="editor-empty mobile-loading-note"><div class="note-loading-ring"></div></div>';
|
||
htmx.ajax('GET','/fragments/editor/'+encodeURIComponent(_state.noteId)+'?currentFolderId='+encodeURIComponent(_state.folderId),{target:'#mobile-editor-body',swap:'innerHTML'}).then(function(){
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
if(_state.folderId)htmx.ajax('GET','/fragments/mobile/notes?folderId='+encodeURIComponent(_state.folderId),{target:'#mobile-notes-body',swap:'innerHTML'});
|
||
});
|
||
}else if(_state.screen==='notes'){
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
if(_state.folderId)htmx.ajax('GET','/fragments/mobile/notes?folderId='+encodeURIComponent(_state.folderId),{target:'#mobile-notes-body',swap:'innerHTML'});
|
||
}else{
|
||
setMobileState({screen:'folders'});
|
||
htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
}
|
||
}
|
||
function syncResponsiveMode(){
|
||
var mobile=isMobile();
|
||
document.body.classList.toggle('mobile-shell-active',mobile);
|
||
document.documentElement.classList.toggle('mobile-no-page-scroll',mobile);
|
||
document.body.classList.toggle('mobile-no-page-scroll',mobile);
|
||
// For auto mode, ensure body classes reflect current viewport so CSS overrides work
|
||
if(_uiMode==='auto'){
|
||
document.body.classList.toggle('force-mobile',mobile);
|
||
document.body.classList.toggle('force-desktop',!mobile);
|
||
}
|
||
if(mobile){
|
||
if(!_mobileInitDone){
|
||
mobileInit();
|
||
}else if(_lastSyncWasMobile===false){
|
||
// Only redraw if we just crossed from desktop→mobile
|
||
document.getElementById('mobile-app').setAttribute('aria-hidden','false');
|
||
redrawMobileUI();
|
||
}
|
||
_lastSyncWasMobile=true;
|
||
return;
|
||
}
|
||
var wasMobile=_lastSyncWasMobile;
|
||
_lastSyncWasMobile=false;
|
||
var app=document.getElementById('mobile-app');
|
||
if(app){
|
||
// Blur any focused element inside mobile-app before hiding to avoid aria-hidden warning
|
||
var focused=app.contains(document.activeElement)?document.activeElement:null;
|
||
if(focused)focused.blur();
|
||
app.setAttribute('aria-hidden','true');
|
||
}
|
||
mobileFabClose();
|
||
mobileCtxClose();
|
||
var fab=document.getElementById('mobile-fab');
|
||
if(fab)fab.style.display='none';
|
||
var foldersHeader=document.getElementById('mobile-folders-header');
|
||
var searchHeader=document.getElementById('mobile-search-header');
|
||
if(foldersHeader)foldersHeader.style.display='';
|
||
if(searchHeader)searchHeader.style.display='none';
|
||
setMobileNav(false);
|
||
// Mirror redrawMobileUI: if we just crossed mobile→desktop, re-init desktop UI.
|
||
// Without this, a session that started in mobile mode never calls initNavPanel/
|
||
// initEditorPanel for the desktop form, leaving the preview/CM host in an
|
||
// uninitialized state (both visible, wrong display values, stale scroll).
|
||
if(wasMobile===true){initNavPanel();initEditorPanel();}
|
||
}
|
||
function mobileEditorToolbar(){
|
||
var body=document.getElementById('mobile-editor-body');
|
||
return body?body.querySelector('#editor-toolbar'):null;
|
||
}
|
||
function setMobileToolbarVisible(show){
|
||
var tb=mobileEditorToolbar();
|
||
if(!tb)return;
|
||
// In rich (TinyMCE) mode the built-in toolbar is used; always keep the
|
||
// custom toolbar visibility class-driven and clear inline overrides.
|
||
if(_editorMode!=='markdown'&&_editorMode!=='md'){
|
||
tb.style.display='';
|
||
positionTinyMCEHost();
|
||
return;
|
||
}
|
||
var form=activeEditorForm();
|
||
if(form&&form.dataset.encrypted==='1'&&form.dataset.vaultUnlocked!=='1')show=false;
|
||
tb.style.display=show?'flex':'none';
|
||
// Reposition immediately, then again after a tick so the toolbar has a
|
||
// rendered height before positionTinyMCEHost measures the anchor bottom.
|
||
positionTinyMCEHost();
|
||
if(show)setTimeout(positionTinyMCEHost,0);
|
||
}
|
||
function initMobileToolbar(){
|
||
// Remove any stale reparented toolbar from a previous approach.
|
||
var stale=document.body.querySelector('#editor-toolbar[data-mobile-reparented]');
|
||
if(stale)stale.remove();
|
||
var tb=mobileEditorToolbar();
|
||
if(!tb||!inMobileEditor())return;
|
||
// Allow CSS mode classes to control toolbar visibility.
|
||
tb.style.display='';
|
||
positionTinyMCEHost();
|
||
syncEditorModeButtons();
|
||
}
|
||
// Reposition TinyMCE host when viewport changes (keyboard open/close, resize).
|
||
// Show/hide toolbar on TinyMCE focus/blur.
|
||
(function initMobileToolbarGlobalListeners(){
|
||
function onVpChange(){
|
||
stabilizeTinyMCEHostPosition();
|
||
// When the visual viewport returns to full height the keyboard has gone away.
|
||
// Hide the toolbar — user is no longer actively editing.
|
||
if(!inMobileEditor())return;
|
||
var vv=window.visualViewport;
|
||
var keyboardGone=!vv||(window.innerHeight-vv.height<80);
|
||
if(keyboardGone&&!(_tinymceEditor&&_tinymceEditor.hasFocus&&_tinymceEditor.hasFocus())){
|
||
setMobileToolbarVisible(false);
|
||
}
|
||
}
|
||
if(window.visualViewport){
|
||
window.visualViewport.addEventListener('resize',onVpChange);
|
||
window.visualViewport.addEventListener('scroll',positionTinyMCEHost);
|
||
}
|
||
window.addEventListener('resize',positionTinyMCEHost);
|
||
// TinyMCE lives in an iframe; relay focus/blur via custom events from setup().
|
||
document.body.addEventListener('joplock:editor-focus',function(){
|
||
if(_tinymceSuppressEdits)return;
|
||
if(inMobileEditor()){
|
||
setMobileToolbarVisible(true);
|
||
stabilizeTinyMCEHostPosition();
|
||
}
|
||
});
|
||
document.body.addEventListener('joplock:editor-blur',function(){
|
||
if(!inMobileEditor())return;
|
||
// Give the browser time to update hasFocus and visualViewport,
|
||
// then hide toolbar if editor is truly no longer focused.
|
||
setTimeout(function(){
|
||
if(_tinymceEditor&&_tinymceEditor.hasFocus&&_tinymceEditor.hasFocus())return;
|
||
setMobileToolbarVisible(false);
|
||
},200);
|
||
});
|
||
})();
|
||
// Update editor title when editor loads
|
||
document.body.addEventListener('htmx:afterSettle',function(e){
|
||
var t=e.detail&&e.detail.target;
|
||
if(t&&t.id==='mobile-editor-body'){
|
||
_trace('mobile-editor-settle-start');
|
||
initEditorPanel();
|
||
var titleHidden=t.querySelector('.editor-title-hidden');
|
||
var titleEl=document.getElementById('mobile-editor-title');
|
||
if(titleEl&&titleHidden)titleEl.textContent=titleHidden.value||'Note';
|
||
var mobileStatus=document.getElementById('mobile-editor-status');
|
||
if(mobileStatus){
|
||
var dirty=t.querySelector('#autosave-status .autosave-edited');
|
||
var saved=t.querySelector('#autosave-status .autosave-ok');
|
||
mobileStatus.innerHTML=dirty?'<span class="autosave-edited">Edited</span>':(saved?'<span class="autosave-ok">Saved</span>':'');
|
||
}
|
||
// Hide desktop titlebar in mobile editor
|
||
var titlebar=t.querySelector('.editor-titlebar');
|
||
if(titlebar&&isMobile())titlebar.style.display='none';
|
||
// Wire delete button
|
||
var form=t.querySelector('#note-editor-form');
|
||
var noteId=form?decodeURIComponent((form.getAttribute('hx-put')||'').replace('/fragments/editor/','')):'';
|
||
_trace('mobile-editor-settle-done',{hasForm:!!form,noteId:noteId,spinner:!!t.querySelector('.mobile-loading-note,.note-loading-ring')});
|
||
var isDeleted=!!t.querySelector('.btn-danger[hx-confirm*="Permanently"]');
|
||
wireMobileDeleteBtn(noteId,isDeleted);
|
||
// Show FAB only when on notes screen
|
||
var fab=document.getElementById('mobile-fab');if(fab)fab.style.display='none';
|
||
// Position toolbar above keyboard using visualViewport
|
||
initMobileToolbar();
|
||
}
|
||
if(t&&(t.id==='mobile-notes-body'||t.id==='mobile-folders-body')){
|
||
var fab=document.getElementById('mobile-fab');
|
||
var editorActive=!!document.querySelector('#mobile-editor-screen.mobile-screen-active');
|
||
if(fab)fab.style.display=editorActive?'none':'flex';
|
||
wireNoteRowLongPress(t);
|
||
}
|
||
});
|
||
// Handle new note response: push to editor
|
||
document.body.addEventListener('htmx:afterRequest',function(e){
|
||
var t=e.detail&&e.detail.target;
|
||
if(t&&t.id==='mobile-notes-body'){
|
||
var xhr=e.detail.xhr;
|
||
var noteId=xhr&&xhr.getResponseHeader('X-Mobile-Note-Id');
|
||
if(noteId){window._mobileNewNoteId=noteId;mobilePushEditor(noteId,_state.folderId)}
|
||
}
|
||
});
|
||
window._syncResponsiveMode=syncResponsiveMode;
|
||
window.addEventListener('resize',handleViewportResize);
|
||
window.addEventListener('orientationchange',handleViewportResize);
|
||
syncResponsiveMode();
|
||
})();
|
||
// --- Encryption UI flows (vault-centric) ---
|
||
|
||
// _vaultModal: modal for creating vault password or unlocking a vault
|
||
var _vaultModalFolderId=null;
|
||
var _vaultModalMode=null; // 'create' | 'unlock'
|
||
var _vaultModalCallback=null; // called with success/failure
|
||
|
||
function _showVaultModal(folderId,mode,callback){
|
||
_vaultModalFolderId=folderId;
|
||
_vaultModalMode=mode;
|
||
_vaultModalCallback=callback;
|
||
var modal=document.getElementById('vault-modal');
|
||
var backdrop=document.getElementById('vault-modal-backdrop');
|
||
var titleEl=document.getElementById('vault-modal-title');
|
||
var pw=document.getElementById('vault-modal-password');
|
||
var confirm=document.getElementById('vault-modal-confirm-wrap');
|
||
var warn=document.getElementById('vault-modal-warning');
|
||
var err=document.getElementById('vault-modal-error');
|
||
if(err)err.textContent='';
|
||
if(pw)pw.value='';
|
||
if(mode==='create'){
|
||
if(titleEl)titleEl.textContent='Create Vault';
|
||
if(confirm)confirm.style.display='';
|
||
if(warn)warn.style.display='';
|
||
}else{
|
||
if(titleEl)titleEl.textContent='Unlock Vault';
|
||
if(confirm)confirm.style.display='none';
|
||
if(warn)warn.style.display='none';
|
||
}
|
||
if(modal)modal.hidden=false;
|
||
if(backdrop)backdrop.hidden=false;
|
||
if(pw)pw.focus();
|
||
}
|
||
|
||
function closeVaultModal(){
|
||
var modal=document.getElementById('vault-modal');
|
||
var backdrop=document.getElementById('vault-modal-backdrop');
|
||
if(modal)modal.hidden=true;
|
||
if(backdrop)backdrop.hidden=true;
|
||
_vaultModalFolderId=null;
|
||
_vaultModalMode=null;
|
||
if(_vaultModalCallback){_vaultModalCallback(false);_vaultModalCallback=null}
|
||
}
|
||
|
||
async function submitVaultModal(event){
|
||
if(event)event.preventDefault();
|
||
var folderId=_vaultModalFolderId;
|
||
var mode=_vaultModalMode;
|
||
if(!folderId||!mode)return;
|
||
var pw=document.getElementById('vault-modal-password');
|
||
var confirmInput=document.getElementById('vault-modal-confirm');
|
||
var err=document.getElementById('vault-modal-error');
|
||
var password=(pw?pw.value:'').trim();
|
||
if(!password){if(err)err.textContent='Password is required.';return}
|
||
|
||
if(mode==='create'){
|
||
var confirmVal=(confirmInput?confirmInput.value:'').trim();
|
||
if(password!==confirmVal){if(err)err.textContent='Passwords do not match.';return}
|
||
if(password.length<4){if(err)err.textContent='Password too short (minimum 4 characters).';return}
|
||
try{
|
||
await createVault(folderId,password);
|
||
var cb=_vaultModalCallback;
|
||
_vaultModalCallback=null;
|
||
_closeVaultModalSilent();
|
||
// Update nav icons to show vault unlocked
|
||
_refreshVaultIcon(folderId,true);
|
||
touchVaultActivity(folderId);
|
||
startAutoLockTimer();
|
||
if(cb)cb(true);
|
||
}catch(e){
|
||
if(err)err.textContent='Failed to create vault: '+(e.message||e);
|
||
}
|
||
}else{
|
||
// unlock mode
|
||
try{
|
||
var ok=await unlockVault(folderId,password);
|
||
if(!ok){if(err)err.textContent='Wrong password.';if(pw){pw.value='';pw.focus()}return}
|
||
var cb=_vaultModalCallback;
|
||
_vaultModalCallback=null;
|
||
_closeVaultModalSilent();
|
||
_refreshVaultIcon(folderId,true);
|
||
touchVaultActivity(folderId);
|
||
startAutoLockTimer();
|
||
if(cb)cb(true);
|
||
}catch(e){
|
||
if(err)err.textContent='Unlock error: '+(e.message||e);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _closeVaultModalSilent(){
|
||
var modal=document.getElementById('vault-modal');
|
||
var backdrop=document.getElementById('vault-modal-backdrop');
|
||
if(modal)modal.hidden=true;
|
||
if(backdrop)backdrop.hidden=true;
|
||
_vaultModalFolderId=null;
|
||
_vaultModalMode=null;
|
||
}
|
||
|
||
// Update vault folder lock icon in nav (client-side optimistic update)
|
||
function _refreshVaultIcon(folderId,unlocked){
|
||
document.querySelectorAll('.vault-folder-lock[data-folder-id="'+folderId+'"]').forEach(function(el){
|
||
el.innerHTML=unlocked?SVG_LOCK_OPEN:SVG_LOCK_CLOSED;
|
||
el.title=unlocked?'Lock vault':'Unlock vault';
|
||
});
|
||
// Also update note lock icons for notes in this vault
|
||
document.querySelectorAll('.note-lock-icon').forEach(function(el){
|
||
var btn=el.closest('.notelist-item,.mobile-note-row');
|
||
if(btn&&btn.dataset.vaultId===folderId){
|
||
el.innerHTML=unlocked?SVG_LOCK_OPEN:SVG_LOCK_CLOSED;
|
||
el.classList.toggle('note-lock-unlocked',unlocked);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Single source of truth: scan DOM for all vault folders and refresh their icons
|
||
// based on isVaultUnlocked() state. Safe to call repeatedly.
|
||
function refreshAllVaultIcons(){
|
||
document.querySelectorAll('.vault-folder-lock[data-folder-id]').forEach(function(el){
|
||
var folderId=el.getAttribute('data-folder-id');
|
||
if(!folderId)return;
|
||
var unlocked=isVaultUnlocked(folderId);
|
||
_refreshVaultIcon(folderId,unlocked);
|
||
});
|
||
}
|
||
|
||
// Toggle vault lock: if unlocked → lock; if locked → prompt unlock
|
||
function toggleVaultLock(folderId){
|
||
var unlocked=isVaultUnlocked(folderId);
|
||
_log('toggleVaultLock',folderId,{unlocked:unlocked});
|
||
if(unlocked){
|
||
// Lock the vault
|
||
clearVaultKey(folderId);
|
||
delete _autoLockActivity[folderId];
|
||
_refreshVaultIcon(folderId,false);
|
||
// If current note belongs to this vault, close it (clear the editor)
|
||
var form=activeEditorForm();
|
||
if(form){
|
||
var ta=getTA();
|
||
// Check form.dataset.vaultId first (set after unlock when textarea is plaintext)
|
||
// Fall back to parsing the body (if still encrypted)
|
||
var bodyVault=form.dataset.vaultId||( ta?getBodyVaultId(ta.value):null);
|
||
_log('toggleVaultLock close-check',folderId,{formVaultId:form.dataset.vaultId||null,bodyVault:bodyVault,noteId:form.dataset.noteId||null});
|
||
if(bodyVault===folderId){
|
||
var panel=form.closest('#editor-panel')||document.getElementById('editor-panel');
|
||
if(panel)panel.innerHTML='<div class="editor-empty">Select a note</div>';
|
||
hideTinyMCEHost();
|
||
}
|
||
}
|
||
}else{
|
||
_showVaultModal(folderId,'unlock',function(ok){
|
||
_log('toggleVaultLock unlock-callback',folderId,{ok:ok});
|
||
if(ok){
|
||
// Auto-decrypt if current note belongs to this vault
|
||
var form=activeEditorForm();
|
||
if(form){
|
||
var noteId=form.dataset.noteId;
|
||
var ta=getTA();
|
||
if(ta&&isEncryptedBody(ta.value)){
|
||
var bodyVault=getBodyVaultId(ta.value);
|
||
_log('toggleVaultLock unlock-open-note-check',folderId,{bodyVault:bodyVault,noteId:noteId});
|
||
if(bodyVault===folderId){
|
||
getVaultKey(folderId).then(function(key){
|
||
if(!key)return;
|
||
return _decryptWithKey(ta.value,key).then(function(pt){_completeUnlock(noteId,pt,folderId)});
|
||
}).catch(function(){});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// lockNote: lock a currently-unlocked note (by encrypting it into its vault)
|
||
// If the note is not in a vault, do nothing (encryption requires vault)
|
||
function lockNote(noteId){
|
||
var form=activeEditorForm();
|
||
if(!form)return;
|
||
var ta=getTA();
|
||
if(!ta)return;
|
||
// Determine vault from editor context (parentId select)
|
||
var parentSelect=form.querySelector('[name="parentId"]');
|
||
var folderId=parentSelect?parentSelect.value:'';
|
||
if(!folderId){
|
||
alert('Please move this note to a vault folder before encrypting it.');
|
||
return;
|
||
}
|
||
if(isVaultUnlocked(folderId)){
|
||
// Encrypt immediately
|
||
_doEncryptNoteInVault(noteId,folderId);
|
||
}else{
|
||
// Need to unlock vault first
|
||
_showVaultModal(folderId,'unlock',function(ok){
|
||
if(ok)_doEncryptNoteInVault(noteId,folderId);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function _doEncryptNoteInVault(noteId,folderId){
|
||
try{
|
||
var ta=getTA();
|
||
if(!ta)return;
|
||
var plaintext=ta.value;
|
||
if(isEncryptedBody(plaintext)){_log('note already encrypted');return}
|
||
var key=await getVaultKey(folderId);
|
||
if(!key){_log('vault key missing');return}
|
||
var salt=getVaultSalt(folderId);
|
||
if(!salt){_log('vault salt missing',folderId);alert('Vault key not available. Unlock the vault and try again.');return}
|
||
_log('_doEncryptNoteInVault encrypt',{noteId:noteId,folderId:folderId,plaintextLen:plaintext.length,keyType:key&&key.type,saltLen:salt&&salt.length});
|
||
var ciphertext=await encryptForVault(plaintext,folderId,key,salt);
|
||
touchVaultActivity(folderId);
|
||
var form=activeEditorForm();
|
||
if(form){
|
||
form.dataset.encrypted='1';
|
||
form.dataset.vaultId=folderId;
|
||
_triggerEncryptedSave(form,ciphertext);
|
||
}
|
||
_updateLockToggle(noteId,true);
|
||
_updateNoteLockIcon(noteId,true);
|
||
}catch(e){
|
||
_log('_doEncryptNoteInVault error',e);
|
||
alert('Encryption failed: '+e.message);
|
||
}
|
||
}
|
||
|
||
// unlockNote: called from the locked editor overlay
|
||
async function unlockNote(noteId){
|
||
var passwordInput=document.getElementById('editor-locked-password');
|
||
var errEl=document.getElementById('editor-locked-error');
|
||
var ta=getTA();
|
||
if(!ta)return;
|
||
var form=activeEditorForm();
|
||
|
||
// Determine vaultId from the ciphertext
|
||
var vaultId=getBodyVaultId(ta.value)||((form&&form.dataset.vaultId)||null);
|
||
var encryptedBody=isEncryptedBody(ta.value);
|
||
_log('unlockNote start',{noteId:noteId,vaultId:vaultId,encryptedBody:encryptedBody,formVaultId:form&&form.dataset.vaultId||null});
|
||
|
||
// Special case: note belongs to a vault but body is still plaintext in storage.
|
||
// Unlock the vault, immediately encrypt+save this note, then keep editing.
|
||
if(vaultId&&!encryptedBody){
|
||
var passwordPlain=(passwordInput?passwordInput.value:'');
|
||
if(!isVaultUnlocked(vaultId)){
|
||
if(!passwordPlain){if(errEl)errEl.textContent='Enter vault password.';return}
|
||
var unlockedPlain=await unlockVault(vaultId,passwordPlain);
|
||
if(!unlockedPlain){if(errEl)errEl.textContent='Wrong password.';if(passwordInput){passwordInput.value='';passwordInput.focus()}return}
|
||
}
|
||
_completeUnlock(noteId,ta.value,vaultId);
|
||
_doEncryptNoteInVault(noteId,vaultId);
|
||
return;
|
||
}
|
||
|
||
// Try auto-unlock with cached vault key
|
||
if(vaultId&&isVaultUnlocked(vaultId)){
|
||
try{
|
||
var key=await getVaultKey(vaultId);
|
||
if(key){
|
||
var plaintext=await _decryptWithKey(ta.value,key);
|
||
_completeUnlock(noteId,plaintext,vaultId);
|
||
return;
|
||
}
|
||
}catch(e){_log('auto-unlock failed')}
|
||
}
|
||
|
||
// Manual password entered
|
||
var password=(passwordInput?passwordInput.value:'');
|
||
if(!password&&!vaultId){if(errEl)errEl.textContent='Enter a password.';return}
|
||
if(!password&&vaultId){
|
||
// Try opening vault modal
|
||
_showVaultModal(vaultId,'unlock',function(ok){
|
||
if(ok)unlockNote(noteId);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Try to unlock vault with typed password
|
||
if(vaultId){
|
||
try{
|
||
var ok=await unlockVault(vaultId,password);
|
||
if(ok){
|
||
var key=await getVaultKey(vaultId);
|
||
var plaintext=await _decryptWithKey(ta.value,key);
|
||
_completeUnlock(noteId,plaintext,vaultId);
|
||
return;
|
||
}
|
||
}catch(e){}
|
||
// Fall through to v1 compat decrypt attempt
|
||
}
|
||
|
||
// v1 compat or orphaned note: try password directly (note has embedded salt)
|
||
try{
|
||
var plaintext=await decryptBody(password,ta.value);
|
||
_completeUnlock(noteId,plaintext,null);
|
||
}catch(e){
|
||
if(errEl)errEl.textContent='Wrong password.';
|
||
if(passwordInput){passwordInput.value='';passwordInput.focus()}
|
||
}
|
||
}
|
||
|
||
// _completeUnlock: shows plaintext in editor. vaultId may be null for v1 notes.
|
||
function _completeUnlock(noteId,plaintext,vaultId){
|
||
if(vaultId)touchVaultActivity(vaultId);
|
||
|
||
var ta=getTA();
|
||
var lockedDiv=document.getElementById('editor-locked');
|
||
var tb=queryActiveEditor('#editor-toolbar');
|
||
var form=activeEditorForm();
|
||
|
||
if(ta){
|
||
ta.dataset.ciphertext=ta.value;
|
||
ta.value=plaintext;
|
||
ta.style.display='';
|
||
}
|
||
if(form){
|
||
if(vaultId){
|
||
form.dataset.encrypted='1';
|
||
form.dataset.vaultId=vaultId;
|
||
form.dataset.vaultUnlocked='1';
|
||
}else{
|
||
// Orphan / v1 note: decrypted with embedded salt, no live vault key.
|
||
// Treat as plaintext so subsequent folder moves don't try to re-encrypt
|
||
// with a vault we have no key for.
|
||
delete form.dataset.encrypted;
|
||
delete form.dataset.vaultId;
|
||
delete form.dataset.vaultUnlocked;
|
||
}
|
||
}
|
||
|
||
if(lockedDiv)lockedDiv.style.display='none';
|
||
if(tb)tb.style.display='';
|
||
|
||
var mdBtn=document.getElementById('markdown-toggle');
|
||
var pvBtn=document.getElementById('preview-toggle');
|
||
if(mdBtn)mdBtn.style.display='';
|
||
if(pvBtn)pvBtn.style.display='';
|
||
|
||
// Open in the user's preferred mode — setEditorMode handles CM6 mount / TinyMCE setContent.
|
||
setEditorMode(_defaultNoteOpenMode==='markdown'?'markdown':'rich');
|
||
snapshotHash();
|
||
|
||
_updateLockToggle(noteId,true);
|
||
_updateNoteLockIcon(noteId,true);
|
||
if(vaultId)_refreshVaultIcon(vaultId,true);
|
||
snapshotHash();
|
||
}
|
||
|
||
// toggleNoteLock: single button in titlebar
|
||
function toggleNoteLock(noteId){
|
||
var form=activeEditorForm();
|
||
var isEnc=form&&form.dataset.encrypted==='1';
|
||
var vaultId=form&&form.dataset.vaultId;
|
||
if(isEnc&&vaultId&&isVaultUnlocked(vaultId)){
|
||
// Vault is unlocked, note is open → lock the vault
|
||
toggleVaultLock(vaultId);
|
||
}else if(isEnc&&(!vaultId||!isVaultUnlocked(vaultId))){
|
||
// Encrypted note, vault locked → unlock
|
||
unlockNote(noteId);
|
||
}else{
|
||
// Not encrypted → lock it (encrypt into current folder's vault)
|
||
lockNote(noteId);
|
||
}
|
||
}
|
||
|
||
function _updateLockToggle(noteId,unlocked){
|
||
var btn=document.getElementById('lock-toggle-btn');
|
||
if(!btn)return;
|
||
btn.innerHTML=unlocked?SVG_LOCK_OPEN:SVG_LOCK_CLOSED;
|
||
btn.title=unlocked?'Lock vault':'Unlock vault';
|
||
}
|
||
|
||
function _updateNoteLockIcon(noteId,unlocked){
|
||
document.querySelectorAll('.note-lock-icon[data-note-id="'+noteId+'"]').forEach(function(el){
|
||
el.innerHTML=unlocked?SVG_LOCK_OPEN:SVG_LOCK_CLOSED;
|
||
el.classList.toggle('note-lock-unlocked',unlocked);
|
||
});
|
||
}
|
||
|
||
// --- Autosave interceptor for encrypted notes ---
|
||
document.body.addEventListener('htmx:configRequest',function(e){
|
||
// no-op: encryption is handled in scheduleSave override
|
||
});
|
||
|
||
var _origScheduleSave=scheduleSave;
|
||
function _setOneShotEncryptedBody(form,ciphertext){
|
||
if(!form)return function(){};
|
||
var ta=form.querySelector('textarea[name="body"], textarea.editor-body');
|
||
if(!ta)return function(){};
|
||
var originalName=ta.getAttribute('name');
|
||
var hidden=document.createElement('input');
|
||
hidden.type='hidden';
|
||
hidden.name='body';
|
||
hidden.value=ciphertext;
|
||
hidden.setAttribute('data-joplock-temp-body','1');
|
||
// Prevent duplicate body fields during htmx form serialization.
|
||
ta.removeAttribute('name');
|
||
form.appendChild(hidden);
|
||
return function(){
|
||
if(originalName!==null)ta.setAttribute('name',originalName);else ta.removeAttribute('name');
|
||
if(hidden.parentNode)hidden.parentNode.removeChild(hidden);
|
||
};
|
||
}
|
||
|
||
function _triggerEncryptedSave(form,ciphertext){
|
||
if(!form)return;
|
||
// Cancel any pending plaintext autosave (e.g. from the change event that
|
||
// just triggered this encrypted save). Otherwise that timer would fire 2s
|
||
// later with name="body" back on the textarea (plaintext) and the server
|
||
// would reject the move with "Vault notes must be saved encrypted".
|
||
if(typeof _saveTimer!=='undefined'&&_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}
|
||
if(typeof _saveTitleTimer!=='undefined'&&_saveTitleTimer){clearTimeout(_saveTitleTimer);_saveTitleTimer=null}
|
||
var restore=_setOneShotEncryptedBody(form,ciphertext);
|
||
var done=false;
|
||
var cleanupTimer=null;
|
||
var cleanup=function(success){
|
||
if(done)return;
|
||
done=true;
|
||
if(cleanupTimer)clearTimeout(cleanupTimer);
|
||
form.removeEventListener('htmx:afterRequest',onDone);
|
||
form.removeEventListener('htmx:responseError',onDone);
|
||
form.removeEventListener('htmx:sendError',onDone);
|
||
restore();
|
||
// Re-snapshot the form hash after the textarea regains its name attr,
|
||
// so a subsequent debounced scheduleSave compares apples to apples.
|
||
if(success)snapshotHash();
|
||
};
|
||
var onDone=function(e){
|
||
if(e.target!==form)return;
|
||
var ok=e.type==='htmx:afterRequest'&&e.detail&&e.detail.successful;
|
||
setTimeout(function(){cleanup(ok)},0);
|
||
};
|
||
form.addEventListener('htmx:afterRequest',onDone);
|
||
form.addEventListener('htmx:responseError',onDone);
|
||
form.addEventListener('htmx:sendError',onDone);
|
||
cleanupTimer=setTimeout(function(){cleanup(false)},30000);
|
||
htmx.trigger(form,'joplock:save');
|
||
}
|
||
|
||
function buildFlushRequest(form){
|
||
if(!form)return Promise.resolve(null);
|
||
var url=form.getAttribute('hx-put');
|
||
if(!url)return Promise.resolve(null);
|
||
var pv=getPV();
|
||
if(pv)syncPV();else if(_editorMode!=='markdown'&&_editorMode!=='md')tinyMCESyncToTA();
|
||
syncTitle();
|
||
var ta=getTA();
|
||
if(form.dataset.encrypted==='1'&&form.dataset.vaultId&&ta&&!isEncryptedBody(ta.value)){
|
||
return getVaultKey(form.dataset.vaultId).then(function(key){
|
||
if(!key)throw new Error('Vault is locked');
|
||
var salt=getVaultSalt(form.dataset.vaultId);
|
||
return encryptForVault(ta.value,form.dataset.vaultId,key,salt).then(function(ciphertext){
|
||
var restore=_setOneShotEncryptedBody(form,ciphertext);
|
||
var fd=new FormData(form);
|
||
var body=new URLSearchParams(fd).toString();
|
||
return { url:url, body:body, restore:restore };
|
||
});
|
||
});
|
||
}
|
||
var fd=new FormData(form);
|
||
var body=new URLSearchParams(fd).toString();
|
||
return Promise.resolve({ url:url, body:body, restore:function(){} });
|
||
}
|
||
|
||
scheduleSave=function(){
|
||
var form=activeEditorForm();
|
||
if(!form||form.dataset.encrypted!=='1'){_origScheduleSave();return}
|
||
var noteId=form.dataset.noteId;
|
||
var vaultId=form.dataset.vaultId;
|
||
if(!noteId){_origScheduleSave();return}
|
||
// If not a vault note, pass through
|
||
if(!vaultId){_origScheduleSave();return}
|
||
|
||
if(_saveTimer)clearTimeout(_saveTimer);
|
||
_saveTimer=setTimeout(async function(){
|
||
_saveTimer=null;
|
||
if(_syncPVInFlight||_pvSyncTimer){scheduleSave();return}
|
||
if(_anyModalOpen()){scheduleSave();return}
|
||
if(!form)return;
|
||
var h=formHash(form);
|
||
if(h===_savedHash){_log('encrypted scheduleSave skip, hash unchanged',h);return}
|
||
|
||
var ta=getTA();
|
||
if(!ta)return;
|
||
var plaintext=ta.value;
|
||
_log('encrypted save begin',vaultId,{noteId:noteId,plaintextLength:plaintext.length,alreadyEncrypted:isEncryptedBody(plaintext)});
|
||
|
||
// Skip if somehow the textarea already holds ciphertext
|
||
if(isEncryptedBody(plaintext)){_origScheduleSave();return}
|
||
|
||
try{
|
||
var key=await getVaultKey(vaultId);
|
||
if(!key){_log('vault key gone during save for vault',vaultId);return}
|
||
var salt=getVaultSalt(vaultId);
|
||
var ciphertext=await encryptForVault(plaintext,vaultId,key,salt);
|
||
_log('encrypted save ciphertext ready',vaultId,{noteId:noteId,ciphertextLength:ciphertext.length,hasMarker:isEncryptedBody(ciphertext)});
|
||
_triggerEncryptedSave(form,ciphertext);
|
||
touchVaultActivity(vaultId);
|
||
}catch(e){
|
||
_log('encrypted save error',e);
|
||
setSaveState('<span class="autosave-error">Encrypt error</span>','Error');
|
||
}
|
||
},2000);
|
||
};
|
||
|
||
// Auto-unlock on editor init if vault key is cached
|
||
var _origInitEditorPanel=initEditorPanel;
|
||
initEditorPanel=function(){
|
||
_origInitEditorPanel();
|
||
var form=activeEditorForm();
|
||
if(!form){_log('initEditorPanel vault-check: no active form');return}
|
||
if(form.dataset.vaultChecked){return}
|
||
form.dataset.vaultChecked='1';
|
||
_log('initEditorPanel vault-check start',{noteId:form.dataset.noteId||null,encryptedFlag:form.dataset.encrypted||null,formVaultId:form.dataset.vaultId||null});
|
||
if(form.dataset.encrypted!=='1'){_log('initEditorPanel vault-check skip: form not encrypted');return}
|
||
var noteId=form.dataset.noteId;
|
||
if(!noteId){_log('initEditorPanel vault-check skip: no noteId');return}
|
||
|
||
var ta=getTA();
|
||
if(!ta){_log('initEditorPanel vault-check skip: no textarea');return}
|
||
var initialVaultId=form.dataset.vaultId||null;
|
||
var encryptedBody=isEncryptedBody(ta.value);
|
||
var editorUnlocked=form.dataset.vaultUnlocked==='1';
|
||
if(!encryptedBody){
|
||
_log('initEditorPanel vault plaintext-in-vault state',{noteId:noteId,vaultId:initialVaultId,editorUnlocked:editorUnlocked,bodyPreview:ta.value.slice(0,80)});
|
||
if(editorUnlocked){
|
||
_log('initEditorPanel vault plaintext-in-vault skip: already unlocked in editor',{noteId:noteId,vaultId:initialVaultId});
|
||
return;
|
||
}
|
||
// Vault-bound note with plaintext body. Keep it hidden while locked; if the vault
|
||
// is already unlocked, immediately encrypt+save and then reveal normally.
|
||
if(initialVaultId&&isVaultUnlocked(initialVaultId)){
|
||
_log('initEditorPanel vault plaintext-in-vault auto-encrypt', {noteId:noteId,vaultId:initialVaultId});
|
||
_completeUnlock(noteId,ta.value,initialVaultId);
|
||
_doEncryptNoteInVault(noteId,initialVaultId);
|
||
}else{
|
||
var lockedDiv=document.getElementById('editor-locked');
|
||
var host=queryActiveEditor('#cm-host');
|
||
var pv=queryActiveEditor('#note-preview');
|
||
var tb=queryActiveEditor('#editor-toolbar');
|
||
var mdBtn=document.getElementById('markdown-toggle');
|
||
var pvBtn=document.getElementById('preview-toggle');
|
||
if(lockedDiv)lockedDiv.style.display='';
|
||
if(tb)tb.style.display='none';
|
||
if(host)host.style.display='none';
|
||
if(pv)pv.style.display='none';
|
||
if(ta)ta.style.display='none';
|
||
if(mdBtn)mdBtn.style.display='none';
|
||
if(pvBtn)pvBtn.style.display='none';
|
||
var pwField=document.getElementById('editor-locked-password');
|
||
if(pwField){
|
||
_log('initEditorPanel prompting for vault password (plaintext note in vault)',{noteId:noteId,vaultId:initialVaultId});
|
||
pwField.focus();
|
||
pwField.addEventListener('keydown',function(e){
|
||
if(e.key==='Enter'){e.preventDefault();_showVaultModal(initialVaultId,'unlock',function(ok){if(ok)window.location.reload()})}
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Determine vault from ciphertext
|
||
var vaultId=getBodyVaultId(ta.value);
|
||
_log('initEditorPanel encrypted note detected',{noteId:noteId,vaultId:vaultId,bodyLength:ta.value.length});
|
||
|
||
// Store vaultId on form for autosave
|
||
if(vaultId&&form)form.dataset.vaultId=vaultId;
|
||
|
||
// Try auto-unlock with cached vault key
|
||
var unlocked=vaultId&&isVaultUnlocked(vaultId);
|
||
_log('initEditorPanel auto-unlock decision',{noteId:noteId,vaultId:vaultId,unlocked:!!unlocked});
|
||
if(unlocked){
|
||
getVaultKey(vaultId).then(function(key){
|
||
_log('initEditorPanel cached key lookup',{noteId:noteId,vaultId:vaultId,hasKey:!!key});
|
||
if(!key)return;
|
||
return _decryptWithKey(ta.value,key).then(function(pt){_completeUnlock(noteId,pt,vaultId)});
|
||
}).catch(function(){
|
||
_log('auto-unlock failed for vault',vaultId);
|
||
var pwField=document.getElementById('editor-locked-password');
|
||
if(pwField)pwField.focus();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Focus password field and handle Enter key
|
||
var pwField=document.getElementById('editor-locked-password');
|
||
if(pwField){
|
||
_log('initEditorPanel prompting for vault password',{noteId:noteId,vaultId:vaultId});
|
||
pwField.focus();
|
||
pwField.addEventListener('keydown',function(e){
|
||
if(e.key==='Enter'){e.preventDefault();unlockNote(noteId)}
|
||
});
|
||
}
|
||
};
|
||
|
||
// Move note: encrypt/decrypt when folder changes
|
||
// Called when user changes folder via the editor folder select
|
||
(function(){
|
||
document.body.addEventListener('change',function(e){
|
||
var select=e.target;
|
||
if(!select||select.id!=='editor-folder-select')return;
|
||
var form=activeEditorForm();
|
||
if(!form)return;
|
||
var noteId=form.dataset.noteId;
|
||
var ta=getTA();
|
||
if(!ta||!noteId)return;
|
||
|
||
var newFolderId=select.value;
|
||
var oldVaultId=form.dataset.vaultId||null;
|
||
var isEnc=form.dataset.encrypted==='1';
|
||
|
||
// Determine if destination is a vault (check nav DOM for vault icon)
|
||
var newFolderIsVault=!!document.querySelector('.vault-folder-lock[data-folder-id="'+newFolderId+'"]');
|
||
|
||
if(!isEnc&&!newFolderIsVault)return; // plain note to plain folder, nothing to do
|
||
|
||
// Helper: only show the unlock modal if the vault is not already unlocked
|
||
var _ensureUnlocked=function(vaultId,cb){
|
||
if(isVaultUnlocked(vaultId)){cb(true);return}
|
||
_showVaultModal(vaultId,'unlock',cb);
|
||
};
|
||
|
||
if(isEnc&&!newFolderIsVault){
|
||
// Moving encrypted note out of vault → save as plaintext.
|
||
// ta.value is already plaintext (note is open and unlocked in editor).
|
||
if(typeof _saveTimer!=='undefined'&&_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}
|
||
delete form.dataset.encrypted;
|
||
delete form.dataset.vaultId;
|
||
delete form.dataset.vaultUnlocked;
|
||
_updateLockToggle(noteId,false);
|
||
_updateNoteLockIcon(noteId,false);
|
||
htmx.trigger(form,'joplock:save');
|
||
}else if(!isEnc&&newFolderIsVault){
|
||
// Moving plain note into vault → encrypt it
|
||
select.value=form.dataset.vaultId||'';
|
||
_ensureUnlocked(newFolderId,function(ok){
|
||
if(!ok)return;
|
||
select.value=newFolderId;
|
||
_doEncryptNoteInVault(noteId,newFolderId);
|
||
});
|
||
}else if(isEnc&&newFolderIsVault&&oldVaultId!==newFolderId){
|
||
// Moving between vaults → re-encrypt with new vault key.
|
||
// ta.value is plaintext (note is open and unlocked); old vault key
|
||
// is irrelevant for the body content. We just need the new vault
|
||
// unlocked so we can encrypt with its key.
|
||
select.value=oldVaultId;
|
||
_ensureUnlocked(newFolderId,function(ok){
|
||
if(!ok)return;
|
||
select.value=newFolderId;
|
||
if(typeof _saveTimer!=='undefined'&&_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}
|
||
getVaultKey(newFolderId).then(function(newKey){
|
||
if(!newKey){_log('new vault key missing',newFolderId);return}
|
||
var salt=getVaultSalt(newFolderId);
|
||
if(!salt){_log('new vault salt missing',newFolderId);return}
|
||
return encryptForVault(ta.value,newFolderId,newKey,salt).then(function(ct){
|
||
form.dataset.vaultId=newFolderId;
|
||
form.dataset.encrypted='1';
|
||
_triggerEncryptedSave(form,ct);
|
||
});
|
||
}).catch(function(e){_log('re-encrypt failed',e);alert('Re-encryption failed: '+e.message)});
|
||
});
|
||
}
|
||
});
|
||
})();
|
||
|
||
// v1 migration: scan for v1 encrypted notes, offer to migrate to a vault
|
||
// Called after first vault is created
|
||
async function migrateV1Notes(newVaultFolderId){
|
||
try{
|
||
var resp=await fetch('/api/web/notes',{method:'GET'});
|
||
if(!resp.ok)return;
|
||
var data=await resp.json();
|
||
var notes=(data.items||[]).filter(function(n){return n.isEncrypted});
|
||
if(!notes.length)return;
|
||
// Check if any are v1 (no vault field)
|
||
// We can't tell without fetching each note body. Check first few.
|
||
var v1candidates=[];
|
||
for(var i=0;i<Math.min(notes.length,50);i++){
|
||
var nr=await fetch('/api/web/notes/'+encodeURIComponent(notes[i].id));
|
||
if(!nr.ok)continue;
|
||
var nd=await nr.json();
|
||
var body=(nd.item||{}).body||'';
|
||
var json=extractCiphertext(body);
|
||
if(!json)continue;
|
||
try{var obj=JSON.parse(json);if(!obj.vault)v1candidates.push({id:notes[i].id,body:body})}catch(e){}
|
||
}
|
||
if(!v1candidates.length)return;
|
||
var oldPw=prompt('Found '+v1candidates.length+' note(s) encrypted with your old password.\nEnter that password to migrate them to your new vault (or Cancel to skip):');
|
||
if(!oldPw)return;
|
||
var key=await getVaultKey(newVaultFolderId);
|
||
var salt=getVaultSalt(newVaultFolderId);
|
||
if(!key||!salt)return;
|
||
var migrated=0;
|
||
for(var j=0;j<v1candidates.length;j++){
|
||
try{
|
||
var pt=await decryptBody(oldPw,v1candidates[j].body);
|
||
var newCt=await encryptForVault(pt,newVaultFolderId,key,salt);
|
||
await fetch('/api/web/notes/'+encodeURIComponent(v1candidates[j].id),{
|
||
method:'PUT',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({body:newCt})
|
||
});
|
||
migrated++;
|
||
}catch(e){_log('migrate v1 note failed',v1candidates[j].id,e)}
|
||
}
|
||
if(migrated>0)alert('Migrated '+migrated+' note(s) to your new vault.');
|
||
}catch(e){_log('migrateV1Notes error',e)}
|
||
}
|
||
|
||
// Create vault flow: called from folder creation modal (new vault checkbox)
|
||
async function submitNewVaultFolder(event){
|
||
if(event)event.preventDefault();
|
||
var modal=document.getElementById('new-folder-modal');
|
||
var origin=modal&&modal.dataset?modal.dataset.origin:'';
|
||
var titleInput=document.getElementById('new-folder-title');
|
||
var pwInput=document.getElementById('new-vault-password');
|
||
var confirmInput=document.getElementById('new-vault-confirm');
|
||
var errEl=document.getElementById('new-vault-error');
|
||
var title=(titleInput?titleInput.value:'').trim();
|
||
var password=(pwInput?pwInput.value:'').trim();
|
||
var confirmVal=(confirmInput?confirmInput.value:'').trim();
|
||
|
||
if(!title){if(errEl)errEl.textContent='Notebook name is required.';return}
|
||
if(!password){if(errEl)errEl.textContent='Vault password is required.';return}
|
||
if(password!==confirmVal){if(errEl)errEl.textContent='Passwords do not match.';return}
|
||
if(password.length<4){if(errEl)errEl.textContent='Password too short (minimum 4 characters).';return}
|
||
if(errEl)errEl.textContent='';
|
||
|
||
try{
|
||
// Create folder via API
|
||
var folderResp=await fetch('/api/web/folders',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({title:title})
|
||
});
|
||
if(!folderResp.ok){var ferr=await folderResp.json().catch(function(){return{}});throw new Error(ferr.error||'Failed to create notebook')}
|
||
var folderData=await folderResp.json();
|
||
var folderId=(folderData.item||{}).id;
|
||
if(!folderId)throw new Error('No notebook id returned');
|
||
|
||
// Create vault
|
||
await createVault(folderId,password);
|
||
|
||
// Check for v1 notes to migrate
|
||
migrateV1Notes(folderId);
|
||
|
||
// Close modal and refresh relevant notebook list
|
||
closeNewFolderModal();
|
||
refreshAfterFolderCreate(origin);
|
||
}catch(e){
|
||
if(errEl)errEl.textContent='Error: '+(e.message||e);
|
||
}
|
||
}
|
||
|
||
function closeNewFolderModal(){
|
||
var modal=document.getElementById('new-folder-modal');
|
||
var backdrop=document.getElementById('new-folder-modal-backdrop');
|
||
if(modal)modal.hidden=true;
|
||
if(backdrop)backdrop.hidden=true;
|
||
if(modal)delete modal.dataset.origin;
|
||
}
|
||
|
||
function openNewFolderModal(origin){
|
||
var modal=document.getElementById('new-folder-modal');
|
||
var backdrop=document.getElementById('new-folder-modal-backdrop');
|
||
var titleInput=document.getElementById('new-folder-title');
|
||
var errEl=document.getElementById('new-vault-error');
|
||
var isVaultCheck=document.getElementById('new-folder-is-vault');
|
||
var vaultFields=document.getElementById('new-vault-fields');
|
||
var pwInput=document.getElementById('new-vault-password');
|
||
var confirmInput=document.getElementById('new-vault-confirm');
|
||
if(titleInput)titleInput.value='';
|
||
if(errEl)errEl.textContent='';
|
||
if(isVaultCheck)isVaultCheck.checked=false;
|
||
if(vaultFields)vaultFields.style.display='none';
|
||
if(pwInput)pwInput.value='';
|
||
if(confirmInput)confirmInput.value='';
|
||
if(modal){
|
||
if(origin)modal.dataset.origin=origin;
|
||
else delete modal.dataset.origin;
|
||
}
|
||
if(modal)modal.hidden=false;
|
||
if(backdrop)backdrop.hidden=false;
|
||
if(titleInput)titleInput.focus();
|
||
}
|
||
|
||
function refreshAfterFolderCreate(origin){
|
||
if(origin==='mobile'){
|
||
var body=document.getElementById('mobile-folders-body');
|
||
if(body)htmx.ajax('GET','/fragments/mobile/folders',{target:'#mobile-folders-body',swap:'innerHTML'});
|
||
return;
|
||
}
|
||
htmx.ajax('GET','/fragments/nav',{target:'#nav-panel',swap:'innerHTML'});
|
||
}
|
||
|
||
function toggleNewFolderVault(checked){
|
||
var fields=document.getElementById('new-vault-fields');
|
||
if(fields)fields.style.display=checked?'':'none';
|
||
}
|
||
|
||
async function submitNewFolderModal(event){
|
||
if(event)event.preventDefault();
|
||
var isVaultCheck=document.getElementById('new-folder-is-vault');
|
||
var modal=document.getElementById('new-folder-modal');
|
||
var origin=modal&&modal.dataset?modal.dataset.origin:'';
|
||
if(isVaultCheck&&isVaultCheck.checked){
|
||
await submitNewVaultFolder(event);
|
||
}else{
|
||
// Regular folder creation
|
||
var titleInput=document.getElementById('new-folder-title');
|
||
var errEl=document.getElementById('new-vault-error');
|
||
var title=(titleInput?titleInput.value:'').trim();
|
||
if(!title){if(errEl)errEl.textContent='Notebook name is required.';return}
|
||
htmx.ajax('POST','/fragments/folders',{target:origin==='mobile'?'#mobile-folders-body':'#nav-panel',swap:'none',values:{title:title}}).then(function(){
|
||
refreshAfterFolderCreate(origin);
|
||
});
|
||
closeNewFolderModal();
|
||
}
|
||
}
|
||
|
||
// Expose functions needed by inline hx-on/onclick handlers (called from global scope by htmx eval)
|
||
window.isMobileShellMode=isMobileShellMode;
|
||
window.closeNav=closeNav;
|
||
window.toggleNav=toggleNav;
|
||
window.toggleNavFolder=toggleNavFolder;
|
||
window.openNavFolderAndFirstNote=openNavFolderAndFirstNote;
|
||
window.openFolderContextMenu=openFolderContextMenu;
|
||
window.editFolderFromMenu=editFolderFromMenu;
|
||
window.deleteFolderFromMenu=deleteFolderFromMenu;
|
||
window.closeFolderModal=closeFolderModal;
|
||
window.submitFolderEdit=submitFolderEdit;
|
||
window.openEmptyTrashModal=openEmptyTrashModal;
|
||
window.closeEmptyTrashModal=closeEmptyTrashModal;
|
||
window.submitEmptyTrash=submitEmptyTrash;
|
||
window.closeLinkModal=closeLinkModal;
|
||
window.submitLink=submitLink;
|
||
window.closeHistoryModal=closeHistoryModal;
|
||
window.openHistoryModal=openHistoryModal;
|
||
window.selectHistorySnapshot=selectHistorySnapshot;
|
||
window.restoreHistorySnapshot=restoreHistorySnapshot;
|
||
window.setEditorMode=setEditorMode;
|
||
window.tinyMCEFormat=tinyMCEFormat;
|
||
window.tinyMCEFormatBlock=tinyMCEFormatBlock;
|
||
window.tinyMCEInsertCheckbox=tinyMCEInsertCheckbox;
|
||
window.tinyMCEInsertDate=tinyMCEInsertDate;
|
||
window.tinyMCEInsertDateTime=tinyMCEInsertDateTime;
|
||
window.tinyMCEInsertLink=tinyMCEInsertLink;
|
||
window.tinyMCEInsertImage=tinyMCEInsertImage;
|
||
window.wrapSel=wrapSel;
|
||
window.insertPfx=insertPfx;
|
||
window.insertTxt=insertTxt;
|
||
window.insertStamp=insertStamp;
|
||
window.clearFormat=clearFormat;
|
||
window.insertLink=insertLink;
|
||
window.insertImg=insertImg;
|
||
window.openFilePicker=openFilePicker;
|
||
window.handleFilePicker=handleFilePicker;
|
||
window.uploadFiles=uploadFiles;
|
||
window.uploadFile=uploadFile;
|
||
window.openCodeModal=openCodeModal;
|
||
window.closeCodeModal=closeCodeModal;
|
||
window.submitCode=submitCode;
|
||
window.handleDrop=handleDrop;
|
||
window.openUploadModal=openUploadModal;
|
||
window.closeUploadModal=closeUploadModal;
|
||
window.handleUploadModalFiles=handleUploadModalFiles;
|
||
window.insertUploadedFiles=insertUploadedFiles;
|
||
window.deleteUploadedResource=deleteUploadedResource;
|
||
window.undoSnapshot=undoSnapshot;
|
||
window.searchNavStep=searchNavStep;
|
||
window.searchNavDismiss=searchNavDismiss;
|
||
window.dismissRemoteUpdateBanner=dismissRemoteUpdateBanner;
|
||
window.reloadCurrentNoteFromServer=reloadCurrentNoteFromServer;
|
||
window.overwriteWithLocalEdits=overwriteWithLocalEdits;
|
||
window.syncPV=syncPV;
|
||
window.getPV=getPV;
|
||
window.setTheme=setTheme;
|
||
window.confirmLogout=confirmLogout;
|
||
window.lockNote=lockNote;
|
||
window.unlockNote=unlockNote;
|
||
window.toggleNoteLock=toggleNoteLock;
|
||
window.toggleVaultLock=toggleVaultLock;
|
||
window.refreshAllVaultIcons=refreshAllVaultIcons;
|
||
window.isVaultUnlocked=isVaultUnlocked;
|
||
window.submitVaultModal=submitVaultModal;
|
||
window.closeVaultModal=closeVaultModal;
|
||
window.openNewFolderModal=openNewFolderModal;
|
||
window.closeNewFolderModal=closeNewFolderModal;
|
||
window.toggleNewFolderVault=toggleNewFolderVault;
|
||
window.submitNewFolderModal=submitNewFolderModal;
|
||
window.isEncryptedBody=isEncryptedBody;
|
||
window.mobileSyncTitle=mobileSyncTitle;
|
||
window.mobileSyncTitleAndSave=mobileSyncTitleAndSave;
|
||
window.mobileTitleInput=function(){_titleManual=true}; // called oninput on #mobile-editor-title
|
||
})(); // end main IIFE
|