commit c48c25769a14b293a7fa272456ff6405008779f8 Author: Nino Nakano Date: Sun Jul 26 21:34:07 2026 -0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f815c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Version control +.svn/ + +# OS-specific files +.DS_Store +Thumbs.db + +# Project Specific +bugs-encontrados.txt +opencode.txt +DocmostApp.md + +# App build +app/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4268865 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Docmost App + +Cliente Android no oficial para [Docmost](https://docmost.com) — una wiki colaborativa de código abierto. + +## Funcionalidades + +- **Autenticación por cookie** — login contra servidor Docmost Community Edition +- **Lista de espacios** — espacios expandibles con páginas en sidebar +- **Editor Markdown** — edición de páginas con TipTap → Markdown, toggle Edit/Preview con Markwon +- **Iconos de espacio** — carga y muestra desde el servidor (OkHttp + cache Lru) +- **Editor de espacio** — modificar nombre, descripción, icono (subir/eliminar) +- **Emojis de página** — muestra el `icon` emoji de cada página +- **UI Material 3** — MaterialCardView, Dynamic Colors, tema en español + +## Requisitos + +- Android 8.0 (API 26) o superior +- Servidor Docmost (Community Edition) accesible desde el dispositivo + +## Compilación + +```bash +./gradlew assembleDebug +``` + +## Capturas de pantalla + +*(pendiente)* + +## Licencia + +MIT diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..67973f7 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + kotlin("kapt") +} + +android { + namespace = "com.docmost.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.docmost.app" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-ktx:1.8.2") + implementation("com.google.code.gson:gson:2.10.1") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Biometric authentication + implementation("androidx.biometric:biometric:1.1.0") + + // Encrypted SharedPreferences + implementation("androidx.security:security-crypto:1.0.0") + + // SwipeRefreshLayout + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // Markwon - Markdown rendering + implementation("io.noties.markwon:core:4.6.2") + implementation("io.noties.markwon:ext-strikethrough:4.6.2") + implementation("io.noties.markwon:ext-tables:4.6.2") + implementation("io.noties.markwon:ext-tasklist:4.6.2") + + // Coil - Image loading + implementation("io.coil-kt:coil:2.7.0") + + // Room - Local database for sync queue + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + kapt("androidx.room:room-compiler:2.6.1") + + // WorkManager - Background sync + implementation("androidx.work:work-runtime-ktx:2.9.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..17bc0e8 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,6 @@ +# Add project specific ProGuard rules here. +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.docmost.app.api.** { *; } +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f58fce --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/editor/checklist.umd.js b/app/src/main/assets/editor/checklist.umd.js new file mode 100644 index 0000000..f0196af --- /dev/null +++ b/app/src/main/assets/editor/checklist.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.cdx-checklist{gap:6px;display:flex;flex-direction:column}.cdx-checklist__item{display:flex;box-sizing:content-box;align-items:flex-start}.cdx-checklist__item-text{outline:none;flex-grow:1;line-height:1.57em}.cdx-checklist__item-checkbox{width:22px;height:22px;display:flex;align-items:center;margin-right:8px;margin-top:calc(.785em - 11px);cursor:pointer}.cdx-checklist__item-checkbox svg{opacity:0;height:20px;width:20px;position:absolute;left:-1px;top:-1px;max-height:20px}@media (hover: hover){.cdx-checklist__item-checkbox:not(.cdx-checklist__item-checkbox--no-hover):hover .cdx-checklist__item-checkbox-check svg{opacity:1}}.cdx-checklist__item-checkbox-check{cursor:pointer;display:inline-block;flex-shrink:0;position:relative;width:20px;height:20px;box-sizing:border-box;margin-left:0;border-radius:5px;border:1px solid #C9C9C9;background:#fff}.cdx-checklist__item-checkbox-check:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:100%;background-color:#369fff;visibility:hidden;pointer-events:none;transform:scale(1);transition:transform .4s ease-out,opacity .4s}@media (hover: hover){.cdx-checklist__item--checked .cdx-checklist__item-checkbox:not(.cdx-checklist__item--checked .cdx-checklist__item-checkbox--no-hover):hover .cdx-checklist__item-checkbox-check{background:#0059AB;border-color:#0059ab}}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check{background:#369FFF;border-color:#369fff}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check svg{opacity:1}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check svg path{stroke:#fff}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check:before{opacity:0;visibility:visible;transform:scale(2.5)}')),document.head.appendChild(e)}}catch(c){console.error("vite-plugin-css-injected-by-js",c)}})(); +(function(l,c){typeof exports=="object"&&typeof module<"u"?module.exports=c():typeof define=="function"&&define.amd?define(c):(l=typeof globalThis<"u"?globalThis:l||self,l.Checklist=c())})(this,function(){"use strict";const l="",c='',g='';function d(){const s=document.activeElement,t=window.getSelection().getRangeAt(0),n=t.cloneRange();return n.selectNodeContents(s),n.setStart(t.endContainer,t.endOffset),n.extractContents()}function f(s){const e=document.createElement("div");return e.appendChild(s),e.innerHTML}function o(s,e=null,t={}){const n=document.createElement(s);Array.isArray(e)?n.classList.add(...e):e&&n.classList.add(e);for(const i in t)n[i]=t[i];return n}function m(s){return s.innerHTML.replace("
"," ").trim()}function p(s,e=!1,t=void 0){const n=document.createRange(),i=window.getSelection();n.selectNodeContents(s),t!==void 0&&(n.setStart(s,t),n.setEnd(s,t)),n.collapse(e),i.removeAllRanges(),i.addRange(n)}Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(s){let e=this;if(!document.documentElement.contains(e))return null;do{if(e.matches(s))return e;e=e.parentElement||e.parentNode}while(e!==null&&e.nodeType===1);return null});class C{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:g,title:"Checklist"}}static get conversionConfig(){return{export:e=>e.items.map(({text:t})=>t).join(". "),import:e=>({items:[{text:e,checked:!1}]})}}constructor({data:e,config:t,api:n,readOnly:i}){this._elements={wrapper:null,items:[]},this.readOnly=i,this.api=n,this.data=e||{}}render(){return this._elements.wrapper=o("div",[this.CSS.baseBlock,this.CSS.wrapper]),this.data.items||(this.data.items=[{text:"",checked:!1}]),this.data.items.forEach(e=>{const t=this.createChecklistItem(e);this._elements.wrapper.appendChild(t)}),this.readOnly?this._elements.wrapper:(this._elements.wrapper.addEventListener("keydown",e=>{const[t,n]=[13,8];switch(e.keyCode){case t:this.enterPressed(e);break;case n:this.backspace(e);break}},!1),this._elements.wrapper.addEventListener("click",e=>{this.toggleCheckbox(e)}),this._elements.wrapper)}save(){let e=this.items.map(t=>{const n=this.getItemInput(t);return{text:m(n),checked:t.classList.contains(this.CSS.itemChecked)}});return e=e.filter(t=>t.text.trim().length!==0),{items:e}}validate(e){return!!e.items.length}toggleCheckbox(e){const t=e.target.closest(`.${this.CSS.item}`),n=t.querySelector(`.${this.CSS.checkboxContainer}`);n.contains(e.target)&&(t.classList.toggle(this.CSS.itemChecked),n.classList.add(this.CSS.noHover),n.addEventListener("mouseleave",()=>this.removeSpecialHoverBehavior(n),{once:!0}))}createChecklistItem(e={}){const t=o("div",this.CSS.item),n=o("span",this.CSS.checkbox),i=o("div",this.CSS.checkboxContainer),a=o("div",this.CSS.textField,{innerHTML:e.text?e.text:"",contentEditable:!this.readOnly});return e.checked&&t.classList.add(this.CSS.itemChecked),n.innerHTML=c,i.appendChild(n),t.appendChild(i),t.appendChild(a),t}enterPressed(e){e.preventDefault();const t=this.items,n=document.activeElement.closest(`.${this.CSS.item}`);if(t.indexOf(n)===t.length-1&&m(this.getItemInput(n)).length===0){const x=this.api.blocks.getCurrentBlockIndex();n.remove(),this.api.blocks.insert(),this.api.caret.setToBlock(x+1);return}const u=d(),h=f(u),r=this.createChecklistItem({text:h,checked:!1});this._elements.wrapper.insertBefore(r,n.nextSibling),p(this.getItemInput(r),!0)}backspace(e){const t=e.target.closest(`.${this.CSS.item}`),n=this.items.indexOf(t),i=this.items[n-1];if(!i||!(window.getSelection().focusOffset===0))return;e.preventDefault();const h=d(),r=this.getItemInput(i),k=r.childNodes.length;r.appendChild(h),p(r,void 0,k),t.remove()}get CSS(){return{baseBlock:this.api.styles.block,wrapper:"cdx-checklist",item:"cdx-checklist__item",itemChecked:"cdx-checklist__item--checked",noHover:"cdx-checklist__item-checkbox--no-hover",checkbox:"cdx-checklist__item-checkbox-check",textField:"cdx-checklist__item-text",checkboxContainer:"cdx-checklist__item-checkbox"}}get items(){return Array.from(this._elements.wrapper.querySelectorAll(`.${this.CSS.item}`))}removeSpecialHoverBehavior(e){e.classList.remove(this.CSS.noHover)}getItemInput(e){return e.querySelector(`.${this.CSS.textField}`)}}return C}); diff --git a/app/src/main/assets/editor/code.umd.js b/app/src/main/assets/editor/code.umd.js new file mode 100644 index 0000000..ecdd4ec --- /dev/null +++ b/app/src/main/assets/editor/code.umd.js @@ -0,0 +1,11 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-code__textarea{min-height:200px;font-family:Menlo,Monaco,Consolas,Courier New,monospace;color:#41314e;line-height:1.6em;font-size:12px;background:#f8f7fa;border:1px solid #f1f1f4;box-shadow:none;white-space:pre;word-wrap:normal;overflow-x:auto;resize:vertical}")),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})(); +(function(o,i){typeof exports=="object"&&typeof module<"u"?module.exports=i():typeof define=="function"&&define.amd?define(i):(o=typeof globalThis<"u"?globalThis:o||self,o.CodeTool=i())})(this,function(){"use strict";const o="";function i(u,t){let s="";for(;s!==` +`&&t>0;)t=t-1,s=u.substr(t,1);return s===` +`&&(t+=1),t}const h='';/** + * CodeTool for Editor.js + * + * @author CodeX (team@ifmo.su) + * @copyright CodeX 2018 + * @license MIT + * @version 2.0.0 + */class c{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}constructor({data:t,config:e,api:s,readOnly:n}){this.api=s,this.readOnly=n,this.placeholder=this.api.i18n.t(e.placeholder||c.DEFAULT_PLACEHOLDER),this.CSS={baseClass:this.api.styles.block,input:this.api.styles.input,wrapper:"ce-code",textarea:"ce-code__textarea"},this.nodes={holder:null,textarea:null},this.data={code:t.code||""},this.nodes.holder=this.drawView()}drawView(){const t=document.createElement("div"),e=document.createElement("textarea");return t.classList.add(this.CSS.baseClass,this.CSS.wrapper),e.classList.add(this.CSS.textarea,this.CSS.input),e.textContent=this.data.code,e.placeholder=this.placeholder,this.readOnly&&(e.disabled=!0),t.appendChild(e),e.addEventListener("keydown",s=>{switch(s.code){case"Tab":this.tabHandler(s);break}}),this.nodes.textarea=e,t}render(){return this.nodes.holder}save(t){return{code:t.querySelector("textarea").value}}onPaste(t){const e=t.detail.data;this.data={code:e.textContent}}get data(){return this._data}set data(t){this._data=t,this.nodes.textarea&&(this.nodes.textarea.textContent=t.code)}static get toolbox(){return{icon:h,title:"Code"}}static get DEFAULT_PLACEHOLDER(){return"Enter a code"}static get pasteConfig(){return{tags:["pre"]}}static get sanitize(){return{code:!0}}tabHandler(t){t.stopPropagation(),t.preventDefault();const e=t.target,s=t.shiftKey,n=e.selectionStart,r=e.value,a=" ";let d;if(!s)d=n+a.length,e.value=r.substring(0,n)+a+r.substring(n);else{const l=i(r,n);if(r.substr(l,a.length)!==a)return;e.value=r.substring(0,l)+r.substring(l+a.length),d=n-a.length}e.setSelectionRange(d,d)}}return c}); diff --git a/app/src/main/assets/editor/delimiter.umd.js b/app/src/main/assets/editor/delimiter.umd.js new file mode 100644 index 0000000..d645ad3 --- /dev/null +++ b/app/src/main/assets/editor/delimiter.umd.js @@ -0,0 +1,9 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.ce-delimiter{line-height:1.6em;width:100%;text-align:center}.ce-delimiter:before{display:inline-block;content:"***";font-size:30px;line-height:65px;height:30px;letter-spacing:.2em}')),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); +(function(i,e){typeof exports=="object"&&typeof module<"u"?module.exports=e():typeof define=="function"&&define.amd?define(e):(i=typeof globalThis<"u"?globalThis:i||self,i.Delimiter=e())})(this,function(){"use strict";const i="",e='';/** + * Delimiter Block for the Editor.js. + * + * @author CodeX (team@ifmo.su) + * @copyright CodeX 2018 + * @license The MIT License (MIT) + * @version 2.0.0 + */class r{static get isReadOnlySupported(){return!0}static get contentless(){return!0}constructor({data:t,config:o,api:n}){this.api=n,this._CSS={block:this.api.styles.block,wrapper:"ce-delimiter"},this._data={},this._element=this.drawView(),this.data=t}drawView(){let t=document.createElement("DIV");return t.classList.add(this._CSS.wrapper,this._CSS.block),t}render(){return this._element}save(t){return{}}static get toolbox(){return{icon:e,title:"Delimiter"}}}return r}); diff --git a/app/src/main/assets/editor/editorjs.umd.js b/app/src/main/assets/editor/editorjs.umd.js new file mode 100644 index 0000000..8846696 --- /dev/null +++ b/app/src/main/assets/editor/editorjs.umd.js @@ -0,0 +1,83 @@ +(function(he,J){typeof exports=="object"&&typeof module<"u"?module.exports=J():typeof define=="function"&&define.amd?define(J):(he=typeof globalThis<"u"?globalThis:he||self,he.EditorJS=J())})(this,function(){"use strict";var he=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function J(s){return s&&s.__esModule&&Object.prototype.hasOwnProperty.call(s,"default")?s.default:s}function Be(){}Object.assign(Be,{default:Be,register:Be,revert:function(){},__esModule:!0}),Element.prototype.matches||(Element.prototype.matches=Element.prototype.matchesSelector||Element.prototype.mozMatchesSelector||Element.prototype.msMatchesSelector||Element.prototype.oMatchesSelector||Element.prototype.webkitMatchesSelector||function(s){const e=(this.document||this.ownerDocument).querySelectorAll(s);let t=e.length;for(;--t>=0&&e.item(t)!==this;);return t>-1}),Element.prototype.closest||(Element.prototype.closest=function(s){let e=this;if(!document.documentElement.contains(e))return null;do{if(e.matches(s))return e;e=e.parentElement||e.parentNode}while(e!==null);return null}),Element.prototype.prepend||(Element.prototype.prepend=function(e){const t=document.createDocumentFragment();Array.isArray(e)||(e=[e]),e.forEach(o=>{const i=o instanceof Node;t.appendChild(i?o:document.createTextNode(o))}),this.insertBefore(t,this.firstChild)}),Element.prototype.scrollIntoViewIfNeeded||(Element.prototype.scrollIntoViewIfNeeded=function(s){s=arguments.length===0?!0:!!s;const e=this.parentNode,t=window.getComputedStyle(e,null),o=parseInt(t.getPropertyValue("border-top-width")),i=parseInt(t.getPropertyValue("border-left-width")),n=this.offsetTop-e.offsetTope.scrollTop+e.clientHeight,a=this.offsetLeft-e.offsetLefte.scrollLeft+e.clientWidth,c=n&&!r;(n||r)&&s&&(e.scrollTop=this.offsetTop-e.offsetTop-e.clientHeight/2-o+this.clientHeight/2),(a||l)&&s&&(e.scrollLeft=this.offsetLeft-e.offsetLeft-e.clientWidth/2-i+this.clientWidth/2),(n||r||a||l)&&!s&&this.scrollIntoView(c)}),window.requestIdleCallback=window.requestIdleCallback||function(s){const e=Date.now();return setTimeout(function(){s({didTimeout:!1,timeRemaining:function(){return Math.max(0,50-(Date.now()-e))}})},1)},window.cancelIdleCallback=window.cancelIdleCallback||function(s){clearTimeout(s)};let Lt=(s=21)=>crypto.getRandomValues(new Uint8Array(s)).reduce((e,t)=>(t&=63,t<36?e+=t.toString(36):t<62?e+=(t-26).toString(36).toUpperCase():t>62?e+="-":e+="_",e),"");var qe=(s=>(s.VERBOSE="VERBOSE",s.INFO="INFO",s.WARN="WARN",s.ERROR="ERROR",s))(qe||{});const B={BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,LEFT:37,UP:38,DOWN:40,RIGHT:39,DELETE:46,META:91},Ot={LEFT:0,WHEEL:1,RIGHT:2,BACKWARD:3,FORWARD:4};function ue(s,e,t="log",o,i="color: inherit"){if(!("console"in window)||!window.console[t])return;const n=["info","log","warn","error"].includes(t),r=[];switch(ue.logLevel){case"ERROR":if(t!=="error")return;break;case"WARN":if(!["error","warn"].includes(t))return;break;case"INFO":if(!n||s)return;break}o&&r.push(o);const a="Editor.js 2.28.2",l=`line-height: 1em; + color: #006FEA; + display: inline-block; + font-size: 11px; + line-height: 1em; + background-color: #fff; + padding: 4px 9px; + border-radius: 30px; + border: 1px solid rgba(56, 138, 229, 0.16); + margin: 4px 5px 4px 0;`;s&&(n?(r.unshift(l,i),e=`%c${a}%c ${e}`):e=`( ${a} )${e}`);try{n?o?console[t](`${e} %o`,...r):console[t](e,...r):console[t](e)}catch{}}ue.logLevel="VERBOSE";function At(s){ue.logLevel=s}const L=ue.bind(window,!1),K=ue.bind(window,!0);function ee(s){return Object.prototype.toString.call(s).match(/\s([a-zA-Z]+)/)[1].toLowerCase()}function D(s){return ee(s)==="function"||ee(s)==="asyncfunction"}function H(s){return ee(s)==="object"}function Q(s){return ee(s)==="string"}function _t(s){return ee(s)==="boolean"}function Ze(s){return ee(s)==="number"}function Ge(s){return ee(s)==="undefined"}function X(s){return s?Object.keys(s).length===0&&s.constructor===Object:!0}function Je(s){return s>47&&s<58||s===32||s===13||s===229||s>64&&s<91||s>95&&s<112||s>185&&s<193||s>218&&s<223}async function Nt(s,e=()=>{},t=()=>{}){async function o(i,n,r){try{await i.function(i.data),await n(Ge(i.data)?{}:i.data)}catch{r(Ge(i.data)?{}:i.data)}}return s.reduce(async(i,n)=>(await i,o(n,e,t)),Promise.resolve())}function Qe(s){return Array.prototype.slice.call(s)}function ne(s,e){return function(){const t=this,o=arguments;window.setTimeout(()=>s.apply(t,o),e)}}function Rt(s){return s.name.split(".").pop()}function Dt(s){return/^[-\w]+\/([-+\w]+|\*)$/.test(s)}function Pt(s,e,t){let o;return(...i)=>{const n=this,r=()=>{o=null,t||s.apply(n,i)},a=t&&!o;window.clearTimeout(o),o=window.setTimeout(r,e),a&&s.apply(n,i)}}function Te(s,e,t=void 0){let o,i,n,r=null,a=0;t||(t={});const l=function(){a=t.leading===!1?0:Date.now(),r=null,n=s.apply(o,i),r||(o=i=null)};return function(){const c=Date.now();!a&&t.leading===!1&&(a=c);const u=e-(c-a);return o=this,i=arguments,u<=0||u>e?(r&&(clearTimeout(r),r=null),a=c,n=s.apply(o,i),r||(o=i=null)):!r&&t.trailing!==!1&&(r=setTimeout(l,u)),n}}function Ft(){const s={win:!1,mac:!1,x11:!1,linux:!1},e=Object.keys(s).find(t=>window.navigator.appVersion.toLowerCase().indexOf(t)!==-1);return e&&(s[e]=!0),s}function se(s){return s[0].toUpperCase()+s.slice(1)}function Ce(s,...e){if(!e.length)return s;const t=e.shift();if(H(s)&&H(t))for(const o in t)H(t[o])?(s[o]||Object.assign(s,{[o]:{}}),Ce(s[o],t[o])):Object.assign(s,{[o]:t[o]});return Ce(s,...e)}function Se(s){const e=Ft();return s=s.replace(/shift/gi,"⇧").replace(/backspace/gi,"⌫").replace(/enter/gi,"⏎").replace(/up/gi,"↑").replace(/left/gi,"→").replace(/down/gi,"↓").replace(/right/gi,"←").replace(/escape/gi,"⎋").replace(/insert/gi,"Ins").replace(/delete/gi,"␡").replace(/\+/gi," + "),e.mac?s=s.replace(/ctrl|cmd/gi,"⌘").replace(/alt/gi,"⌥"):s=s.replace(/cmd/gi,"Ctrl").replace(/windows/gi,"WIN"),s}function Ht(s){try{return new URL(s).href}catch{}return s.substring(0,2)==="//"?window.location.protocol+s:window.location.origin+s}function jt(){return Lt(10)}function zt(s){window.open(s,"_blank")}function Ut(s=""){return`${s}${Math.floor(Math.random()*1e8).toString(16)}`}function Ie(s,e,t){const o=`«${e}» is deprecated and will be removed in the next major release. Please use the «${t}» instead.`;s&&K(o,"warn")}function re(s,e,t){const o=t.value?"value":"get",i=t[o],n=`#${e}Cache`;if(t[o]=function(...r){return this[n]===void 0&&(this[n]=i.apply(this,...r)),this[n]},o==="get"&&t.set){const r=t.set;t.set=function(a){delete s[n],r.apply(this,a)}}return t}const et=650;function te(){return window.matchMedia(`(max-width: ${et}px)`).matches}const tt=typeof window<"u"&&window.navigator&&window.navigator.platform&&(/iP(ad|hone|od)/.test(window.navigator.platform)||window.navigator.platform==="MacIntel"&&window.navigator.maxTouchPoints>1);function $t(s,e){const t=Array.isArray(s)||H(s),o=Array.isArray(e)||H(e);return t||o?JSON.stringify(s)===JSON.stringify(e):s===e}class d{static isSingleTag(e){return e.tagName&&["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"].includes(e.tagName)}static isLineBreakTag(e){return e&&e.tagName&&["BR","WBR"].includes(e.tagName)}static make(e,t=null,o={}){const i=document.createElement(e);Array.isArray(t)?i.classList.add(...t):t&&i.classList.add(t);for(const n in o)Object.prototype.hasOwnProperty.call(o,n)&&(i[n]=o[n]);return i}static text(e){return document.createTextNode(e)}static append(e,t){Array.isArray(t)?t.forEach(o=>e.appendChild(o)):e.appendChild(t)}static prepend(e,t){Array.isArray(t)?(t=t.reverse(),t.forEach(o=>e.prepend(o))):e.prepend(t)}static swap(e,t){const o=document.createElement("div"),i=e.parentNode;i.insertBefore(o,e),i.insertBefore(e,t),i.insertBefore(t,o),i.removeChild(o)}static find(e=document,t){return e.querySelector(t)}static get(e){return document.getElementById(e)}static findAll(e=document,t){return e.querySelectorAll(t)}static get allInputsSelector(){return"[contenteditable=true], textarea, input:not([type]), "+["text","password","email","number","search","tel","url"].map(t=>`input[type="${t}"]`).join(", ")}static findAllInputs(e){return Qe(e.querySelectorAll(d.allInputsSelector)).reduce((t,o)=>d.isNativeInput(o)||d.containsOnlyInlineElements(o)?[...t,o]:[...t,...d.getDeepestBlockElements(o)],[])}static getDeepestNode(e,t=!1){const o=t?"lastChild":"firstChild",i=t?"previousSibling":"nextSibling";if(e&&e.nodeType===Node.ELEMENT_NODE&&e[o]){let n=e[o];if(d.isSingleTag(n)&&!d.isNativeInput(n)&&!d.isLineBreakTag(n))if(n[i])n=n[i];else if(n.parentNode[i])n=n.parentNode[i];else return n.parentNode;return this.getDeepestNode(n,t)}return e}static isElement(e){return Ze(e)?!1:e&&e.nodeType&&e.nodeType===Node.ELEMENT_NODE}static isFragment(e){return Ze(e)?!1:e&&e.nodeType&&e.nodeType===Node.DOCUMENT_FRAGMENT_NODE}static isContentEditable(e){return e.contentEditable==="true"}static isNativeInput(e){const t=["INPUT","TEXTAREA"];return e&&e.tagName?t.includes(e.tagName):!1}static canSetCaret(e){let t=!0;if(d.isNativeInput(e))switch(e.type){case"file":case"checkbox":case"radio":case"hidden":case"submit":case"button":case"image":case"reset":t=!1;break}else t=d.isContentEditable(e);return t}static isNodeEmpty(e){let t;return this.isSingleTag(e)&&!this.isLineBreakTag(e)?!1:(this.isElement(e)&&this.isNativeInput(e)?t=e.value:t=e.textContent.replace("​",""),t.trim().length===0)}static isLeaf(e){return e?e.childNodes.length===0:!1}static isEmpty(e){e.normalize();const t=[e];for(;t.length>0;)if(e=t.shift(),!!e){if(this.isLeaf(e)&&!this.isNodeEmpty(e))return!1;e.childNodes&&t.push(...Array.from(e.childNodes))}return!0}static isHTMLString(e){const t=d.make("div");return t.innerHTML=e,t.childElementCount>0}static getContentLength(e){return d.isNativeInput(e)?e.value.length:e.nodeType===Node.TEXT_NODE?e.length:e.textContent.length}static get blockElements(){return["address","article","aside","blockquote","canvas","div","dl","dt","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","li","main","nav","noscript","ol","output","p","pre","ruby","section","table","tbody","thead","tr","tfoot","ul","video"]}static containsOnlyInlineElements(e){let t;Q(e)?(t=document.createElement("div"),t.innerHTML=e):t=e;const o=i=>!d.blockElements.includes(i.tagName.toLowerCase())&&Array.from(i.children).every(o);return Array.from(t.children).every(o)}static getDeepestBlockElements(e){return d.containsOnlyInlineElements(e)?[e]:Array.from(e.children).reduce((t,o)=>[...t,...d.getDeepestBlockElements(o)],[])}static getHolder(e){return Q(e)?document.getElementById(e):e}static isAnchor(e){return e.tagName.toLowerCase()==="a"}static offset(e){const t=e.getBoundingClientRect(),o=window.pageXOffset||document.documentElement.scrollLeft,i=window.pageYOffset||document.documentElement.scrollTop,n=t.top+i,r=t.left+o;return{top:n,left:r,bottom:n+t.height,right:r+t.width}}}const ot={ui:{blockTunes:{toggler:{"Click to tune":"","or drag to move":""}},inlineToolbar:{converter:{"Convert to":""}},toolbar:{toolbox:{Add:""}},popover:{Filter:"","Nothing found":""}},toolNames:{Text:"",Link:"",Bold:"",Italic:""},tools:{link:{"Add a link":""},stub:{"The block can not be displayed correctly.":""}},blockTunes:{delete:{Delete:"","Click to delete":""},moveUp:{"Move up":""},moveDown:{"Move down":""}}},ae=class{static ui(s,e){return ae._t(s,e)}static t(s,e){return ae._t(s,e)}static setDictionary(s){ae.currentDictionary=s}static _t(s,e){const t=ae.getNamespace(s);return!t||!t[e]?e:t[e]}static getNamespace(s){return s.split(".").reduce((t,o)=>!t||!Object.keys(t).length?{}:t[o],ae.currentDictionary)}};let $=ae;$.currentDictionary=ot;class it extends Error{}class ve{constructor(){this.subscribers={}}on(e,t){e in this.subscribers||(this.subscribers[e]=[]),this.subscribers[e].push(t)}once(e,t){e in this.subscribers||(this.subscribers[e]=[]);const o=i=>{const n=t(i),r=this.subscribers[e].indexOf(o);return r!==-1&&this.subscribers[e].splice(r,1),n};this.subscribers[e].push(o)}emit(e,t){X(this.subscribers)||!this.subscribers[e]||this.subscribers[e].reduce((o,i)=>{const n=i(o);return n!==void 0?n:o},t)}off(e,t){if(this.subscribers[e]===void 0){console.warn(`EventDispatcher .off(): there is no subscribers for event "${e.toString()}". Probably, .off() called before .on()`);return}for(let o=0;o{const l=this.allListeners.indexOf(n[a]);l>-1&&(this.allListeners.splice(l,1),r.element.removeEventListener(r.eventType,r.handler,r.options))})}offById(e){const t=this.findById(e);t&&t.element.removeEventListener(t.eventType,t.handler,t.options)}findOne(e,t,o){const i=this.findAll(e,t,o);return i.length>0?i[0]:null}findAll(e,t,o){let i;const n=e?this.findByEventTarget(e):[];return e&&t&&o?i=n.filter(r=>r.eventType===t&&r.handler===o):e&&t?i=n.filter(r=>r.eventType===t):i=n,i}removeAll(){this.allListeners.map(e=>{e.element.removeEventListener(e.eventType,e.handler,e.options)}),this.allListeners=[]}destroy(){this.removeAll()}findByEventTarget(e){return this.allListeners.filter(t=>{if(t.element===e)return t})}findByType(e){return this.allListeners.filter(t=>{if(t.eventType===e)return t})}findByHandler(e){return this.allListeners.filter(t=>{if(t.handler===e)return t})}findById(e){return this.allListeners.find(t=>t.id===e)}}class C{constructor({config:e,eventsDispatcher:t}){if(this.nodes={},this.listeners=new Me,this.readOnlyMutableListeners={on:(o,i,n,r=!1)=>{this.mutableListenerIds.push(this.listeners.on(o,i,n,r))},clearAll:()=>{for(const o of this.mutableListenerIds)this.listeners.offById(o);this.mutableListenerIds=[]}},this.mutableListenerIds=[],new.target===C)throw new TypeError("Constructors for abstract class Module are not allowed.");this.config=e,this.eventsDispatcher=t}set state(e){this.Editor=e}removeAllNodes(){for(const e in this.nodes){const t=this.nodes[e];t instanceof HTMLElement&&t.remove()}}get isRtl(){return this.config.i18n.direction==="rtl"}}class m{constructor(){this.instance=null,this.selection=null,this.savedSelectionRange=null,this.isFakeBackgroundEnabled=!1,this.commandBackground="backColor",this.commandRemoveFormat="removeFormat"}static get CSS(){return{editorWrapper:"codex-editor",editorZone:"codex-editor__redactor"}}static get anchorNode(){const e=window.getSelection();return e?e.anchorNode:null}static get anchorElement(){const e=window.getSelection();if(!e)return null;const t=e.anchorNode;return t?d.isElement(t)?t:t.parentElement:null}static get anchorOffset(){const e=window.getSelection();return e?e.anchorOffset:null}static get isCollapsed(){const e=window.getSelection();return e?e.isCollapsed:null}static get isAtEditor(){return this.isSelectionAtEditor(m.get())}static isSelectionAtEditor(e){if(!e)return!1;let t=e.anchorNode||e.focusNode;t&&t.nodeType===Node.TEXT_NODE&&(t=t.parentNode);let o=null;return t&&t instanceof Element&&(o=t.closest(`.${m.CSS.editorZone}`)),o?o.nodeType===Node.ELEMENT_NODE:!1}static isRangeAtEditor(e){if(!e)return;let t=e.startContainer;t&&t.nodeType===Node.TEXT_NODE&&(t=t.parentNode);let o=null;return t&&t instanceof Element&&(o=t.closest(`.${m.CSS.editorZone}`)),o?o.nodeType===Node.ELEMENT_NODE:!1}static get isSelectionExists(){return!!m.get().anchorNode}static get range(){return this.getRangeFromSelection(this.get())}static getRangeFromSelection(e){return e&&e.rangeCount?e.getRangeAt(0):null}static get rect(){let e=document.selection,t,o={x:0,y:0,width:0,height:0};if(e&&e.type!=="Control")return e=e,t=e.createRange(),o.x=t.boundingLeft,o.y=t.boundingTop,o.width=t.boundingWidth,o.height=t.boundingHeight,o;if(!window.getSelection)return L("Method window.getSelection is not supported","warn"),o;if(e=window.getSelection(),e.rangeCount===null||isNaN(e.rangeCount))return L("Method SelectionUtils.rangeCount is not supported","warn"),o;if(e.rangeCount===0)return o;if(t=e.getRangeAt(0).cloneRange(),t.getBoundingClientRect&&(o=t.getBoundingClientRect()),o.x===0&&o.y===0){const i=document.createElement("span");if(i.getBoundingClientRect){i.appendChild(document.createTextNode("​")),t.insertNode(i),o=i.getBoundingClientRect();const n=i.parentNode;n.removeChild(i),n.normalize()}}return o}static get text(){return window.getSelection?window.getSelection().toString():""}static get(){return window.getSelection()}static setCursor(e,t=0){const o=document.createRange(),i=window.getSelection();return d.isNativeInput(e)?d.canSetCaret(e)?(e.focus(),e.selectionStart=e.selectionEnd=t,e.getBoundingClientRect()):void 0:(o.setStart(e,t),o.setEnd(e,t),i.removeAllRanges(),i.addRange(o),o.getBoundingClientRect())}static isRangeInsideContainer(e){const t=m.range;return t===null?!1:e.contains(t.startContainer)}static addFakeCursor(){const e=m.range;if(e===null)return;const t=d.make("span","codex-editor__fake-cursor");t.dataset.mutationFree="true",e.collapse(),e.insertNode(t)}static isFakeCursorInsideContainer(e){return d.find(e,".codex-editor__fake-cursor")!==null}static removeFakeCursor(e=document.body){const t=d.find(e,".codex-editor__fake-cursor");t&&t.remove()}removeFakeBackground(){this.isFakeBackgroundEnabled&&(this.isFakeBackgroundEnabled=!1,document.execCommand(this.commandRemoveFormat))}setFakeBackground(){document.execCommand(this.commandBackground,!1,"#a8d6ff"),this.isFakeBackgroundEnabled=!0}save(){this.savedSelectionRange=m.range}restore(){if(!this.savedSelectionRange)return;const e=window.getSelection();e.removeAllRanges(),e.addRange(this.savedSelectionRange)}clearSaved(){this.savedSelectionRange=null}collapseToEnd(){const e=window.getSelection(),t=document.createRange();t.selectNodeContents(e.focusNode),t.collapse(!1),e.removeAllRanges(),e.addRange(t)}findParentTag(e,t,o=10){const i=window.getSelection();let n=null;return!i||!i.anchorNode||!i.focusNode?null:([i.anchorNode,i.focusNode].forEach(a=>{let l=o;for(;l>0&&a.parentNode&&!(a.tagName===e&&(n=a,t&&a.classList&&!a.classList.contains(t)&&(n=null),n));)a=a.parentNode,l--}),n)}expandToTag(e){const t=window.getSelection();t.removeAllRanges();const o=document.createRange();o.selectNodeContents(e),t.addRange(o)}}function Wt(s,e){const{type:t,target:o,addedNodes:i,removedNodes:n}=s;if(o===e)return!0;if(["characterData","attributes"].includes(t)){const l=o.nodeType===Node.TEXT_NODE?o.parentNode:o;return e.contains(l)}const r=Array.from(i).some(l=>e.contains(l)),a=Array.from(n).some(l=>e.contains(l));return r||a}const Le="redactor dom changed",nt="block changed",st="fake cursor is about to be toggled",rt="fake cursor have been set";function at(s,e){return s.mergeable&&s.name===e.name}function Yt(s,e){const t=e==null?void 0:e.export;return D(t)?t(s):Q(t)?s[t]:(t!==void 0&&L("Conversion «export» property must be a string or function. String means key of saved data object to export. Function should export processed string to export."),"")}function Kt(s,e){const t=e==null?void 0:e.import;return D(t)?t(s):Q(t)?{[t]:s}:(t!==void 0&&L("Conversion «import» property must be a string or function. String means key of tool data to import. Function accepts a imported string and return composed tool data."),{})}var q=(s=>(s.APPEND_CALLBACK="appendCallback",s.RENDERED="rendered",s.MOVED="moved",s.UPDATED="updated",s.REMOVED="removed",s.ON_PASTE="onPaste",s))(q||{});class F extends ve{constructor({id:e=jt(),data:t,tool:o,api:i,readOnly:n,tunesData:r},a){super(),this.cachedInputs=[],this.toolRenderedElement=null,this.tunesInstances=new Map,this.defaultTunesInstances=new Map,this.unavailableTunesData={},this.inputIndex=0,this.editorEventBus=null,this.handleFocus=()=>{this.dropInputsCache(),this.updateCurrentInput()},this.didMutated=(l=void 0)=>{const c=l===void 0,u=l instanceof InputEvent;!c&&!u&&this.detectToolRootChange(l);let h;c||u?h=!0:h=!(l.length>0&&l.every(k=>{const{addedNodes:p,removedNodes:v,target:A}=k;return[...Array.from(p),...Array.from(v),A].some(_=>d.isElement(_)?_.dataset.mutationFree==="true":!1)})),h&&(this.dropInputsCache(),this.updateCurrentInput(),this.call("updated"),this.emit("didMutated",this))},this.name=o.name,this.id=e,this.settings=o.settings,this.config=o.settings.config||{},this.api=i,this.editorEventBus=a||null,this.blockAPI=new oe(this),this.tool=o,this.toolInstance=o.create(t,this.blockAPI,n),this.tunes=o.tunes,this.composeTunes(r),this.holder=this.compose(),window.requestIdleCallback(()=>{this.watchBlockMutations(),this.addInputEvents()})}static get CSS(){return{wrapper:"ce-block",wrapperStretched:"ce-block--stretched",content:"ce-block__content",focused:"ce-block--focused",selected:"ce-block--selected",dropTarget:"ce-block--drop-target"}}get inputs(){if(this.cachedInputs.length!==0)return this.cachedInputs;const e=d.findAllInputs(this.holder);return this.inputIndex>e.length-1&&(this.inputIndex=e.length-1),this.cachedInputs=e,e}get currentInput(){return this.inputs[this.inputIndex]}set currentInput(e){const t=this.inputs.findIndex(o=>o===e||o.contains(e));t!==-1&&(this.inputIndex=t)}get firstInput(){return this.inputs[0]}get lastInput(){const e=this.inputs;return e[e.length-1]}get nextInput(){return this.inputs[this.inputIndex+1]}get previousInput(){return this.inputs[this.inputIndex-1]}get data(){return this.save().then(e=>e&&!X(e.data)?e.data:{})}get sanitize(){return this.tool.sanitizeConfig}get mergeable(){return D(this.toolInstance.merge)}get isEmpty(){const e=d.isEmpty(this.pluginsContent),t=!this.hasMedia;return e&&t}get hasMedia(){const e=["img","iframe","video","audio","source","input","textarea","twitterwidget"];return!!this.holder.querySelector(e.join(","))}set focused(e){this.holder.classList.toggle(F.CSS.focused,e)}get focused(){return this.holder.classList.contains(F.CSS.focused)}set selected(e){var i,n;this.holder.classList.toggle(F.CSS.selected,e);const t=e===!0&&m.isRangeInsideContainer(this.holder),o=e===!1&&m.isFakeCursorInsideContainer(this.holder);(t||o)&&((i=this.editorEventBus)==null||i.emit(st,{state:e}),t?m.addFakeCursor():m.removeFakeCursor(this.holder),(n=this.editorEventBus)==null||n.emit(rt,{state:e}))}get selected(){return this.holder.classList.contains(F.CSS.selected)}set stretched(e){this.holder.classList.toggle(F.CSS.wrapperStretched,e)}get stretched(){return this.holder.classList.contains(F.CSS.wrapperStretched)}set dropTarget(e){this.holder.classList.toggle(F.CSS.dropTarget,e)}get pluginsContent(){return this.toolRenderedElement}call(e,t){if(D(this.toolInstance[e])){e==="appendCallback"&&L("`appendCallback` hook is deprecated and will be removed in the next major release. Use `rendered` hook instead","warn");try{this.toolInstance[e].call(this.toolInstance,t)}catch(o){L(`Error during '${e}' call: ${o.message}`,"error")}}}async mergeWith(e){await this.toolInstance.merge(e)}async save(){const e=await this.toolInstance.save(this.pluginsContent),t=this.unavailableTunesData;[...this.tunesInstances.entries(),...this.defaultTunesInstances.entries()].forEach(([n,r])=>{if(D(r.save))try{t[n]=r.save()}catch(a){L(`Tune ${r.constructor.name} save method throws an Error %o`,"warn",a)}});const o=window.performance.now();let i;return Promise.resolve(e).then(n=>(i=window.performance.now(),{id:this.id,tool:this.name,data:n,tunes:t,time:i-o})).catch(n=>{L(`Saving process for ${this.name} tool failed due to the ${n}`,"log","red")})}async validate(e){let t=!0;return this.toolInstance.validate instanceof Function&&(t=await this.toolInstance.validate(e)),t}getTunes(){const e=document.createElement("div"),t=[],o=typeof this.toolInstance.renderSettings=="function"?this.toolInstance.renderSettings():[],i=[...this.tunesInstances.values(),...this.defaultTunesInstances.values()].map(n=>n.render());return[o,i].flat().forEach(n=>{d.isElement(n)?e.appendChild(n):Array.isArray(n)?t.push(...n):t.push(n)}),[t,e]}updateCurrentInput(){this.currentInput=d.isNativeInput(document.activeElement)||!m.anchorNode?document.activeElement:m.anchorNode}dispatchChange(){this.didMutated()}destroy(){this.unwatchBlockMutations(),this.removeInputEvents(),super.destroy(),D(this.toolInstance.destroy)&&this.toolInstance.destroy()}async getActiveToolboxEntry(){const e=this.tool.toolbox;if(e.length===1)return Promise.resolve(this.tool.toolbox[0]);const t=await this.data;return e.find(i=>Object.entries(i.data).some(([n,r])=>t[n]&&$t(t[n],r)))}async exportDataAsString(){const e=await this.data;return Yt(e,this.tool.conversionConfig)}compose(){const e=d.make("div",F.CSS.wrapper),t=d.make("div",F.CSS.content),o=this.toolInstance.render();e.dataset.id=this.id,this.toolRenderedElement=o,t.appendChild(this.toolRenderedElement);let i=t;return[...this.tunesInstances.values(),...this.defaultTunesInstances.values()].forEach(n=>{if(D(n.wrap))try{i=n.wrap(i)}catch(r){L(`Tune ${n.constructor.name} wrap method throws an Error %o`,"warn",r)}}),e.appendChild(i),e}composeTunes(e){Array.from(this.tunes.values()).forEach(t=>{(t.isInternal?this.defaultTunesInstances:this.tunesInstances).set(t.name,t.create(e[t.name],this.blockAPI))}),Object.entries(e).forEach(([t,o])=>{this.tunesInstances.has(t)||(this.unavailableTunesData[t]=o)})}addInputEvents(){this.inputs.forEach(e=>{e.addEventListener("focus",this.handleFocus),d.isNativeInput(e)&&e.addEventListener("input",this.didMutated)})}removeInputEvents(){this.inputs.forEach(e=>{e.removeEventListener("focus",this.handleFocus),d.isNativeInput(e)&&e.removeEventListener("input",this.didMutated)})}watchBlockMutations(){var e;this.redactorDomChangedCallback=t=>{const{mutations:o}=t;o.some(n=>Wt(n,this.toolRenderedElement))&&this.didMutated(o)},(e=this.editorEventBus)==null||e.on(Le,this.redactorDomChangedCallback)}unwatchBlockMutations(){var e;(e=this.editorEventBus)==null||e.off(Le,this.redactorDomChangedCallback)}detectToolRootChange(e){e.forEach(t=>{if(Array.from(t.removedNodes).includes(this.toolRenderedElement)){const i=t.addedNodes[t.addedNodes.length-1];this.toolRenderedElement=i}})}dropInputsCache(){this.cachedInputs=[]}}class Xt extends C{constructor(){super(...arguments),this.insert=(e=this.config.defaultBlock,t={},o={},i,n,r,a)=>{const l=this.Editor.BlockManager.insert({id:a,tool:e,data:t,index:i,needToFocus:n,replace:r});return new oe(l)},this.composeBlockData=async e=>{const t=this.Editor.Tools.blockTools.get(e);return new F({tool:t,api:this.Editor.API,readOnly:!0,data:{},tunesData:{}}).data},this.update=async(e,t)=>{const{BlockManager:o}=this.Editor,i=o.getBlockById(e);if(i===void 0)throw new Error(`Block with id "${e}" not found`);const n=await o.update(i,t);return new oe(n)},this.convert=(e,t,o)=>{var h,f;const{BlockManager:i,Tools:n}=this.Editor,r=i.getBlockById(e);if(!r)throw new Error(`Block with id "${e}" not found`);const a=n.blockTools.get(r.name),l=n.blockTools.get(t);if(!l)throw new Error(`Block Tool with type "${t}" not found`);const c=((h=a==null?void 0:a.conversionConfig)==null?void 0:h.export)!==void 0,u=((f=l.conversionConfig)==null?void 0:f.import)!==void 0;if(c&&u)i.convert(r,t,o);else{const k=[c?!1:se(r.name),u?!1:se(t)].filter(Boolean).join(" and ");throw new Error(`Conversion from "${r.name}" to "${t}" is not possible. ${k} tool(s) should provide a "conversionConfig"`)}},this.insertMany=(e,t=this.Editor.BlockManager.blocks.length-1)=>{this.validateIndex(t);const o=e.map(({id:i,type:n,data:r})=>this.Editor.BlockManager.composeBlock({id:i,tool:n||this.config.defaultBlock,data:r}));return this.Editor.BlockManager.insertMany(o,t),o.map(i=>new oe(i))}}get methods(){return{clear:()=>this.clear(),render:e=>this.render(e),renderFromHTML:e=>this.renderFromHTML(e),delete:e=>this.delete(e),swap:(e,t)=>this.swap(e,t),move:(e,t)=>this.move(e,t),getBlockByIndex:e=>this.getBlockByIndex(e),getById:e=>this.getById(e),getCurrentBlockIndex:()=>this.getCurrentBlockIndex(),getBlockIndex:e=>this.getBlockIndex(e),getBlocksCount:()=>this.getBlocksCount(),stretchBlock:(e,t=!0)=>this.stretchBlock(e,t),insertNewBlock:()=>this.insertNewBlock(),insert:this.insert,insertMany:this.insertMany,update:this.update,composeBlockData:this.composeBlockData,convert:this.convert}}getBlocksCount(){return this.Editor.BlockManager.blocks.length}getCurrentBlockIndex(){return this.Editor.BlockManager.currentBlockIndex}getBlockIndex(e){const t=this.Editor.BlockManager.getBlockById(e);if(!t){K("There is no block with id `"+e+"`","warn");return}return this.Editor.BlockManager.getBlockIndex(t)}getBlockByIndex(e){const t=this.Editor.BlockManager.getBlockByIndex(e);if(t===void 0){K("There is no block at index `"+e+"`","warn");return}return new oe(t)}getById(e){const t=this.Editor.BlockManager.getBlockById(e);return t===void 0?(K("There is no block with id `"+e+"`","warn"),null):new oe(t)}swap(e,t){L("`blocks.swap()` method is deprecated and will be removed in the next major release. Use `block.move()` method instead","info"),this.Editor.BlockManager.swap(e,t)}move(e,t){this.Editor.BlockManager.move(e,t)}delete(e=this.Editor.BlockManager.currentBlockIndex){try{const t=this.Editor.BlockManager.getBlockByIndex(e);this.Editor.BlockManager.removeBlock(t)}catch(t){K(t,"warn");return}this.Editor.BlockManager.blocks.length===0&&this.Editor.BlockManager.insert(),this.Editor.BlockManager.currentBlock&&this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock,this.Editor.Caret.positions.END),this.Editor.Toolbar.close()}async clear(){await this.Editor.BlockManager.clear(!0),this.Editor.InlineToolbar.close()}async render(e){if(e===void 0||e.blocks===void 0)throw new Error("Incorrect data passed to the render() method");this.Editor.ModificationsObserver.disable(),await this.Editor.BlockManager.clear(),await this.Editor.Renderer.render(e.blocks),this.Editor.ModificationsObserver.enable()}renderFromHTML(e){return this.Editor.BlockManager.clear(),this.Editor.Paste.processText(e,!0)}stretchBlock(e,t=!0){Ie(!0,"blocks.stretchBlock()","BlockAPI");const o=this.Editor.BlockManager.getBlockByIndex(e);o&&(o.stretched=t)}insertNewBlock(){L("Method blocks.insertNewBlock() is deprecated and it will be removed in the next major release. Use blocks.insert() instead.","warn"),this.insert()}validateIndex(e){if(typeof e!="number")throw new Error("Index should be a number");if(e<0)throw new Error("Index should be greater than or equal to 0");if(e===null)throw new Error("Index should be greater than or equal to 0")}}class Vt extends C{constructor(){super(...arguments),this.setToFirstBlock=(e=this.Editor.Caret.positions.DEFAULT,t=0)=>this.Editor.BlockManager.firstBlock?(this.Editor.Caret.setToBlock(this.Editor.BlockManager.firstBlock,e,t),!0):!1,this.setToLastBlock=(e=this.Editor.Caret.positions.DEFAULT,t=0)=>this.Editor.BlockManager.lastBlock?(this.Editor.Caret.setToBlock(this.Editor.BlockManager.lastBlock,e,t),!0):!1,this.setToPreviousBlock=(e=this.Editor.Caret.positions.DEFAULT,t=0)=>this.Editor.BlockManager.previousBlock?(this.Editor.Caret.setToBlock(this.Editor.BlockManager.previousBlock,e,t),!0):!1,this.setToNextBlock=(e=this.Editor.Caret.positions.DEFAULT,t=0)=>this.Editor.BlockManager.nextBlock?(this.Editor.Caret.setToBlock(this.Editor.BlockManager.nextBlock,e,t),!0):!1,this.setToBlock=(e,t=this.Editor.Caret.positions.DEFAULT,o=0)=>this.Editor.BlockManager.blocks[e]?(this.Editor.Caret.setToBlock(this.Editor.BlockManager.blocks[e],t,o),!0):!1,this.focus=(e=!1)=>e?this.setToLastBlock(this.Editor.Caret.positions.END):this.setToFirstBlock(this.Editor.Caret.positions.START)}get methods(){return{setToFirstBlock:this.setToFirstBlock,setToLastBlock:this.setToLastBlock,setToPreviousBlock:this.setToPreviousBlock,setToNextBlock:this.setToNextBlock,setToBlock:this.setToBlock,focus:this.focus}}}class qt extends C{get methods(){return{emit:(e,t)=>this.emit(e,t),off:(e,t)=>this.off(e,t),on:(e,t)=>this.on(e,t)}}on(e,t){this.eventsDispatcher.on(e,t)}emit(e,t){this.eventsDispatcher.emit(e,t)}off(e,t){this.eventsDispatcher.off(e,t)}}class Oe extends C{static getNamespace(e){return e.isTune()?`blockTunes.${e.name}`:`tools.${e.name}`}get methods(){return{t:()=>{K("I18n.t() method can be accessed only from Tools","warn")}}}getMethodsForTool(e){return Object.assign(this.methods,{t:t=>$.t(Oe.getNamespace(e),t)})}}class Zt extends C{get methods(){return{blocks:this.Editor.BlocksAPI.methods,caret:this.Editor.CaretAPI.methods,events:this.Editor.EventsAPI.methods,listeners:this.Editor.ListenersAPI.methods,notifier:this.Editor.NotifierAPI.methods,sanitizer:this.Editor.SanitizerAPI.methods,saver:this.Editor.SaverAPI.methods,selection:this.Editor.SelectionAPI.methods,styles:this.Editor.StylesAPI.classes,toolbar:this.Editor.ToolbarAPI.methods,inlineToolbar:this.Editor.InlineToolbarAPI.methods,tooltip:this.Editor.TooltipAPI.methods,i18n:this.Editor.I18nAPI.methods,readOnly:this.Editor.ReadOnlyAPI.methods,ui:this.Editor.UiAPI.methods}}getMethodsForTool(e){return Object.assign(this.methods,{i18n:this.Editor.I18nAPI.getMethodsForTool(e)})}}class Gt extends C{get methods(){return{close:()=>this.close(),open:()=>this.open()}}open(){this.Editor.InlineToolbar.tryToShow()}close(){this.Editor.InlineToolbar.close()}}class Jt extends C{get methods(){return{on:(e,t,o,i)=>this.on(e,t,o,i),off:(e,t,o,i)=>this.off(e,t,o,i),offById:e=>this.offById(e)}}on(e,t,o,i){return this.listeners.on(e,t,o,i)}off(e,t,o,i){this.listeners.off(e,t,o,i)}offById(e){this.listeners.offById(e)}}var Ae={},Qt={get exports(){return Ae},set exports(s){Ae=s}};(function(s,e){(function(t,o){s.exports=o()})(window,function(){return function(t){var o={};function i(n){if(o[n])return o[n].exports;var r=o[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=o,i.d=function(n,r,a){i.o(n,r)||Object.defineProperty(n,r,{enumerable:!0,get:a})},i.r=function(n){typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},i.t=function(n,r){if(1&r&&(n=i(n)),8&r||4&r&&typeof n=="object"&&n&&n.__esModule)return n;var a=Object.create(null);if(i.r(a),Object.defineProperty(a,"default",{enumerable:!0,value:n}),2&r&&typeof n!="string")for(var l in n)i.d(a,l,function(c){return n[c]}.bind(null,l));return a},i.n=function(n){var r=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(r,"a",r),r},i.o=function(n,r){return Object.prototype.hasOwnProperty.call(n,r)},i.p="/",i(i.s=0)}([function(t,o,i){i(1),t.exports=function(){var n=i(6),r="cdx-notify--bounce-in",a=null;return{show:function(l){if(l.message){(function(){if(a)return!0;a=n.getWrapper(),document.body.appendChild(a)})();var c=null,u=l.time||8e3;switch(l.type){case"confirm":c=n.confirm(l);break;case"prompt":c=n.prompt(l);break;default:c=n.alert(l),window.setTimeout(function(){c.remove()},u)}a.appendChild(c),c.classList.add(r)}}}}()},function(t,o,i){var n=i(2);typeof n=="string"&&(n=[[t.i,n,""]]);var r={hmr:!0,transform:void 0,insertInto:void 0};i(4)(n,r),n.locals&&(t.exports=n.locals)},function(t,o,i){(t.exports=i(3)(!1)).push([t.i,`.cdx-notify--error{background:#fffbfb!important}.cdx-notify--error::before{background:#fb5d5d!important}.cdx-notify__input{max-width:130px;padding:5px 10px;background:#f7f7f7;border:0;border-radius:3px;font-size:13px;color:#656b7c;outline:0}.cdx-notify__input:-ms-input-placeholder{color:#656b7c}.cdx-notify__input::placeholder{color:#656b7c}.cdx-notify__input:focus:-ms-input-placeholder{color:rgba(101,107,124,.3)}.cdx-notify__input:focus::placeholder{color:rgba(101,107,124,.3)}.cdx-notify__button{border:none;border-radius:3px;font-size:13px;padding:5px 10px;cursor:pointer}.cdx-notify__button:last-child{margin-left:10px}.cdx-notify__button--cancel{background:#f2f5f7;box-shadow:0 2px 1px 0 rgba(16,19,29,0);color:#656b7c}.cdx-notify__button--cancel:hover{background:#eee}.cdx-notify__button--confirm{background:#34c992;box-shadow:0 1px 1px 0 rgba(18,49,35,.05);color:#fff}.cdx-notify__button--confirm:hover{background:#33b082}.cdx-notify__btns-wrapper{display:-ms-flexbox;display:flex;-ms-flex-flow:row nowrap;flex-flow:row nowrap;margin-top:5px}.cdx-notify__cross{position:absolute;top:5px;right:5px;width:10px;height:10px;padding:5px;opacity:.54;cursor:pointer}.cdx-notify__cross::after,.cdx-notify__cross::before{content:'';position:absolute;left:9px;top:5px;height:12px;width:2px;background:#575d67}.cdx-notify__cross::before{transform:rotate(-45deg)}.cdx-notify__cross::after{transform:rotate(45deg)}.cdx-notify__cross:hover{opacity:1}.cdx-notifies{position:fixed;z-index:2;bottom:20px;left:20px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",sans-serif}.cdx-notify{position:relative;width:220px;margin-top:15px;padding:13px 16px;background:#fff;box-shadow:0 11px 17px 0 rgba(23,32,61,.13);border-radius:5px;font-size:14px;line-height:1.4em;word-wrap:break-word}.cdx-notify::before{content:'';position:absolute;display:block;top:0;left:0;width:3px;height:calc(100% - 6px);margin:3px;border-radius:5px;background:0 0}@keyframes bounceIn{0%{opacity:0;transform:scale(.3)}50%{opacity:1;transform:scale(1.05)}70%{transform:scale(.9)}100%{transform:scale(1)}}.cdx-notify--bounce-in{animation-name:bounceIn;animation-duration:.6s;animation-iteration-count:1}.cdx-notify--success{background:#fafffe!important}.cdx-notify--success::before{background:#41ffb1!important}`,""])},function(t,o){t.exports=function(i){var n=[];return n.toString=function(){return this.map(function(r){var a=function(l,c){var u=l[1]||"",h=l[3];if(!h)return u;if(c&&typeof btoa=="function"){var f=(p=h,"/*# sourceMappingURL=data:application/json;charset=utf-8;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(p))))+" */"),k=h.sources.map(function(v){return"/*# sourceURL="+h.sourceRoot+v+" */"});return[u].concat(k).concat([f]).join(` +`)}var p;return[u].join(` +`)}(r,i);return r[2]?"@media "+r[2]+"{"+a+"}":a}).join("")},n.i=function(r,a){typeof r=="string"&&(r=[[null,r,""]]);for(var l={},c=0;c=0&&f.splice(g,1)}function _(b){var g=document.createElement("style");return b.attrs.type===void 0&&(b.attrs.type="text/css"),y(g,b.attrs),A(b,g),g}function y(b,g){Object.keys(g).forEach(function(E){b.setAttribute(E,g[E])})}function x(b,g){var E,T,O,S;if(g.transform&&b.css){if(!(S=g.transform(b.css)))return function(){};b.css=S}if(g.singleton){var z=h++;E=u||(u=_(g)),T=R.bind(null,E,z,!1),O=R.bind(null,E,z,!0)}else b.sourceMap&&typeof URL=="function"&&typeof URL.createObjectURL=="function"&&typeof URL.revokeObjectURL=="function"&&typeof Blob=="function"&&typeof btoa=="function"?(E=function(M){var Y=document.createElement("link");return M.attrs.type===void 0&&(M.attrs.type="text/css"),M.attrs.rel="stylesheet",y(Y,M.attrs),A(M,Y),Y}(g),T=function(M,Y,ke){var ie=ke.css,Ve=ke.sourceMap,fi=Y.convertToAbsoluteUrls===void 0&&Ve;(Y.convertToAbsoluteUrls||fi)&&(ie=k(ie)),Ve&&(ie+=` +/*# sourceMappingURL=data:application/json;base64,`+btoa(unescape(encodeURIComponent(JSON.stringify(Ve))))+" */");var gi=new Blob([ie],{type:"text/css"}),Mt=M.href;M.href=URL.createObjectURL(gi),Mt&&URL.revokeObjectURL(Mt)}.bind(null,E,g),O=function(){N(E),E.href&&URL.revokeObjectURL(E.href)}):(E=_(g),T=function(M,Y){var ke=Y.css,ie=Y.media;if(ie&&M.setAttribute("media",ie),M.styleSheet)M.styleSheet.cssText=ke;else{for(;M.firstChild;)M.removeChild(M.firstChild);M.appendChild(document.createTextNode(ke))}}.bind(null,E),O=function(){N(E)});return T(b),function(M){if(M){if(M.css===b.css&&M.media===b.media&&M.sourceMap===b.sourceMap)return;T(b=M)}else O()}}t.exports=function(b,g){if(typeof DEBUG<"u"&&DEBUG&&typeof document!="object")throw new Error("The style-loader cannot be used in a non-browser environment");(g=g||{}).attrs=typeof g.attrs=="object"?g.attrs:{},g.singleton||typeof g.singleton=="boolean"||(g.singleton=l()),g.insertInto||(g.insertInto="head"),g.insertAt||(g.insertAt="bottom");var E=v(b,g);return p(E,g),function(T){for(var O=[],S=0;Sthis.show(e)}}show(e){return this.notifier.show(e)}}class io extends C{get methods(){const e=()=>this.isEnabled;return{toggle:t=>this.toggle(t),get isEnabled(){return e()}}}toggle(e){return this.Editor.ReadOnly.toggle(e)}get isEnabled(){return this.Editor.ReadOnly.isEnabled}}var _e={},no={get exports(){return _e},set exports(s){_e=s}};(function(s,e){(function(t,o){s.exports=o()})(he,function(){function t(h){var f=h.tags,k=Object.keys(f),p=k.map(function(v){return typeof f[v]}).every(function(v){return v==="object"||v==="boolean"||v==="function"});if(!p)throw new Error("The configuration was invalid");this.config=h}var o=["P","LI","TD","TH","DIV","H1","H2","H3","H4","H5","H6","PRE"];function i(h){return o.indexOf(h.nodeName)!==-1}var n=["A","B","STRONG","I","EM","SUB","SUP","U","STRIKE"];function r(h){return n.indexOf(h.nodeName)!==-1}t.prototype.clean=function(h){const f=document.implementation.createHTMLDocument(),k=f.createElement("div");return k.innerHTML=h,this._sanitize(f,k),k.innerHTML},t.prototype._sanitize=function(h,f){var k=a(h,f),p=k.firstChild();if(p)do{if(p.nodeType===Node.TEXT_NODE)if(p.data.trim()===""&&(p.previousElementSibling&&i(p.previousElementSibling)||p.nextElementSibling&&i(p.nextElementSibling))){f.removeChild(p),this._sanitize(h,f);break}else continue;if(p.nodeType===Node.COMMENT_NODE){f.removeChild(p),this._sanitize(h,f);break}var v=r(p),A;v&&(A=Array.prototype.some.call(p.childNodes,i));var N=!!f.parentNode,_=i(f)&&i(p)&&N,y=p.nodeName.toLowerCase(),x=l(this.config,y,p),w=v&&A;if(w||c(p,x)||!this.config.keepNestedBlockElements&&_){if(!(p.nodeName==="SCRIPT"||p.nodeName==="STYLE"))for(;p.childNodes.length>0;)f.insertBefore(p.childNodes[0],p);f.removeChild(p),this._sanitize(h,f);break}for(var I=0;I"u"?!0:typeof f=="boolean"?!f:!1}function u(h,f,k){var p=h.name.toLowerCase();return f===!0?!1:typeof f[p]=="function"?!f[p](h.value,k):typeof f[p]>"u"||f[p]===!1?!0:typeof f[p]=="string"?f[p]!==h.value:!1}return t})})(no);const so=_e;function lt(s,e){return s.map(t=>{const o=D(e)?e(t.tool):e;return X(o)||(t.data=Ne(t.data,o)),t})}function Z(s,e={}){const t={tags:e};return new so(t).clean(s)}function Ne(s,e){return Array.isArray(s)?ro(s,e):H(s)?ao(s,e):Q(s)?lo(s,e):s}function ro(s,e){return s.map(t=>Ne(t,e))}function ao(s,e){const t={};for(const o in s){if(!Object.prototype.hasOwnProperty.call(s,o))continue;const i=s[o],n=co(e[o])?e[o]:e;t[o]=Ne(i,n)}return t}function lo(s,e){return H(e)?Z(s,e):e===!1?Z(s,{}):s}function co(s){return H(s)||_t(s)||D(s)}class ho extends C{get methods(){return{clean:(e,t)=>this.clean(e,t)}}clean(e,t){return Z(e,t)}}class uo extends C{get methods(){return{save:()=>this.save()}}save(){const e="Editor's content can not be saved in read-only mode";return this.Editor.ReadOnly.isEnabled?(K(e,"warn"),Promise.reject(new Error(e))):this.Editor.Saver.save()}}class po extends C{get methods(){return{findParentTag:(e,t)=>this.findParentTag(e,t),expandToTag:e=>this.expandToTag(e)}}findParentTag(e,t){return new m().findParentTag(e,t)}expandToTag(e){new m().expandToTag(e)}}class fo extends C{get classes(){return{block:"cdx-block",inlineToolButton:"ce-inline-tool",inlineToolButtonActive:"ce-inline-tool--active",input:"cdx-input",loader:"cdx-loader",button:"cdx-button",settingsButton:"cdx-settings-button",settingsButtonActive:"cdx-settings-button--active"}}}class go extends C{get methods(){return{close:()=>this.close(),open:()=>this.open(),toggleBlockSettings:e=>this.toggleBlockSettings(e),toggleToolbox:e=>this.toggleToolbox(e)}}open(){this.Editor.Toolbar.moveAndOpen()}close(){this.Editor.Toolbar.close()}toggleBlockSettings(e){if(this.Editor.BlockManager.currentBlockIndex===-1){K("Could't toggle the Toolbar because there is no block selected ","warn");return}e??!this.Editor.BlockSettings.opened?(this.Editor.Toolbar.moveAndOpen(),this.Editor.BlockSettings.open()):this.Editor.BlockSettings.close()}toggleToolbox(e){if(this.Editor.BlockManager.currentBlockIndex===-1){K("Could't toggle the Toolbox because there is no block selected ","warn");return}e??!this.Editor.Toolbar.toolbox.opened?(this.Editor.Toolbar.moveAndOpen(),this.Editor.Toolbar.toolbox.open()):this.Editor.Toolbar.toolbox.close()}}var Re={},bo={get exports(){return Re},set exports(s){Re=s}};/*! + * CodeX.Tooltips + * + * @version 1.0.5 + * + * @licence MIT + * @author CodeX + * + * + */(function(s,e){(function(t,o){s.exports=o()})(window,function(){return function(t){var o={};function i(n){if(o[n])return o[n].exports;var r=o[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=o,i.d=function(n,r,a){i.o(n,r)||Object.defineProperty(n,r,{enumerable:!0,get:a})},i.r=function(n){typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},i.t=function(n,r){if(1&r&&(n=i(n)),8&r||4&r&&typeof n=="object"&&n&&n.__esModule)return n;var a=Object.create(null);if(i.r(a),Object.defineProperty(a,"default",{enumerable:!0,value:n}),2&r&&typeof n!="string")for(var l in n)i.d(a,l,function(c){return n[c]}.bind(null,l));return a},i.n=function(n){var r=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(r,"a",r),r},i.o=function(n,r){return Object.prototype.hasOwnProperty.call(n,r)},i.p="",i(i.s=0)}([function(t,o,i){t.exports=i(1)},function(t,o,i){i.r(o),i.d(o,"default",function(){return n});class n{constructor(){this.nodes={wrapper:null,content:null},this.showed=!1,this.offsetTop=10,this.offsetLeft=10,this.offsetRight=10,this.hidingDelay=0,this.handleWindowScroll=()=>{this.showed&&this.hide(!0)},this.loadStyles(),this.prepare(),window.addEventListener("scroll",this.handleWindowScroll,{passive:!0})}get CSS(){return{tooltip:"ct",tooltipContent:"ct__content",tooltipShown:"ct--shown",placement:{left:"ct--left",bottom:"ct--bottom",right:"ct--right",top:"ct--top"}}}show(a,l,c){this.nodes.wrapper||this.prepare(),this.hidingTimeout&&clearTimeout(this.hidingTimeout);const u=Object.assign({placement:"bottom",marginTop:0,marginLeft:0,marginRight:0,marginBottom:0,delay:70,hidingDelay:0},c);if(u.hidingDelay&&(this.hidingDelay=u.hidingDelay),this.nodes.content.innerHTML="",typeof l=="string")this.nodes.content.appendChild(document.createTextNode(l));else{if(!(l instanceof Node))throw Error("[CodeX Tooltip] Wrong type of «content» passed. It should be an instance of Node or String. But "+typeof l+" given.");this.nodes.content.appendChild(l)}switch(this.nodes.wrapper.classList.remove(...Object.values(this.CSS.placement)),u.placement){case"top":this.placeTop(a,u);break;case"left":this.placeLeft(a,u);break;case"right":this.placeRight(a,u);break;case"bottom":default:this.placeBottom(a,u)}u&&u.delay?this.showingTimeout=setTimeout(()=>{this.nodes.wrapper.classList.add(this.CSS.tooltipShown),this.showed=!0},u.delay):(this.nodes.wrapper.classList.add(this.CSS.tooltipShown),this.showed=!0)}hide(a=!1){if(this.hidingDelay&&!a)return this.hidingTimeout&&clearTimeout(this.hidingTimeout),void(this.hidingTimeout=setTimeout(()=>{this.hide(!0)},this.hidingDelay));this.nodes.wrapper.classList.remove(this.CSS.tooltipShown),this.showed=!1,this.showingTimeout&&clearTimeout(this.showingTimeout)}onHover(a,l,c){a.addEventListener("mouseenter",()=>{this.show(a,l,c)}),a.addEventListener("mouseleave",()=>{this.hide()})}destroy(){this.nodes.wrapper.remove(),window.removeEventListener("scroll",this.handleWindowScroll)}prepare(){this.nodes.wrapper=this.make("div",this.CSS.tooltip),this.nodes.content=this.make("div",this.CSS.tooltipContent),this.append(this.nodes.wrapper,this.nodes.content),this.append(document.body,this.nodes.wrapper)}loadStyles(){const a="codex-tooltips-style";if(document.getElementById(a))return;const l=i(2),c=this.make("style",null,{textContent:l.toString(),id:a});this.prepend(document.head,c)}placeBottom(a,l){const c=a.getBoundingClientRect(),u=c.left+a.clientWidth/2-this.nodes.wrapper.offsetWidth/2,h=c.bottom+window.pageYOffset+this.offsetTop+l.marginTop;this.applyPlacement("bottom",u,h)}placeTop(a,l){const c=a.getBoundingClientRect(),u=c.left+a.clientWidth/2-this.nodes.wrapper.offsetWidth/2,h=c.top+window.pageYOffset-this.nodes.wrapper.clientHeight-this.offsetTop;this.applyPlacement("top",u,h)}placeLeft(a,l){const c=a.getBoundingClientRect(),u=c.left-this.nodes.wrapper.offsetWidth-this.offsetLeft-l.marginLeft,h=c.top+window.pageYOffset+a.clientHeight/2-this.nodes.wrapper.offsetHeight/2;this.applyPlacement("left",u,h)}placeRight(a,l){const c=a.getBoundingClientRect(),u=c.right+this.offsetRight+l.marginRight,h=c.top+window.pageYOffset+a.clientHeight/2-this.nodes.wrapper.offsetHeight/2;this.applyPlacement("right",u,h)}applyPlacement(a,l,c){this.nodes.wrapper.classList.add(this.CSS.placement[a]),this.nodes.wrapper.style.left=l+"px",this.nodes.wrapper.style.top=c+"px"}make(a,l=null,c={}){const u=document.createElement(a);Array.isArray(l)?u.classList.add(...l):l&&u.classList.add(l);for(const h in c)c.hasOwnProperty(h)&&(u[h]=c[h]);return u}append(a,l){Array.isArray(l)?l.forEach(c=>a.appendChild(c)):a.appendChild(l)}prepend(a,l){Array.isArray(l)?(l=l.reverse()).forEach(c=>a.prepend(c)):a.prepend(l)}}},function(t,o){t.exports=`.ct{z-index:999;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;-webkit-transition:opacity 50ms ease-in,-webkit-transform 70ms cubic-bezier(.215,.61,.355,1);transition:opacity 50ms ease-in,-webkit-transform 70ms cubic-bezier(.215,.61,.355,1);transition:opacity 50ms ease-in,transform 70ms cubic-bezier(.215,.61,.355,1);transition:opacity 50ms ease-in,transform 70ms cubic-bezier(.215,.61,.355,1),-webkit-transform 70ms cubic-bezier(.215,.61,.355,1);will-change:opacity,top,left;-webkit-box-shadow:0 8px 12px 0 rgba(29,32,43,.17),0 4px 5px -3px rgba(5,6,12,.49);box-shadow:0 8px 12px 0 rgba(29,32,43,.17),0 4px 5px -3px rgba(5,6,12,.49);border-radius:9px}.ct,.ct:before{position:absolute;top:0;left:0}.ct:before{content:"";bottom:0;right:0;background-color:#1d202b;z-index:-1;border-radius:4px}@supports(-webkit-mask-box-image:url("")){.ct:before{border-radius:0;-webkit-mask-box-image:url('data:image/svg+xml;charset=utf-8,') 48% 41% 37.9% 53.3%}}@media (--mobile){.ct{display:none}}.ct__content{padding:6px 10px;color:#cdd1e0;font-size:12px;text-align:center;letter-spacing:.02em;line-height:1em}.ct:after{content:"";width:8px;height:8px;position:absolute;background-color:#1d202b;z-index:-1}.ct--bottom{-webkit-transform:translateY(5px);transform:translateY(5px)}.ct--bottom:after{top:-3px;left:50%;-webkit-transform:translateX(-50%) rotate(-45deg);transform:translateX(-50%) rotate(-45deg)}.ct--top{-webkit-transform:translateY(-5px);transform:translateY(-5px)}.ct--top:after{top:auto;bottom:-3px;left:50%;-webkit-transform:translateX(-50%) rotate(-45deg);transform:translateX(-50%) rotate(-45deg)}.ct--left{-webkit-transform:translateX(-5px);transform:translateX(-5px)}.ct--left:after{top:50%;left:auto;right:0;-webkit-transform:translate(41.6%,-50%) rotate(-45deg);transform:translate(41.6%,-50%) rotate(-45deg)}.ct--right{-webkit-transform:translateX(5px);transform:translateX(5px)}.ct--right:after{top:50%;left:0;-webkit-transform:translate(-41.6%,-50%) rotate(-45deg);transform:translate(-41.6%,-50%) rotate(-45deg)}.ct--shown{opacity:1;-webkit-transform:none;transform:none}`}]).default})})(bo);const mo=J(Re);class De{constructor(){this.lib=new mo}destroy(){this.lib.destroy()}show(e,t,o){this.lib.show(e,t,o)}hide(e=!1){this.lib.hide(e)}onHover(e,t,o){this.lib.onHover(e,t,o)}}class ko extends C{constructor({config:e,eventsDispatcher:t}){super({config:e,eventsDispatcher:t}),this.tooltip=new De}destroy(){this.tooltip.destroy()}get methods(){return{show:(e,t,o)=>this.show(e,t,o),hide:()=>this.hide(),onHover:(e,t,o)=>this.onHover(e,t,o)}}show(e,t,o){this.tooltip.show(e,t,o)}hide(){this.tooltip.hide()}onHover(e,t,o){this.tooltip.onHover(e,t,o)}}class vo extends C{get methods(){return{nodes:this.editorNodes}}get editorNodes(){return{wrapper:this.Editor.UI.nodes.wrapper,redactor:this.Editor.UI.nodes.redactor}}}function ct(s,e){const t={};return Object.entries(s).forEach(([o,i])=>{if(H(i)){const n=e?`${e}.${o}`:o;Object.values(i).every(a=>Q(a))?t[o]=n:t[o]=ct(i,n);return}t[o]=i}),t}const V=ct(ot);function xo(s,e){const t={};return Object.keys(s).forEach(o=>{const i=e[o];i!==void 0?t[i]=s[o]:t[o]=s[o]}),t}const wo='',dt='',yo='',Eo='',Bo='',To='',ht='',Co='',So='',Io='',Mo='',Lo='';class P{constructor(e){this.nodes={root:null,icon:null},this.confirmationState=null,this.removeSpecialFocusBehavior=()=>{this.nodes.root.classList.remove(P.CSS.noFocus)},this.removeSpecialHoverBehavior=()=>{this.nodes.root.classList.remove(P.CSS.noHover)},this.onErrorAnimationEnd=()=>{this.nodes.icon.classList.remove(P.CSS.wobbleAnimation),this.nodes.icon.removeEventListener("animationend",this.onErrorAnimationEnd)},this.params=e,this.nodes.root=this.make(e)}get isDisabled(){return this.params.isDisabled}get toggle(){return this.params.toggle}get title(){return this.params.title}get closeOnActivate(){return this.params.closeOnActivate}get isConfirmationStateEnabled(){return this.confirmationState!==null}get isFocused(){return this.nodes.root.classList.contains(P.CSS.focused)}static get CSS(){return{container:"ce-popover-item",title:"ce-popover-item__title",secondaryTitle:"ce-popover-item__secondary-title",icon:"ce-popover-item__icon",active:"ce-popover-item--active",disabled:"ce-popover-item--disabled",focused:"ce-popover-item--focused",hidden:"ce-popover-item--hidden",confirmationState:"ce-popover-item--confirmation",noHover:"ce-popover-item--no-hover",noFocus:"ce-popover-item--no-focus",wobbleAnimation:"wobble"}}getElement(){return this.nodes.root}handleClick(){if(this.isConfirmationStateEnabled){this.activateOrEnableConfirmationMode(this.confirmationState);return}this.activateOrEnableConfirmationMode(this.params)}toggleActive(e){this.nodes.root.classList.toggle(P.CSS.active,e)}toggleHidden(e){this.nodes.root.classList.toggle(P.CSS.hidden,e)}reset(){this.isConfirmationStateEnabled&&this.disableConfirmationMode()}onFocus(){this.disableSpecialHoverAndFocusBehavior()}make(e){const t=d.make("div",P.CSS.container);return e.name&&(t.dataset.itemName=e.name),this.nodes.icon=d.make("div",P.CSS.icon,{innerHTML:e.icon||Bo}),t.appendChild(this.nodes.icon),t.appendChild(d.make("div",P.CSS.title,{innerHTML:e.title||""})),e.secondaryLabel&&t.appendChild(d.make("div",P.CSS.secondaryTitle,{textContent:e.secondaryLabel})),e.isActive&&t.classList.add(P.CSS.active),e.isDisabled&&t.classList.add(P.CSS.disabled),t}enableConfirmationMode(e){const t={...this.params,...e,confirmation:e.confirmation},o=this.make(t);this.nodes.root.innerHTML=o.innerHTML,this.nodes.root.classList.add(P.CSS.confirmationState),this.confirmationState=e,this.enableSpecialHoverAndFocusBehavior()}disableConfirmationMode(){const e=this.make(this.params);this.nodes.root.innerHTML=e.innerHTML,this.nodes.root.classList.remove(P.CSS.confirmationState),this.confirmationState=null,this.disableSpecialHoverAndFocusBehavior()}enableSpecialHoverAndFocusBehavior(){this.nodes.root.classList.add(P.CSS.noHover),this.nodes.root.classList.add(P.CSS.noFocus),this.nodes.root.addEventListener("mouseleave",this.removeSpecialHoverBehavior,{once:!0})}disableSpecialHoverAndFocusBehavior(){this.removeSpecialFocusBehavior(),this.removeSpecialHoverBehavior(),this.nodes.root.removeEventListener("mouseleave",this.removeSpecialHoverBehavior)}activateOrEnableConfirmationMode(e){if(e.confirmation===void 0)try{e.onActivate(e),this.disableConfirmationMode()}catch{this.animateError()}else this.enableConfirmationMode(e.confirmation)}animateError(){this.nodes.icon.classList.contains(P.CSS.wobbleAnimation)||(this.nodes.icon.classList.add(P.CSS.wobbleAnimation),this.nodes.icon.addEventListener("animationend",this.onErrorAnimationEnd))}}const pe=class{constructor(s,e){this.cursor=-1,this.items=[],this.items=s||[],this.focusedCssClass=e}get currentItem(){return this.cursor===-1?null:this.items[this.cursor]}setCursor(s){s=-1&&(this.dropCursor(),this.cursor=s,this.items[this.cursor].classList.add(this.focusedCssClass))}setItems(s){this.items=s}next(){this.cursor=this.leafNodesAndReturnIndex(pe.directions.RIGHT)}previous(){this.cursor=this.leafNodesAndReturnIndex(pe.directions.LEFT)}dropCursor(){this.cursor!==-1&&(this.items[this.cursor].classList.remove(this.focusedCssClass),this.cursor=-1)}leafNodesAndReturnIndex(s){if(this.items.length===0)return this.cursor;let e=this.cursor;return e===-1?e=s===pe.directions.RIGHT?-1:0:this.items[e].classList.remove(this.focusedCssClass),s===pe.directions.RIGHT?e=(e+1)%this.items.length:e=(this.items.length+e-1)%this.items.length,d.canSetCaret(this.items[e])&&ne(()=>m.setCursor(this.items[e]),50)(),this.items[e].classList.add(this.focusedCssClass),e}};let le=pe;le.directions={RIGHT:"right",LEFT:"left"};class G{constructor(e){this.iterator=null,this.activated=!1,this.flipCallbacks=[],this.onKeyDown=t=>{if(this.isEventReadyForHandling(t))switch(G.usedKeys.includes(t.keyCode)&&t.preventDefault(),t.keyCode){case B.TAB:this.handleTabPress(t);break;case B.LEFT:case B.UP:this.flipLeft();break;case B.RIGHT:case B.DOWN:this.flipRight();break;case B.ENTER:this.handleEnterPress(t);break}},this.iterator=new le(e.items,e.focusedItemClass),this.activateCallback=e.activateCallback,this.allowedKeys=e.allowedKeys||G.usedKeys}get isActivated(){return this.activated}static get usedKeys(){return[B.TAB,B.LEFT,B.RIGHT,B.ENTER,B.UP,B.DOWN]}activate(e,t){this.activated=!0,e&&this.iterator.setItems(e),t!==void 0&&this.iterator.setCursor(t),document.addEventListener("keydown",this.onKeyDown,!0)}deactivate(){this.activated=!1,this.dropCursor(),document.removeEventListener("keydown",this.onKeyDown)}focusFirst(){this.dropCursor(),this.flipRight()}flipLeft(){this.iterator.previous(),this.flipCallback()}flipRight(){this.iterator.next(),this.flipCallback()}hasFocus(){return!!this.iterator.currentItem}onFlip(e){this.flipCallbacks.push(e)}removeOnFlip(e){this.flipCallbacks=this.flipCallbacks.filter(t=>t!==e)}dropCursor(){this.iterator.dropCursor()}isEventReadyForHandling(e){return this.activated&&this.allowedKeys.includes(e.keyCode)}handleTabPress(e){switch(e.shiftKey?le.directions.LEFT:le.directions.RIGHT){case le.directions.RIGHT:this.flipRight();break;case le.directions.LEFT:this.flipLeft();break}}handleEnterPress(e){this.activated&&(this.iterator.currentItem&&(e.stopPropagation(),e.preventDefault(),this.iterator.currentItem.click()),D(this.activateCallback)&&this.activateCallback(this.iterator.currentItem))}flipCallback(){this.iterator.currentItem&&this.iterator.currentItem.scrollIntoViewIfNeeded(),this.flipCallbacks.forEach(e=>e())}}class fe{static get CSS(){return{wrapper:"cdx-search-field",icon:"cdx-search-field__icon",input:"cdx-search-field__input"}}constructor({items:e,onSearch:t,placeholder:o}){this.listeners=new Me,this.items=e,this.onSearch=t,this.render(o)}getElement(){return this.wrapper}focus(){this.input.focus()}clear(){this.input.value="",this.searchQuery="",this.onSearch("",this.foundItems)}destroy(){this.listeners.removeAll()}render(e){this.wrapper=d.make("div",fe.CSS.wrapper);const t=d.make("div",fe.CSS.icon,{innerHTML:Io});this.input=d.make("input",fe.CSS.input,{placeholder:e}),this.wrapper.appendChild(t),this.wrapper.appendChild(this.input),this.listeners.on(this.input,"input",()=>{this.searchQuery=this.input.value,this.onSearch(this.searchQuery,this.foundItems)})}get foundItems(){return this.items.filter(e=>this.checkItem(e))}checkItem(e){var i;const t=((i=e.title)==null?void 0:i.toLowerCase())||"",o=this.searchQuery.toLowerCase();return t.includes(o)}}const ge=class{lock(){tt?this.lockHard():document.body.classList.add(ge.CSS.scrollLocked)}unlock(){tt?this.unlockHard():document.body.classList.remove(ge.CSS.scrollLocked)}lockHard(){this.scrollPosition=window.pageYOffset,document.documentElement.style.setProperty("--window-scroll-offset",`${this.scrollPosition}px`),document.body.classList.add(ge.CSS.scrollLockedHard)}unlockHard(){document.body.classList.remove(ge.CSS.scrollLockedHard),this.scrollPosition!==null&&window.scrollTo(0,this.scrollPosition),this.scrollPosition=null}};let ut=ge;ut.CSS={scrollLocked:"ce-scroll-locked",scrollLockedHard:"ce-scroll-locked--hard"};var Oo=Object.defineProperty,Ao=Object.getOwnPropertyDescriptor,_o=(s,e,t,o)=>{for(var i=o>1?void 0:o?Ao(e,t):e,n=s.length-1,r;n>=0;n--)(r=s[n])&&(i=(o?r(e,t,i):r(i))||i);return o&&i&&Oo(e,t,i),i},be=(s=>(s.Close="close",s))(be||{});const j=class extends ve{constructor(s){super(),this.scopeElement=document.body,this.listeners=new Me,this.scrollLocker=new ut,this.nodes={wrapper:null,popover:null,nothingFoundMessage:null,customContent:null,items:null,overlay:null},this.messages={nothingFound:"Nothing found",search:"Search"},this.onFlip=()=>{this.items.find(t=>t.isFocused).onFocus()},this.items=s.items.map(e=>new P(e)),s.scopeElement!==void 0&&(this.scopeElement=s.scopeElement),s.messages&&(this.messages={...this.messages,...s.messages}),s.customContentFlippableItems&&(this.customContentFlippableItems=s.customContentFlippableItems),this.make(),s.customContent&&this.addCustomContent(s.customContent),s.searchable&&this.addSearch(),this.initializeFlipper()}static get CSS(){return{popover:"ce-popover",popoverOpenTop:"ce-popover--open-top",popoverOpened:"ce-popover--opened",search:"ce-popover__search",nothingFoundMessage:"ce-popover__nothing-found-message",nothingFoundMessageDisplayed:"ce-popover__nothing-found-message--displayed",customContent:"ce-popover__custom-content",customContentHidden:"ce-popover__custom-content--hidden",items:"ce-popover__items",overlay:"ce-popover__overlay",overlayHidden:"ce-popover__overlay--hidden"}}getElement(){return this.nodes.wrapper}hasFocus(){return this.flipper.hasFocus()}show(){this.shouldOpenBottom||(this.nodes.popover.style.setProperty("--popover-height",this.height+"px"),this.nodes.popover.classList.add(j.CSS.popoverOpenTop)),this.nodes.overlay.classList.remove(j.CSS.overlayHidden),this.nodes.popover.classList.add(j.CSS.popoverOpened),this.flipper.activate(this.flippableElements),this.search!==void 0&&setTimeout(()=>{this.search.focus()},100),te()&&this.scrollLocker.lock()}hide(){this.nodes.popover.classList.remove(j.CSS.popoverOpened),this.nodes.popover.classList.remove(j.CSS.popoverOpenTop),this.nodes.overlay.classList.add(j.CSS.overlayHidden),this.flipper.deactivate(),this.items.forEach(s=>s.reset()),this.search!==void 0&&this.search.clear(),te()&&this.scrollLocker.unlock(),this.emit("close")}destroy(){this.flipper.deactivate(),this.listeners.removeAll(),te()&&this.scrollLocker.unlock()}make(){this.nodes.popover=d.make("div",[j.CSS.popover]),this.nodes.nothingFoundMessage=d.make("div",[j.CSS.nothingFoundMessage],{textContent:this.messages.nothingFound}),this.nodes.popover.appendChild(this.nodes.nothingFoundMessage),this.nodes.items=d.make("div",[j.CSS.items]),this.items.forEach(s=>{this.nodes.items.appendChild(s.getElement())}),this.nodes.popover.appendChild(this.nodes.items),this.listeners.on(this.nodes.popover,"click",s=>{const e=this.getTargetItem(s);e!==void 0&&this.handleItemClick(e)}),this.nodes.wrapper=d.make("div"),this.nodes.overlay=d.make("div",[j.CSS.overlay,j.CSS.overlayHidden]),this.listeners.on(this.nodes.overlay,"click",()=>{this.hide()}),this.nodes.wrapper.appendChild(this.nodes.overlay),this.nodes.wrapper.appendChild(this.nodes.popover)}addSearch(){this.search=new fe({items:this.items,placeholder:this.messages.search,onSearch:(e,t)=>{this.items.forEach(i=>{const n=!t.includes(i);i.toggleHidden(n)}),this.toggleNothingFoundMessage(t.length===0),this.toggleCustomContent(e!=="");const o=e===""?this.flippableElements:t.map(i=>i.getElement());this.flipper.isActivated&&(this.flipper.deactivate(),this.flipper.activate(o))}});const s=this.search.getElement();s.classList.add(j.CSS.search),this.nodes.popover.insertBefore(s,this.nodes.popover.firstChild)}addCustomContent(s){this.nodes.customContent=s,this.nodes.customContent.classList.add(j.CSS.customContent),this.nodes.popover.insertBefore(s,this.nodes.popover.firstChild)}getTargetItem(s){return this.items.find(e=>s.composedPath().includes(e.getElement()))}handleItemClick(s){s.isDisabled||(this.items.filter(e=>e!==s).forEach(e=>e.reset()),s.handleClick(),this.toggleItemActivenessIfNeeded(s),s.closeOnActivate&&this.hide())}initializeFlipper(){this.flipper=new G({items:this.flippableElements,focusedItemClass:P.CSS.focused,allowedKeys:[B.TAB,B.UP,B.DOWN,B.ENTER]}),this.flipper.onFlip(this.onFlip)}get flippableElements(){const s=this.items.map(t=>t.getElement());return(this.customContentFlippableItems||[]).concat(s)}get height(){let s=0;if(this.nodes.popover===null)return s;const e=this.nodes.popover.cloneNode(!0);return e.style.visibility="hidden",e.style.position="absolute",e.style.top="-1000px",e.classList.add(j.CSS.popoverOpened),document.body.appendChild(e),s=e.offsetHeight,e.remove(),s}get shouldOpenBottom(){const s=this.nodes.popover.getBoundingClientRect(),e=this.scopeElement.getBoundingClientRect(),t=this.height,o=s.top+t,i=s.top-t,n=Math.min(window.innerHeight,e.bottom);return it.toggle===s.toggle);if(e.length===1){s.toggleActive();return}e.forEach(t=>{t.toggleActive(t===s)})}}};let Pe=j;_o([re],Pe.prototype,"height",1);class No extends C{constructor(){super(...arguments),this.opened=!1,this.selection=new m,this.onPopoverClose=()=>{this.close()}}get events(){return{opened:"block-settings-opened",closed:"block-settings-closed"}}get CSS(){return{settings:"ce-settings"}}get flipper(){var e;return(e=this.popover)==null?void 0:e.flipper}make(){this.nodes.wrapper=d.make("div",[this.CSS.settings])}destroy(){this.removeAllNodes()}open(e=this.Editor.BlockManager.currentBlock){this.opened=!0,this.selection.save(),e.selected=!0,this.Editor.BlockSelection.clearCache();const[t,o]=e.getTunes();this.eventsDispatcher.emit(this.events.opened),this.popover=new Pe({searchable:!0,items:t.map(i=>this.resolveTuneAliases(i)),customContent:o,customContentFlippableItems:this.getControls(o),scopeElement:this.Editor.API.methods.ui.nodes.redactor,messages:{nothingFound:$.ui(V.ui.popover,"Nothing found"),search:$.ui(V.ui.popover,"Filter")}}),this.popover.on(be.Close,this.onPopoverClose),this.nodes.wrapper.append(this.popover.getElement()),this.popover.show()}getElement(){return this.nodes.wrapper}close(){this.opened=!1,m.isAtEditor||this.selection.restore(),this.selection.clearSaved(),!this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted&&this.Editor.BlockManager.currentBlock&&(this.Editor.BlockManager.currentBlock.selected=!1),this.eventsDispatcher.emit(this.events.closed),this.popover&&(this.popover.off(be.Close,this.onPopoverClose),this.popover.destroy(),this.popover.getElement().remove(),this.popover=null)}getControls(e){const{StylesAPI:t}=this.Editor,o=e.querySelectorAll(`.${t.classes.settingsButton}, ${d.allInputsSelector}`);return Array.from(o)}resolveTuneAliases(e){const t=xo(e,{label:"title"});return e.confirmation&&(t.confirmation=this.resolveTuneAliases(e.confirmation)),t}}class W extends C{constructor(){super(...arguments),this.opened=!1,this.tools=[],this.flipper=null,this.togglingCallback=null}static get CSS(){return{conversionToolbarWrapper:"ce-conversion-toolbar",conversionToolbarShowed:"ce-conversion-toolbar--showed",conversionToolbarTools:"ce-conversion-toolbar__tools",conversionToolbarLabel:"ce-conversion-toolbar__label",conversionTool:"ce-conversion-tool",conversionToolHidden:"ce-conversion-tool--hidden",conversionToolIcon:"ce-conversion-tool__icon",conversionToolSecondaryLabel:"ce-conversion-tool__secondary-label",conversionToolFocused:"ce-conversion-tool--focused",conversionToolActive:"ce-conversion-tool--active"}}make(){this.nodes.wrapper=d.make("div",[W.CSS.conversionToolbarWrapper,...this.isRtl?[this.Editor.UI.CSS.editorRtlFix]:[]]),this.nodes.tools=d.make("div",W.CSS.conversionToolbarTools);const e=d.make("div",W.CSS.conversionToolbarLabel,{textContent:$.ui(V.ui.inlineToolbar.converter,"Convert to")});return this.addTools(),this.enableFlipper(),d.append(this.nodes.wrapper,e),d.append(this.nodes.wrapper,this.nodes.tools),this.nodes.wrapper}destroy(){this.flipper&&(this.flipper.deactivate(),this.flipper=null),this.removeAllNodes()}toggle(e){this.opened?this.close():this.open(),D(e)&&(this.togglingCallback=e)}open(){this.filterTools(),this.opened=!0,this.nodes.wrapper.classList.add(W.CSS.conversionToolbarShowed),window.requestAnimationFrame(()=>{this.flipper.activate(this.tools.map(e=>e.button).filter(e=>!e.classList.contains(W.CSS.conversionToolHidden))),this.flipper.focusFirst(),D(this.togglingCallback)&&this.togglingCallback(!0)})}close(){this.opened=!1,this.flipper.deactivate(),this.nodes.wrapper.classList.remove(W.CSS.conversionToolbarShowed),D(this.togglingCallback)&&this.togglingCallback(!1)}hasTools(){return this.tools.length===1?this.tools[0].name!==this.config.defaultBlock:!0}async replaceWithBlock(e,t){const{BlockManager:o,BlockSelection:i,InlineToolbar:n,Caret:r}=this.Editor;o.convert(this.Editor.BlockManager.currentBlock,e,t),i.clearSelection(),this.close(),n.close(),window.requestAnimationFrame(()=>{r.setToBlock(this.Editor.BlockManager.currentBlock,r.positions.END)})}addTools(){const e=this.Editor.Tools.blockTools;Array.from(e.entries()).forEach(([t,o])=>{var n;const i=o.conversionConfig;!i||!i.import||(n=o.toolbox)==null||n.forEach(r=>this.addToolIfValid(t,r))})}addToolIfValid(e,t){X(t)||!t.icon||this.addTool(e,t)}addTool(e,t){var r;const o=d.make("div",[W.CSS.conversionTool]),i=d.make("div",[W.CSS.conversionToolIcon]);o.dataset.tool=e,i.innerHTML=t.icon,d.append(o,i),d.append(o,d.text($.t(V.toolNames,t.title||se(e))));const n=(r=this.Editor.Tools.blockTools.get(e))==null?void 0:r.shortcut;if(n){const a=d.make("span",W.CSS.conversionToolSecondaryLabel,{innerText:Se(n)});d.append(o,a)}d.append(this.nodes.tools,o),this.tools.push({name:e,button:o,toolboxItem:t}),this.listeners.on(o,"click",async()=>{await this.replaceWithBlock(e,t.data)})}async filterTools(){const{currentBlock:e}=this.Editor.BlockManager,t=await e.getActiveToolboxEntry();function o(i,n){return i.icon===n.icon&&i.title===n.title}this.tools.forEach(i=>{let n=!1;if(t){const r=o(t,i.toolboxItem);n=i.button.dataset.tool===e.name&&r}i.button.hidden=n,i.button.classList.toggle(W.CSS.conversionToolHidden,n)})}enableFlipper(){this.flipper=new G({focusedItemClass:W.CSS.conversionToolFocused})}}var Fe={},Ro={get exports(){return Fe},set exports(s){Fe=s}};/*! + * Library for handling keyboard shortcuts + * @copyright CodeX (https://codex.so) + * @license MIT + * @author CodeX (https://codex.so) + * @version 1.2.0 + */(function(s,e){(function(t,o){s.exports=o()})(window,function(){return function(t){var o={};function i(n){if(o[n])return o[n].exports;var r=o[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=o,i.d=function(n,r,a){i.o(n,r)||Object.defineProperty(n,r,{enumerable:!0,get:a})},i.r=function(n){typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},i.t=function(n,r){if(1&r&&(n=i(n)),8&r||4&r&&typeof n=="object"&&n&&n.__esModule)return n;var a=Object.create(null);if(i.r(a),Object.defineProperty(a,"default",{enumerable:!0,value:n}),2&r&&typeof n!="string")for(var l in n)i.d(a,l,function(c){return n[c]}.bind(null,l));return a},i.n=function(n){var r=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(r,"a",r),r},i.o=function(n,r){return Object.prototype.hasOwnProperty.call(n,r)},i.p="",i(i.s=0)}([function(t,o,i){function n(l,c){for(var u=0;un!==o))}findShortcut(e,t){return(this.registeredShortcuts.get(e)||[]).find(({name:i})=>i===t)}}const ce=new Po;var Fo=Object.defineProperty,Ho=Object.getOwnPropertyDescriptor,pt=(s,e,t,o)=>{for(var i=o>1?void 0:o?Ho(e,t):e,n=s.length-1,r;n>=0;n--)(r=s[n])&&(i=(o?r(e,t,i):r(i))||i);return o&&i&&Fo(e,t,i),i},xe=(s=>(s.Opened="toolbox-opened",s.Closed="toolbox-closed",s.BlockAdded="toolbox-block-added",s))(xe||{});const ft=class extends ve{constructor({api:s,tools:e,i18nLabels:t}){super(),this.opened=!1,this.nodes={toolbox:null},this.onPopoverClose=()=>{this.opened=!1,this.emit("toolbox-closed")},this.api=s,this.tools=e,this.i18nLabels=t}get isEmpty(){return this.toolsToBeDisplayed.length===0}static get CSS(){return{toolbox:"ce-toolbox"}}make(){return this.popover=new Pe({scopeElement:this.api.ui.nodes.redactor,searchable:!0,messages:{nothingFound:this.i18nLabels.nothingFound,search:this.i18nLabels.filter},items:this.toolboxItemsToBeDisplayed}),this.popover.on(be.Close,this.onPopoverClose),this.enableShortcuts(),this.nodes.toolbox=this.popover.getElement(),this.nodes.toolbox.classList.add(ft.CSS.toolbox),this.nodes.toolbox}hasFocus(){var s;return(s=this.popover)==null?void 0:s.hasFocus()}destroy(){var s;super.destroy(),this.nodes&&this.nodes.toolbox&&(this.nodes.toolbox.remove(),this.nodes.toolbox=null),this.removeAllShortcuts(),(s=this.popover)==null||s.off(be.Close,this.onPopoverClose)}toolButtonActivated(s,e){this.insertNewBlock(s,e)}open(){var s;this.isEmpty||((s=this.popover)==null||s.show(),this.opened=!0,this.emit("toolbox-opened"))}close(){var s;(s=this.popover)==null||s.hide(),this.opened=!1,this.emit("toolbox-closed")}toggle(){this.opened?this.close():this.open()}get toolsToBeDisplayed(){const s=[];return this.tools.forEach(e=>{e.toolbox&&s.push(e)}),s}get toolboxItemsToBeDisplayed(){const s=(e,t)=>({icon:e.icon,title:$.t(V.toolNames,e.title||se(t.name)),name:t.name,onActivate:()=>{this.toolButtonActivated(t.name,e.data)},secondaryLabel:t.shortcut?Se(t.shortcut):""});return this.toolsToBeDisplayed.reduce((e,t)=>(Array.isArray(t.toolbox)?t.toolbox.forEach(o=>{e.push(s(o,t))}):t.toolbox!==void 0&&e.push(s(t.toolbox,t)),e),[])}enableShortcuts(){this.toolsToBeDisplayed.forEach(s=>{const e=s.shortcut;e&&this.enableShortcutForTool(s.name,e)})}enableShortcutForTool(s,e){ce.add({name:e,on:this.api.ui.nodes.redactor,handler:t=>{t.preventDefault();const o=this.api.blocks.getCurrentBlockIndex(),i=this.api.blocks.getBlockByIndex(o);if(i)try{this.api.blocks.convert(i.id,s),window.requestAnimationFrame(()=>{this.api.caret.setToBlock(o,"end")});return}catch{}this.insertNewBlock(s)}})}removeAllShortcuts(){this.toolsToBeDisplayed.forEach(s=>{const e=s.shortcut;e&&ce.remove(this.api.ui.nodes.redactor,e)})}async insertNewBlock(s,e){const t=this.api.blocks.getCurrentBlockIndex(),o=this.api.blocks.getBlockByIndex(t);if(!o)return;const i=o.isEmpty?t:t+1;let n;if(e){const a=await this.api.blocks.composeBlockData(s);n=Object.assign(a,e)}const r=this.api.blocks.insert(s,n,void 0,i,void 0,o.isEmpty);r.call(q.APPEND_CALLBACK),this.api.caret.setToBlock(i),this.emit("toolbox-block-added",{block:r}),this.api.toolbar.close()}};let He=ft;pt([re],He.prototype,"toolsToBeDisplayed",1),pt([re],He.prototype,"toolboxItemsToBeDisplayed",1);const gt="block hovered";class jo extends C{constructor({config:e,eventsDispatcher:t}){super({config:e,eventsDispatcher:t}),this.toolboxInstance=null,this.tooltip=new De}get CSS(){return{toolbar:"ce-toolbar",content:"ce-toolbar__content",actions:"ce-toolbar__actions",actionsOpened:"ce-toolbar__actions--opened",toolbarOpened:"ce-toolbar--opened",openedToolboxHolderModifier:"codex-editor--toolbox-opened",plusButton:"ce-toolbar__plus",plusButtonShortcut:"ce-toolbar__plus-shortcut",settingsToggler:"ce-toolbar__settings-btn",settingsTogglerHidden:"ce-toolbar__settings-btn--hidden"}}get opened(){return this.nodes.wrapper.classList.contains(this.CSS.toolbarOpened)}get toolbox(){var e;return{opened:(e=this.toolboxInstance)==null?void 0:e.opened,close:()=>{var t;(t=this.toolboxInstance)==null||t.close()},open:()=>{if(this.toolboxInstance===null){L("toolbox.open() called before initialization is finished","warn");return}this.Editor.BlockManager.currentBlock=this.hoveredBlock,this.toolboxInstance.open()},toggle:()=>{if(this.toolboxInstance===null){L("toolbox.toggle() called before initialization is finished","warn");return}this.toolboxInstance.toggle()},hasFocus:()=>{var t;return(t=this.toolboxInstance)==null?void 0:t.hasFocus()}}}get blockActions(){return{hide:()=>{this.nodes.actions.classList.remove(this.CSS.actionsOpened)},show:()=>{this.nodes.actions.classList.add(this.CSS.actionsOpened)}}}get blockTunesToggler(){return{hide:()=>this.nodes.settingsToggler.classList.add(this.CSS.settingsTogglerHidden),show:()=>this.nodes.settingsToggler.classList.remove(this.CSS.settingsTogglerHidden)}}toggleReadOnly(e){e?(this.destroy(),this.Editor.BlockSettings.destroy(),this.disableModuleBindings()):window.requestIdleCallback(()=>{this.drawUI(),this.enableModuleBindings()},{timeout:2e3})}moveAndOpen(e=this.Editor.BlockManager.currentBlock){if(this.toolboxInstance===null){L("Can't open Toolbar since Editor initialization is not finished yet","warn");return}if(this.toolboxInstance.opened&&this.toolboxInstance.close(),this.Editor.BlockSettings.opened&&this.Editor.BlockSettings.close(),!e)return;this.hoveredBlock=e;const t=e.holder,{isMobile:o}=this.Editor.UI,i=e.pluginsContent,n=window.getComputedStyle(i),r=parseInt(n.paddingTop,10),a=t.offsetHeight;let l;o?l=t.offsetTop+a:l=t.offsetTop+r,this.nodes.wrapper.style.top=`${Math.floor(l)}px`,this.Editor.BlockManager.blocks.length===1&&e.isEmpty?this.blockTunesToggler.hide():this.blockTunesToggler.show(),this.open()}close(){var e;this.Editor.ReadOnly.isEnabled||(this.nodes.wrapper.classList.remove(this.CSS.toolbarOpened),this.blockActions.hide(),(e=this.toolboxInstance)==null||e.close(),this.Editor.BlockSettings.close())}open(e=!0){ne(()=>{this.nodes.wrapper.classList.add(this.CSS.toolbarOpened),e?this.blockActions.show():this.blockActions.hide()},50)()}make(){this.nodes.wrapper=d.make("div",this.CSS.toolbar),["content","actions"].forEach(t=>{this.nodes[t]=d.make("div",this.CSS[t])}),d.append(this.nodes.wrapper,this.nodes.content),d.append(this.nodes.content,this.nodes.actions),this.nodes.plusButton=d.make("div",this.CSS.plusButton,{innerHTML:So}),d.append(this.nodes.actions,this.nodes.plusButton),this.readOnlyMutableListeners.on(this.nodes.plusButton,"click",()=>{this.tooltip.hide(!0),this.plusButtonClicked()},!1);const e=d.make("div");e.appendChild(document.createTextNode($.ui(V.ui.toolbar.toolbox,"Add"))),e.appendChild(d.make("div",this.CSS.plusButtonShortcut,{textContent:"⇥ Tab"})),this.tooltip.onHover(this.nodes.plusButton,e,{hidingDelay:400}),this.nodes.settingsToggler=d.make("span",this.CSS.settingsToggler,{innerHTML:Co}),d.append(this.nodes.actions,this.nodes.settingsToggler),this.tooltip.onHover(this.nodes.settingsToggler,$.ui(V.ui.blockTunes.toggler,"Click to tune"),{hidingDelay:400}),d.append(this.nodes.actions,this.makeToolbox()),d.append(this.nodes.actions,this.Editor.BlockSettings.getElement()),d.append(this.Editor.UI.nodes.wrapper,this.nodes.wrapper)}makeToolbox(){return this.toolboxInstance=new He({api:this.Editor.API.methods,tools:this.Editor.Tools.blockTools,i18nLabels:{filter:$.ui(V.ui.popover,"Filter"),nothingFound:$.ui(V.ui.popover,"Nothing found")}}),this.toolboxInstance.on(xe.Opened,()=>{this.Editor.UI.nodes.wrapper.classList.add(this.CSS.openedToolboxHolderModifier)}),this.toolboxInstance.on(xe.Closed,()=>{this.Editor.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier)}),this.toolboxInstance.on(xe.BlockAdded,({block:e})=>{const{BlockManager:t,Caret:o}=this.Editor,i=t.getBlockById(e.id);i.inputs.length===0&&(i===t.lastBlock?(t.insertAtEnd(),o.setToBlock(t.lastBlock)):o.setToBlock(t.nextBlock))}),this.toolboxInstance.make()}plusButtonClicked(){var e;this.Editor.BlockManager.currentBlock=this.hoveredBlock,(e=this.toolboxInstance)==null||e.toggle()}enableModuleBindings(){this.readOnlyMutableListeners.on(this.nodes.settingsToggler,"mousedown",e=>{var t;e.stopPropagation(),this.settingsTogglerClicked(),(t=this.toolboxInstance)!=null&&t.opened&&this.toolboxInstance.close(),this.tooltip.hide(!0)},!0),te()||this.eventsDispatcher.on(gt,e=>{var t;this.Editor.BlockSettings.opened||(t=this.toolboxInstance)!=null&&t.opened||this.moveAndOpen(e.block)})}disableModuleBindings(){this.readOnlyMutableListeners.clearAll()}settingsTogglerClicked(){this.Editor.BlockManager.currentBlock=this.hoveredBlock,this.Editor.BlockSettings.opened?this.Editor.BlockSettings.close():this.Editor.BlockSettings.open(this.hoveredBlock)}drawUI(){this.Editor.BlockSettings.make(),this.make()}destroy(){this.removeAllNodes(),this.toolboxInstance&&this.toolboxInstance.destroy(),this.tooltip.destroy()}}var we=(s=>(s[s.Block=0]="Block",s[s.Inline=1]="Inline",s[s.Tune=2]="Tune",s))(we||{}),ye=(s=>(s.Shortcut="shortcut",s.Toolbox="toolbox",s.EnabledInlineTools="inlineToolbar",s.EnabledBlockTunes="tunes",s.Config="config",s))(ye||{}),bt=(s=>(s.Shortcut="shortcut",s.SanitizeConfig="sanitize",s))(bt||{}),de=(s=>(s.IsEnabledLineBreaks="enableLineBreaks",s.Toolbox="toolbox",s.ConversionConfig="conversionConfig",s.IsReadOnlySupported="isReadOnlySupported",s.PasteConfig="pasteConfig",s))(de||{}),je=(s=>(s.IsInline="isInline",s.Title="title",s))(je||{}),mt=(s=>(s.IsTune="isTune",s))(mt||{});class ze{constructor({name:e,constructable:t,config:o,api:i,isDefault:n,isInternal:r=!1,defaultPlaceholder:a}){this.api=i,this.name=e,this.constructable=t,this.config=o,this.isDefault=n,this.isInternal=r,this.defaultPlaceholder=a}get settings(){const e=this.config.config||{};return this.isDefault&&!("placeholder"in e)&&this.defaultPlaceholder&&(e.placeholder=this.defaultPlaceholder),e}reset(){if(D(this.constructable.reset))return this.constructable.reset()}prepare(){if(D(this.constructable.prepare))return this.constructable.prepare({toolName:this.name,config:this.settings})}get shortcut(){const e=this.constructable.shortcut;return this.config.shortcut||e}get sanitizeConfig(){return this.constructable.sanitize||{}}isInline(){return this.type===1}isBlock(){return this.type===0}isTune(){return this.type===2}}class zo extends C{constructor({config:e,eventsDispatcher:t}){super({config:e,eventsDispatcher:t}),this.CSS={inlineToolbar:"ce-inline-toolbar",inlineToolbarShowed:"ce-inline-toolbar--showed",inlineToolbarLeftOriented:"ce-inline-toolbar--left-oriented",inlineToolbarRightOriented:"ce-inline-toolbar--right-oriented",inlineToolbarShortcut:"ce-inline-toolbar__shortcut",buttonsWrapper:"ce-inline-toolbar__buttons",actionsWrapper:"ce-inline-toolbar__actions",inlineToolButton:"ce-inline-tool",inputField:"cdx-input",focusedButton:"ce-inline-tool--focused",conversionToggler:"ce-inline-toolbar__dropdown",conversionTogglerArrow:"ce-inline-toolbar__dropdown-arrow",conversionTogglerHidden:"ce-inline-toolbar__dropdown--hidden",conversionTogglerContent:"ce-inline-toolbar__dropdown-content",togglerAndButtonsWrapper:"ce-inline-toolbar__toggler-and-button-wrapper"},this.opened=!1,this.toolbarVerticalMargin=te()?20:6,this.buttonsList=null,this.width=0,this.flipper=null,this.tooltip=new De}toggleReadOnly(e){e?(this.destroy(),this.Editor.ConversionToolbar.destroy()):window.requestIdleCallback(()=>{this.make()},{timeout:2e3})}tryToShow(e=!1,t=!0){if(!this.allowedToShow()){e&&this.close();return}this.move(),this.open(t),this.Editor.Toolbar.close()}move(){const e=m.rect,t=this.Editor.UI.nodes.wrapper.getBoundingClientRect(),o={x:e.x-t.left,y:e.y+e.height-t.top+this.toolbarVerticalMargin};e.width&&(o.x+=Math.floor(e.width/2));const i=o.x-this.width/2,n=o.x+this.width/2;this.nodes.wrapper.classList.toggle(this.CSS.inlineToolbarLeftOriented,ithis.Editor.UI.contentRect.right),this.nodes.wrapper.style.left=Math.floor(o.x)+"px",this.nodes.wrapper.style.top=Math.floor(o.y)+"px"}close(){this.opened&&(this.Editor.ReadOnly.isEnabled||(this.nodes.wrapper.classList.remove(this.CSS.inlineToolbarShowed),Array.from(this.toolsInstances.entries()).forEach(([e,t])=>{const o=this.getToolShortcut(e);o&&ce.remove(this.Editor.UI.nodes.redactor,o),D(t.clear)&&t.clear()}),this.opened=!1,this.flipper.deactivate(),this.Editor.ConversionToolbar.close()))}open(e=!0){if(this.opened)return;this.addToolsFiltered(),this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed),this.buttonsList=this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`),this.opened=!0,e&&this.Editor.ConversionToolbar.hasTools()?this.setConversionTogglerContent():this.nodes.conversionToggler.hidden=!0;let t=Array.from(this.buttonsList);t.unshift(this.nodes.conversionToggler),t=t.filter(o=>!o.hidden),this.flipper.activate(t)}containsNode(e){return this.nodes.wrapper.contains(e)}destroy(){this.flipper&&(this.flipper.deactivate(),this.flipper=null),this.removeAllNodes(),this.tooltip.destroy()}make(){this.nodes.wrapper=d.make("div",[this.CSS.inlineToolbar,...this.isRtl?[this.Editor.UI.CSS.editorRtlFix]:[]]),this.nodes.togglerAndButtonsWrapper=d.make("div",this.CSS.togglerAndButtonsWrapper),this.nodes.buttons=d.make("div",this.CSS.buttonsWrapper),this.nodes.actions=d.make("div",this.CSS.actionsWrapper),this.listeners.on(this.nodes.wrapper,"mousedown",e=>{e.target.closest(`.${this.CSS.actionsWrapper}`)||e.preventDefault()}),d.append(this.nodes.wrapper,[this.nodes.togglerAndButtonsWrapper,this.nodes.actions]),d.append(this.Editor.UI.nodes.wrapper,this.nodes.wrapper),this.addConversionToggler(),d.append(this.nodes.togglerAndButtonsWrapper,this.nodes.buttons),this.prepareConversionToolbar(),window.requestAnimationFrame(()=>{this.recalculateWidth()}),this.enableFlipper()}allowedToShow(){const e=["IMG","INPUT"],t=m.get(),o=m.text;if(!t||!t.anchorNode||t.isCollapsed||o.length<1)return!1;const i=d.isElement(t.anchorNode)?t.anchorNode:t.anchorNode.parentElement;if(t&&e.includes(i.tagName)||i.closest('[contenteditable="true"]')===null)return!1;const r=this.Editor.BlockManager.getBlock(t.anchorNode);return r?r.tool.inlineTools.size!==0:!1}recalculateWidth(){this.width=this.nodes.wrapper.offsetWidth}addConversionToggler(){this.nodes.conversionToggler=d.make("div",this.CSS.conversionToggler),this.nodes.conversionTogglerContent=d.make("div",this.CSS.conversionTogglerContent);const e=d.make("div",this.CSS.conversionTogglerArrow,{innerHTML:dt});this.nodes.conversionToggler.appendChild(this.nodes.conversionTogglerContent),this.nodes.conversionToggler.appendChild(e),this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler),this.listeners.on(this.nodes.conversionToggler,"click",()=>{this.Editor.ConversionToolbar.toggle(t=>{!t&&this.opened?this.flipper.activate():this.opened&&this.flipper.deactivate()})}),te()===!1&&this.tooltip.onHover(this.nodes.conversionToggler,$.ui(V.ui.inlineToolbar.converter,"Convert to"),{placement:"top",hidingDelay:100})}async setConversionTogglerContent(){const{BlockManager:e}=this.Editor,{currentBlock:t}=e,o=t.name,i=t.tool.conversionConfig,n=i&&i.export;this.nodes.conversionToggler.hidden=!n,this.nodes.conversionToggler.classList.toggle(this.CSS.conversionTogglerHidden,!n);const r=await t.getActiveToolboxEntry()||{};this.nodes.conversionTogglerContent.innerHTML=r.icon||r.title||se(o)}prepareConversionToolbar(){const e=this.Editor.ConversionToolbar.make();d.append(this.nodes.wrapper,e)}addToolsFiltered(){const e=m.get(),t=this.Editor.BlockManager.getBlock(e.anchorNode);this.nodes.buttons.innerHTML="",this.nodes.actions.innerHTML="",this.toolsInstances=new Map,Array.from(t.tool.inlineTools.values()).forEach(o=>{this.addTool(o)}),this.recalculateWidth()}addTool(e){const t=e.create(),o=t.render();if(!o){L("Render method must return an instance of Node","warn",e.name);return}if(o.dataset.tool=e.name,this.nodes.buttons.appendChild(o),this.toolsInstances.set(e.name,t),D(t.renderActions)){const a=t.renderActions();this.nodes.actions.appendChild(a)}this.listeners.on(o,"click",a=>{this.toolClicked(t),a.preventDefault()});const i=this.getToolShortcut(e.name);if(i)try{this.enableShortcuts(t,i)}catch{}const n=d.make("div"),r=$.t(V.toolNames,e.title||se(e.name));n.appendChild(d.text(r)),i&&n.appendChild(d.make("div",this.CSS.inlineToolbarShortcut,{textContent:Se(i)})),te()===!1&&this.tooltip.onHover(o,n,{placement:"top",hidingDelay:100}),t.checkState(m.get())}getToolShortcut(e){const{Tools:t}=this.Editor,o=t.inlineTools.get(e),i=t.internal.inlineTools;return Array.from(i.keys()).includes(e)?this.inlineTools[e][bt.Shortcut]:o.shortcut}enableShortcuts(e,t){ce.add({name:t,handler:o=>{const{currentBlock:i}=this.Editor.BlockManager;i&&i.tool.enabledInlineTools&&(o.preventDefault(),this.toolClicked(e))},on:this.Editor.UI.nodes.redactor})}toolClicked(e){const t=m.range;e.surround(t),this.checkToolsState(),e.renderActions!==void 0&&this.flipper.deactivate()}checkToolsState(){this.toolsInstances.forEach(e=>{e.checkState(m.get())})}get inlineTools(){const e={};return Array.from(this.Editor.Tools.inlineTools.entries()).forEach(([t,o])=>{e[t]=o.create()}),e}enableFlipper(){this.flipper=new G({focusedItemClass:this.CSS.focusedButton,allowedKeys:[B.ENTER,B.TAB]})}}class Uo extends C{keydown(e){switch(this.beforeKeydownProcessing(e),e.keyCode){case B.BACKSPACE:this.backspace(e);break;case B.DELETE:this.delete(e);break;case B.ENTER:this.enter(e);break;case B.DOWN:case B.RIGHT:this.arrowRightAndDown(e);break;case B.UP:case B.LEFT:this.arrowLeftAndUp(e);break;case B.TAB:this.tabPressed(e);break}}beforeKeydownProcessing(e){this.needToolbarClosing(e)&&Je(e.keyCode)&&(this.Editor.Toolbar.close(),this.Editor.ConversionToolbar.close(),e.ctrlKey||e.metaKey||e.altKey||e.shiftKey||(this.Editor.BlockManager.clearFocused(),this.Editor.BlockSelection.clearSelection(e)))}keyup(e){e.shiftKey||this.Editor.UI.checkEmptiness()}tabPressed(e){this.Editor.BlockSelection.clearSelection(e);const{BlockManager:t,InlineToolbar:o,ConversionToolbar:i}=this.Editor,n=t.currentBlock;if(!n)return;const r=n.isEmpty,a=n.tool.isDefault&&r,l=!r&&i.opened,c=!r&&!m.isCollapsed&&o.opened;a?this.activateToolbox():!l&&!c&&this.activateBlockSettings()}dragOver(e){const t=this.Editor.BlockManager.getBlockByChildNode(e.target);t.dropTarget=!0}dragLeave(e){const t=this.Editor.BlockManager.getBlockByChildNode(e.target);t.dropTarget=!1}handleCommandC(e){const{BlockSelection:t}=this.Editor;t.anyBlockSelected&&t.copySelectedBlocks(e)}handleCommandX(e){const{BlockSelection:t,BlockManager:o,Caret:i}=this.Editor;t.anyBlockSelected&&t.copySelectedBlocks(e).then(()=>{const n=o.removeSelectedBlocks(),r=o.insertDefaultBlockAtIndex(n,!0);i.setToBlock(r,i.positions.START),t.clearSelection(e)})}enter(e){const{BlockManager:t,UI:o}=this.Editor;if(t.currentBlock.tool.isLineBreaksEnabled||o.someToolbarOpened&&o.someFlipperButtonFocused||e.shiftKey)return;let n=this.Editor.BlockManager.currentBlock;this.Editor.Caret.isAtStart&&!this.Editor.BlockManager.currentBlock.hasMedia?this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex):this.Editor.Caret.isAtEnd?n=this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex+1):n=this.Editor.BlockManager.split(),this.Editor.Caret.setToBlock(n),this.Editor.Toolbar.moveAndOpen(n),e.preventDefault()}backspace(e){const{BlockManager:t,Caret:o}=this.Editor,{currentBlock:i,previousBlock:n}=t;if(!m.isCollapsed||!o.isAtStart)return;if(e.preventDefault(),this.Editor.Toolbar.close(),!(i.currentInput===i.firstInput)){o.navigatePrevious();return}if(n===null)return;if(n.isEmpty){t.removeBlock(n);return}if(i.isEmpty){t.removeBlock(i);const l=t.currentBlock;o.setToBlock(l,o.positions.END);return}at(i,n)?this.mergeBlocks(n,i):o.setToBlock(n,o.positions.END)}delete(e){const{BlockManager:t,Caret:o}=this.Editor,{currentBlock:i,nextBlock:n}=t;if(!m.isCollapsed||!o.isAtEnd)return;if(e.preventDefault(),this.Editor.Toolbar.close(),!(i.currentInput===i.lastInput)){o.navigateNext();return}if(n===null)return;if(n.isEmpty){t.removeBlock(n);return}if(i.isEmpty){t.removeBlock(i),o.setToBlock(n,o.positions.START);return}at(i,n)?this.mergeBlocks(i,n):o.setToBlock(n,o.positions.START)}mergeBlocks(e,t){const{BlockManager:o,Caret:i,Toolbar:n}=this.Editor;i.createShadow(e.pluginsContent),o.mergeBlocks(e,t).then(()=>{window.requestAnimationFrame(()=>{i.restoreCaret(e.pluginsContent),e.pluginsContent.normalize(),n.close()})})}arrowRightAndDown(e){const t=G.usedKeys.includes(e.keyCode)&&(!e.shiftKey||e.keyCode===B.TAB);if(this.Editor.UI.someToolbarOpened&&t)return;this.Editor.BlockManager.clearFocused(),this.Editor.Toolbar.close();const o=this.Editor.Caret.isAtEnd||this.Editor.BlockSelection.anyBlockSelected;if(e.shiftKey&&e.keyCode===B.DOWN&&o){this.Editor.CrossBlockSelection.toggleBlockSelectedState();return}(e.keyCode===B.DOWN||e.keyCode===B.RIGHT&&!this.isRtl?this.Editor.Caret.navigateNext():this.Editor.Caret.navigatePrevious())?e.preventDefault():ne(()=>{this.Editor.BlockManager.currentBlock&&this.Editor.BlockManager.currentBlock.updateCurrentInput()},20)(),this.Editor.BlockSelection.clearSelection(e)}arrowLeftAndUp(e){if(this.Editor.UI.someToolbarOpened){if(G.usedKeys.includes(e.keyCode)&&(!e.shiftKey||e.keyCode===B.TAB))return;this.Editor.UI.closeAllToolbars()}this.Editor.BlockManager.clearFocused(),this.Editor.Toolbar.close();const t=this.Editor.Caret.isAtStart||this.Editor.BlockSelection.anyBlockSelected;if(e.shiftKey&&e.keyCode===B.UP&&t){this.Editor.CrossBlockSelection.toggleBlockSelectedState(!1);return}(e.keyCode===B.UP||e.keyCode===B.LEFT&&!this.isRtl?this.Editor.Caret.navigatePrevious():this.Editor.Caret.navigateNext())?e.preventDefault():ne(()=>{this.Editor.BlockManager.currentBlock&&this.Editor.BlockManager.currentBlock.updateCurrentInput()},20)(),this.Editor.BlockSelection.clearSelection(e)}needToolbarClosing(e){const t=e.keyCode===B.ENTER&&this.Editor.Toolbar.toolbox.opened,o=e.keyCode===B.ENTER&&this.Editor.BlockSettings.opened,i=e.keyCode===B.ENTER&&this.Editor.InlineToolbar.opened,n=e.keyCode===B.ENTER&&this.Editor.ConversionToolbar.opened,r=e.keyCode===B.TAB;return!(e.shiftKey||r||t||o||i||n)}activateToolbox(){this.Editor.Toolbar.opened||this.Editor.Toolbar.moveAndOpen(),this.Editor.Toolbar.toolbox.open()}activateBlockSettings(){this.Editor.Toolbar.opened||(this.Editor.BlockManager.currentBlock.focused=!0,this.Editor.Toolbar.moveAndOpen()),this.Editor.BlockSettings.opened||this.Editor.BlockSettings.open()}}class Ue{constructor(e){this.blocks=[],this.workingArea=e}get length(){return this.blocks.length}get array(){return this.blocks}get nodes(){return Qe(this.workingArea.children)}static set(e,t,o){return isNaN(Number(t))?(Reflect.set(e,t,o),!0):(e.insert(+t,o),!0)}static get(e,t){return isNaN(Number(t))?Reflect.get(e,t):e.get(+t)}push(e){this.blocks.push(e),this.insertToDOM(e)}swap(e,t){const o=this.blocks[t];d.swap(this.blocks[e].holder,o.holder),this.blocks[t]=this.blocks[e],this.blocks[e]=o}move(e,t){const o=this.blocks.splice(t,1)[0],i=e-1,n=Math.max(0,i),r=this.blocks[n];e>0?this.insertToDOM(o,"afterend",r):this.insertToDOM(o,"beforebegin",r),this.blocks.splice(e,0,o);const a=this.composeBlockEvent("move",{fromIndex:t,toIndex:e});o.call(q.MOVED,a)}insert(e,t,o=!1){if(!this.length){this.push(t);return}e>this.length&&(e=this.length),o&&(this.blocks[e].holder.remove(),this.blocks[e].call(q.REMOVED));const i=o?1:0;if(this.blocks.splice(e,i,t),e>0){const n=this.blocks[e-1];this.insertToDOM(t,"afterend",n)}else{const n=this.blocks[e+1];n?this.insertToDOM(t,"beforebegin",n):this.insertToDOM(t)}}replace(e,t){if(this.blocks[e]===void 0)throw Error("Incorrect index");this.blocks[e].holder.replaceWith(t.holder),this.blocks[e]=t}insertMany(e,t){const o=new DocumentFragment;for(const i of e)o.appendChild(i.holder);if(this.length>0){if(t>0){const i=Math.min(t-1,this.length-1);this.blocks[i].holder.after(o)}else t===0&&this.workingArea.prepend(o);this.blocks.splice(t,0,...e)}else this.blocks.push(...e),this.workingArea.appendChild(o);e.forEach(i=>i.call(q.RENDERED))}remove(e){isNaN(e)&&(e=this.length-1),this.blocks[e].holder.remove(),this.blocks[e].call(q.REMOVED),this.blocks.splice(e,1)}removeAll(){this.workingArea.innerHTML="",this.blocks.forEach(e=>e.call(q.REMOVED)),this.blocks.length=0}insertAfter(e,t){const o=this.blocks.indexOf(e);this.insert(o+1,t)}get(e){return this.blocks[e]}indexOf(e){return this.blocks.indexOf(e)}insertToDOM(e,t,o){t?o.holder.insertAdjacentElement(t,e.holder):this.workingArea.appendChild(e.holder),e.call(q.RENDERED)}composeBlockEvent(e,t){return new CustomEvent(e,{detail:t})}}const kt="block-removed",vt="block-added",$o="block-moved",xt="block-changed";class Wo{constructor(){this.completed=Promise.resolve()}add(e){return new Promise((t,o)=>{this.completed=this.completed.then(e).then(t).catch(o)})}}class Yo extends C{constructor(){super(...arguments),this._currentBlockIndex=-1,this._blocks=null}get currentBlockIndex(){return this._currentBlockIndex}set currentBlockIndex(e){this._currentBlockIndex=e}get firstBlock(){return this._blocks[0]}get lastBlock(){return this._blocks[this._blocks.length-1]}get currentBlock(){return this._blocks[this.currentBlockIndex]}set currentBlock(e){this.currentBlockIndex=this.getBlockIndex(e)}get nextBlock(){return this.currentBlockIndex===this._blocks.length-1?null:this._blocks[this.currentBlockIndex+1]}get nextContentfulBlock(){return this.blocks.slice(this.currentBlockIndex+1).find(t=>!!t.inputs.length)}get previousContentfulBlock(){return this.blocks.slice(0,this.currentBlockIndex).reverse().find(t=>!!t.inputs.length)}get previousBlock(){return this.currentBlockIndex===0?null:this._blocks[this.currentBlockIndex-1]}get blocks(){return this._blocks.array}get isEditorEmpty(){return this.blocks.every(e=>e.isEmpty)}prepare(){const e=new Ue(this.Editor.UI.nodes.redactor);this._blocks=new Proxy(e,{set:Ue.set,get:Ue.get}),this.listeners.on(document,"copy",t=>this.Editor.BlockEvents.handleCommandC(t))}toggleReadOnly(e){e?this.disableModuleBindings():this.enableModuleBindings()}composeBlock({tool:e,data:t={},id:o=void 0,tunes:i={}}){const n=this.Editor.ReadOnly.isEnabled,r=this.Editor.Tools.blockTools.get(e),a=new F({id:o,data:t,tool:r,api:this.Editor.API,readOnly:n,tunesData:i},this.eventsDispatcher);return n||window.requestIdleCallback(()=>{this.bindBlockEvents(a)},{timeout:2e3}),a}insert({id:e=void 0,tool:t=this.config.defaultBlock,data:o={},index:i,needToFocus:n=!0,replace:r=!1,tunes:a={}}={}){let l=i;l===void 0&&(l=this.currentBlockIndex+(r?0:1));const c=this.composeBlock({id:e,tool:t,data:o,tunes:a});return r&&this.blockDidMutated(kt,this.getBlockByIndex(l),{index:l}),this._blocks.insert(l,c,r),this.blockDidMutated(vt,c,{index:l}),n?this.currentBlockIndex=l:l<=this.currentBlockIndex&&this.currentBlockIndex++,c}insertMany(e,t=0){this._blocks.insertMany(e,t)}async update(e,t){const o=await e.data,i=this.composeBlock({id:e.id,tool:e.name,data:Object.assign({},o,t),tunes:e.tunes}),n=this.getBlockIndex(e);return this._blocks.replace(n,i),this.blockDidMutated(xt,i,{index:n}),i}replace(e,t,o){const i=this.getBlockIndex(e);this.insert({tool:t,data:o,index:i,replace:!0})}paste(e,t,o=!1){const i=this.insert({tool:e,replace:o});try{window.requestIdleCallback(()=>{i.call(q.ON_PASTE,t)})}catch(n){L(`${e}: onPaste callback call is failed`,"error",n)}return i}insertDefaultBlockAtIndex(e,t=!1){const o=this.composeBlock({tool:this.config.defaultBlock});return this._blocks[e]=o,this.blockDidMutated(vt,o,{index:e}),t?this.currentBlockIndex=e:e<=this.currentBlockIndex&&this.currentBlockIndex++,o}insertAtEnd(){return this.currentBlockIndex=this.blocks.length-1,this.insert()}async mergeBlocks(e,t){const o=await t.data;X(o)||await e.mergeWith(o),this.removeBlock(t),this.currentBlockIndex=this._blocks.indexOf(e)}removeBlock(e,t=!0){return new Promise(o=>{const i=this._blocks.indexOf(e);if(!this.validateIndex(i))throw new Error("Can't find a Block to remove");e.destroy(),this._blocks.remove(i),this.blockDidMutated(kt,e,{index:i}),this.currentBlockIndex>=i&&this.currentBlockIndex--,this.blocks.length?i===0&&(this.currentBlockIndex=0):(this.currentBlockIndex=-1,t&&this.insert()),o()})}removeSelectedBlocks(){let e;for(let t=this.blocks.length-1;t>=0;t--)this.blocks[t].selected&&(this.removeBlock(this.blocks[t]),e=t);return e}removeAllBlocks(){for(let e=this.blocks.length-1;e>=0;e--)this._blocks.remove(e);this.currentBlockIndex=-1,this.insert(),this.currentBlock.firstInput.focus()}split(){const e=this.Editor.Caret.extractFragmentFromCaretPosition(),t=d.make("div");t.appendChild(e);const o={text:d.isEmpty(t)?"":t.innerHTML};return this.insert({data:o})}getBlockByIndex(e){return e===-1&&(e=this._blocks.length-1),this._blocks[e]}getBlockIndex(e){return this._blocks.indexOf(e)}getBlockById(e){return this._blocks.array.find(t=>t.id===e)}getBlock(e){d.isElement(e)||(e=e.parentNode);const t=this._blocks.nodes,o=e.closest(`.${F.CSS.wrapper}`),i=t.indexOf(o);if(i>=0)return this._blocks[i]}highlightCurrentNode(){this.clearFocused(),this.currentBlock.focused=!0}clearFocused(){this.blocks.forEach(e=>{e.focused=!1})}setCurrentBlockByChildNode(e){d.isElement(e)||(e=e.parentNode);const t=e.closest(`.${F.CSS.wrapper}`);if(!t)return;const o=t.closest(`.${this.Editor.UI.CSS.editorWrapper}`);if(o!=null&&o.isEqualNode(this.Editor.UI.nodes.wrapper))return this.currentBlockIndex=this._blocks.nodes.indexOf(t),this.currentBlock.updateCurrentInput(),this.currentBlock}getBlockByChildNode(e){d.isElement(e)||(e=e.parentNode);const t=e.closest(`.${F.CSS.wrapper}`);return this.blocks.find(o=>o.holder===t)}swap(e,t){this._blocks.swap(e,t),this.currentBlockIndex=t}move(e,t=this.currentBlockIndex){if(isNaN(e)||isNaN(t)){L("Warning during 'move' call: incorrect indices provided.","warn");return}if(!this.validateIndex(e)||!this.validateIndex(t)){L("Warning during 'move' call: indices cannot be lower than 0 or greater than the amount of blocks.","warn");return}this._blocks.move(e,t),this.currentBlockIndex=e,this.blockDidMutated($o,this.currentBlock,{fromIndex:t,toIndex:e})}async convert(e,t,o){if(!await e.save())throw new Error("Could not convert Block. Failed to extract original Block data.");const n=this.Editor.Tools.blockTools.get(t);if(!n)throw new Error(`Could not convert Block. Tool «${t}» not found.`);const r=await e.exportDataAsString(),a=Z(r,n.sanitizeConfig);let l=Kt(a,n.conversionConfig);o&&(l=Object.assign(l,o)),this.replace(e,n.name,l)}dropPointer(){this.currentBlockIndex=-1,this.clearFocused()}async clear(e=!1){const t=new Wo;this.blocks.forEach(o=>{t.add(async()=>{await this.removeBlock(o,!1)})}),await t.completed,this.dropPointer(),e&&this.insert(),this.Editor.UI.checkEmptiness()}async destroy(){await Promise.all(this.blocks.map(e=>e.destroy()))}bindBlockEvents(e){const{BlockEvents:t}=this.Editor;this.readOnlyMutableListeners.on(e.holder,"keydown",o=>{t.keydown(o)}),this.readOnlyMutableListeners.on(e.holder,"keyup",o=>{t.keyup(o)}),this.readOnlyMutableListeners.on(e.holder,"dragover",o=>{t.dragOver(o)}),this.readOnlyMutableListeners.on(e.holder,"dragleave",o=>{t.dragLeave(o)}),e.on("didMutated",o=>this.blockDidMutated(xt,o,{index:this.getBlockIndex(o)}))}disableModuleBindings(){this.readOnlyMutableListeners.clearAll()}enableModuleBindings(){this.readOnlyMutableListeners.on(document,"cut",e=>this.Editor.BlockEvents.handleCommandX(e)),this.blocks.forEach(e=>{this.bindBlockEvents(e)})}validateIndex(e){return!(e<0||e>=this._blocks.length)}blockDidMutated(e,t,o){const i=new CustomEvent(e,{detail:{target:new oe(t),...o}});return this.eventsDispatcher.emit(nt,{event:i}),t}}class Ko extends C{constructor(){super(...arguments),this.anyBlockSelectedCache=null,this.needToSelectAll=!1,this.nativeInputSelected=!1,this.readyToBlockSelection=!1}get sanitizerConfig(){return{p:{},h1:{},h2:{},h3:{},h4:{},h5:{},h6:{},ol:{},ul:{},li:{},br:!0,img:{src:!0,width:!0,height:!0},a:{href:!0},b:{},i:{},u:{}}}get allBlocksSelected(){const{BlockManager:e}=this.Editor;return e.blocks.every(t=>t.selected===!0)}set allBlocksSelected(e){const{BlockManager:t}=this.Editor;t.blocks.forEach(o=>{o.selected=e}),this.clearCache()}get anyBlockSelected(){const{BlockManager:e}=this.Editor;return this.anyBlockSelectedCache===null&&(this.anyBlockSelectedCache=e.blocks.some(t=>t.selected===!0)),this.anyBlockSelectedCache}get selectedBlocks(){return this.Editor.BlockManager.blocks.filter(e=>e.selected)}prepare(){this.selection=new m,ce.add({name:"CMD+A",handler:e=>{const{BlockManager:t,ReadOnly:o}=this.Editor;if(o.isEnabled){e.preventDefault(),this.selectAllBlocks();return}t.currentBlock&&this.handleCommandA(e)},on:this.Editor.UI.nodes.redactor})}toggleReadOnly(){m.get().removeAllRanges(),this.allBlocksSelected=!1}unSelectBlockByIndex(e){const{BlockManager:t}=this.Editor;let o;isNaN(e)?o=t.currentBlock:o=t.getBlockByIndex(e),o.selected=!1,this.clearCache()}clearSelection(e,t=!1){const{BlockManager:o,Caret:i,RectangleSelection:n}=this.Editor;this.needToSelectAll=!1,this.nativeInputSelected=!1,this.readyToBlockSelection=!1;const r=e&&e instanceof KeyboardEvent,a=r&&Je(e.keyCode);if(this.anyBlockSelected&&r&&a&&!m.isSelectionExists){const l=o.removeSelectedBlocks();o.insertDefaultBlockAtIndex(l,!0),i.setToBlock(o.currentBlock),ne(()=>{const c=e.key;i.insertContentAtCaretPosition(c.length>1?"":c)},20)()}if(this.Editor.CrossBlockSelection.clear(e),!this.anyBlockSelected||n.isRectActivated()){this.Editor.RectangleSelection.clearSelection();return}t&&this.selection.restore(),this.allBlocksSelected=!1}copySelectedBlocks(e){e.preventDefault();const t=d.make("div");this.selectedBlocks.forEach(n=>{const r=Z(n.holder.innerHTML,this.sanitizerConfig),a=d.make("p");a.innerHTML=r,t.appendChild(a)});const o=Array.from(t.childNodes).map(n=>n.textContent).join(` + +`),i=t.innerHTML;return e.clipboardData.setData("text/plain",o),e.clipboardData.setData("text/html",i),Promise.all(this.selectedBlocks.map(n=>n.save())).then(n=>{try{e.clipboardData.setData(this.Editor.Paste.MIME_TYPE,JSON.stringify(n))}catch{}})}selectBlockByIndex(e){const{BlockManager:t}=this.Editor;t.clearFocused();let o;isNaN(e)?o=t.currentBlock:o=t.getBlockByIndex(e),this.selection.save(),m.get().removeAllRanges(),o.selected=!0,this.clearCache(),this.Editor.InlineToolbar.close()}clearCache(){this.anyBlockSelectedCache=null}destroy(){ce.remove(this.Editor.UI.nodes.redactor,"CMD+A")}handleCommandA(e){if(this.Editor.RectangleSelection.clearSelection(),d.isNativeInput(e.target)&&!this.readyToBlockSelection){this.readyToBlockSelection=!0;return}const o=this.Editor.BlockManager.getBlock(e.target).inputs;if(o.length>1&&!this.readyToBlockSelection){this.readyToBlockSelection=!0;return}if(o.length===1&&!this.needToSelectAll){this.needToSelectAll=!0;return}this.needToSelectAll?(e.preventDefault(),this.selectAllBlocks(),this.needToSelectAll=!1,this.readyToBlockSelection=!1,this.Editor.ConversionToolbar.close()):this.readyToBlockSelection&&(e.preventDefault(),this.selectBlockByIndex(),this.needToSelectAll=!0)}selectAllBlocks(){this.selection.save(),m.get().removeAllRanges(),this.allBlocksSelected=!0,this.Editor.InlineToolbar.close()}}class Ee extends C{get positions(){return{START:"start",END:"end",DEFAULT:"default"}}static get CSS(){return{shadowCaret:"cdx-shadow-caret"}}get isAtStart(){const e=m.get(),t=d.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);let o=e.focusNode;if(d.isNativeInput(t))return t.selectionEnd===0;if(!e.anchorNode)return!1;let i=o.textContent.search(/\S/);i===-1&&(i=0);let n=e.focusOffset;return o.nodeType!==Node.TEXT_NODE&&o.childNodes.length&&(o.childNodes[n]?(o=o.childNodes[n],n=0):(o=o.childNodes[n-1],n=o.textContent.length)),(d.isLineBreakTag(t)||d.isEmpty(t))&&this.getHigherLevelSiblings(o,"left").every(l=>{const c=d.isLineBreakTag(l),u=l.children.length===1&&d.isLineBreakTag(l.children[0]),h=c||u;return d.isEmpty(l)&&!h})&&n===i?!0:t===null||o===t&&n<=i}get isAtEnd(){const e=m.get();let t=e.focusNode;const o=d.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput,!0);if(d.isNativeInput(o))return o.selectionEnd===o.value.length;if(!e.focusNode)return!1;let i=e.focusOffset;if(t.nodeType!==Node.TEXT_NODE&&t.childNodes.length&&(t.childNodes[i-1]?(t=t.childNodes[i-1],i=t.textContent.length):(t=t.childNodes[0],i=0)),d.isLineBreakTag(o)||d.isEmpty(o)){const r=this.getHigherLevelSiblings(t,"right");if(r.every((l,c)=>c===r.length-1&&d.isLineBreakTag(l)||d.isEmpty(l)&&!d.isLineBreakTag(l))&&i===t.textContent.length)return!0}const n=o.textContent.replace(/\s+$/,"");return t===o&&i>=n.length}setToBlock(e,t=this.positions.DEFAULT,o=0){const{BlockManager:i}=this.Editor;let n;switch(t){case this.positions.START:n=e.firstInput;break;case this.positions.END:n=e.lastInput;break;default:n=e.currentInput}if(!n)return;const r=d.getDeepestNode(n,t===this.positions.END),a=d.getContentLength(r);switch(!0){case t===this.positions.START:o=0;break;case t===this.positions.END:case o>a:o=a;break}ne(()=>{this.set(r,o)},20)(),i.setCurrentBlockByChildNode(e.holder),i.currentBlock.currentInput=n}setToInput(e,t=this.positions.DEFAULT,o=0){const{currentBlock:i}=this.Editor.BlockManager,n=d.getDeepestNode(e);switch(t){case this.positions.START:this.set(n,0);break;case this.positions.END:this.set(n,d.getContentLength(n));break;default:o&&this.set(n,o)}i.currentInput=e}set(e,t=0){const{top:o,bottom:i}=m.setCursor(e,t),{innerHeight:n}=window;o<0&&window.scrollBy(0,o),i>n&&window.scrollBy(0,i-n)}setToTheLastBlock(){const e=this.Editor.BlockManager.lastBlock;if(e)if(e.tool.isDefault&&e.isEmpty)this.setToBlock(e);else{const t=this.Editor.BlockManager.insertAtEnd();this.setToBlock(t)}}extractFragmentFromCaretPosition(){const e=m.get();if(e.rangeCount){const t=e.getRangeAt(0),o=this.Editor.BlockManager.currentBlock.currentInput;if(t.deleteContents(),o)if(d.isNativeInput(o)){const i=o,n=document.createDocumentFragment(),r=i.value.substring(0,i.selectionStart),a=i.value.substring(i.selectionStart);return n.textContent=a,i.value=r,n}else{const i=t.cloneRange();return i.selectNodeContents(o),i.setStart(t.endContainer,t.endOffset),i.extractContents()}}}navigateNext(){const{BlockManager:e}=this.Editor,{currentBlock:t,nextContentfulBlock:o}=e,{nextInput:i}=t,n=this.isAtEnd;let r=o;if(!r&&!i){if(t.tool.isDefault||!n)return!1;r=e.insertAtEnd()}return n?(i?this.setToInput(i,this.positions.START):this.setToBlock(r,this.positions.START),!0):!1}navigatePrevious(){const{currentBlock:e,previousContentfulBlock:t}=this.Editor.BlockManager;if(!e)return!1;const{previousInput:o}=e;return!t&&!o?!1:this.isAtStart?(o?this.setToInput(o,this.positions.END):this.setToBlock(t,this.positions.END),!0):!1}createShadow(e){const t=document.createElement("span");t.classList.add(Ee.CSS.shadowCaret),e.insertAdjacentElement("beforeend",t)}restoreCaret(e){const t=e.querySelector(`.${Ee.CSS.shadowCaret}`);if(!t)return;new m().expandToTag(t);const i=document.createRange();i.selectNode(t),i.extractContents()}insertContentAtCaretPosition(e){const t=document.createDocumentFragment(),o=document.createElement("div"),i=m.get(),n=m.range;o.innerHTML=e,Array.from(o.childNodes).forEach(c=>t.appendChild(c)),t.childNodes.length===0&&t.appendChild(new Text);const r=t.lastChild;n.deleteContents(),n.insertNode(t);const a=document.createRange(),l=r.nodeType===Node.TEXT_NODE?r:r.firstChild;l!==null&&l.textContent!==null&&a.setStart(l,l.textContent.length),i.removeAllRanges(),i.addRange(a)}getHigherLevelSiblings(e,t){let o=e;const i=[];for(;o.parentNode&&o.parentNode.contentEditable!=="true";)o=o.parentNode;const n=t==="left"?"previousSibling":"nextSibling";for(;o[n];)o=o[n],i.push(o);return i}}class Xo extends C{constructor(){super(...arguments),this.onMouseUp=()=>{this.listeners.off(document,"mouseover",this.onMouseOver),this.listeners.off(document,"mouseup",this.onMouseUp)},this.onMouseOver=e=>{const{BlockManager:t,BlockSelection:o}=this.Editor,i=t.getBlockByChildNode(e.relatedTarget)||this.lastSelectedBlock,n=t.getBlockByChildNode(e.target);if(!(!i||!n)&&n!==i){if(i===this.firstSelectedBlock){m.get().removeAllRanges(),i.selected=!0,n.selected=!0,o.clearCache();return}if(n===this.firstSelectedBlock){i.selected=!1,n.selected=!1,o.clearCache();return}this.Editor.InlineToolbar.close(),this.toggleBlocksSelectedState(i,n),this.lastSelectedBlock=n}}}async prepare(){this.listeners.on(document,"mousedown",e=>{this.enableCrossBlockSelection(e)})}watchSelection(e){if(e.button!==Ot.LEFT)return;const{BlockManager:t}=this.Editor;this.firstSelectedBlock=t.getBlock(e.target),this.lastSelectedBlock=this.firstSelectedBlock,this.listeners.on(document,"mouseover",this.onMouseOver),this.listeners.on(document,"mouseup",this.onMouseUp)}get isCrossBlockSelectionStarted(){return!!this.firstSelectedBlock&&!!this.lastSelectedBlock}toggleBlockSelectedState(e=!0){const{BlockManager:t,BlockSelection:o}=this.Editor;this.lastSelectedBlock||(this.lastSelectedBlock=this.firstSelectedBlock=t.currentBlock),this.firstSelectedBlock===this.lastSelectedBlock&&(this.firstSelectedBlock.selected=!0,o.clearCache(),m.get().removeAllRanges());const i=t.blocks.indexOf(this.lastSelectedBlock)+(e?1:-1),n=t.blocks[i];n&&(this.lastSelectedBlock.selected!==n.selected?(n.selected=!0,o.clearCache()):(this.lastSelectedBlock.selected=!1,o.clearCache()),this.lastSelectedBlock=n,this.Editor.InlineToolbar.close(),n.holder.scrollIntoView({block:"nearest"}))}clear(e){const{BlockManager:t,BlockSelection:o,Caret:i}=this.Editor,n=t.blocks.indexOf(this.firstSelectedBlock),r=t.blocks.indexOf(this.lastSelectedBlock);if(o.anyBlockSelected&&n>-1&&r>-1)if(e&&e instanceof KeyboardEvent)switch(e.keyCode){case B.DOWN:case B.RIGHT:i.setToBlock(t.blocks[Math.max(n,r)],i.positions.END);break;case B.UP:case B.LEFT:i.setToBlock(t.blocks[Math.min(n,r)],i.positions.START);break;default:i.setToBlock(t.blocks[Math.max(n,r)],i.positions.END)}else i.setToBlock(t.blocks[Math.max(n,r)],i.positions.END);this.firstSelectedBlock=this.lastSelectedBlock=null}enableCrossBlockSelection(e){const{UI:t}=this.Editor;m.isCollapsed||this.Editor.BlockSelection.clearSelection(e),t.nodes.redactor.contains(e.target)?this.watchSelection(e):this.Editor.BlockSelection.clearSelection(e)}toggleBlocksSelectedState(e,t){const{BlockManager:o,BlockSelection:i}=this.Editor,n=o.blocks.indexOf(e),r=o.blocks.indexOf(t),a=e.selected!==t.selected;for(let l=Math.min(n,r);l<=Math.max(n,r);l++){const c=o.blocks[l];c!==this.firstSelectedBlock&&c!==(a?e:t)&&(o.blocks[l].selected=!o.blocks[l].selected,i.clearCache())}}}class Vo extends C{constructor(){super(...arguments),this.isStartedAtEditor=!1}toggleReadOnly(e){e?this.disableModuleBindings():this.enableModuleBindings()}enableModuleBindings(){const{UI:e}=this.Editor;this.readOnlyMutableListeners.on(e.nodes.holder,"drop",async t=>{await this.processDrop(t)},!0),this.readOnlyMutableListeners.on(e.nodes.holder,"dragstart",()=>{this.processDragStart()}),this.readOnlyMutableListeners.on(e.nodes.holder,"dragover",t=>{this.processDragOver(t)},!0)}disableModuleBindings(){this.readOnlyMutableListeners.clearAll()}async processDrop(e){const{BlockManager:t,Caret:o,Paste:i}=this.Editor;e.preventDefault(),t.blocks.forEach(r=>{r.dropTarget=!1}),m.isAtEditor&&!m.isCollapsed&&this.isStartedAtEditor&&document.execCommand("delete"),this.isStartedAtEditor=!1;const n=t.setCurrentBlockByChildNode(e.target);if(n)this.Editor.Caret.setToBlock(n,o.positions.END);else{const r=t.setCurrentBlockByChildNode(t.lastBlock.holder);this.Editor.Caret.setToBlock(r,o.positions.END)}await i.processDataTransfer(e.dataTransfer,!0)}processDragStart(){m.isAtEditor&&!m.isCollapsed&&(this.isStartedAtEditor=!0),this.Editor.InlineToolbar.close()}processDragOver(e){e.preventDefault()}}class qo extends C{constructor({config:e,eventsDispatcher:t}){super({config:e,eventsDispatcher:t}),this.disabled=!1,this.batchingTimeout=null,this.batchingOnChangeQueue=new Map,this.batchTime=400,this.mutationObserver=new MutationObserver(o=>{this.redactorChanged(o)}),this.eventsDispatcher.on(nt,o=>{this.particularBlockChanged(o.event)}),this.eventsDispatcher.on(st,()=>{this.disable()}),this.eventsDispatcher.on(rt,()=>{this.enable()})}enable(){this.mutationObserver.observe(this.Editor.UI.nodes.redactor,{childList:!0,subtree:!0,characterData:!0,attributes:!0}),this.disabled=!1}disable(){this.mutationObserver.disconnect(),this.disabled=!0}particularBlockChanged(e){this.disabled||!D(this.config.onChange)||(this.batchingOnChangeQueue.set(`block:${e.detail.target.id}:event:${e.type}`,e),this.batchingTimeout&&clearTimeout(this.batchingTimeout),this.batchingTimeout=setTimeout(()=>{let t;this.batchingOnChangeQueue.size===1?t=this.batchingOnChangeQueue.values().next().value:t=Array.from(this.batchingOnChangeQueue.values()),this.config.onChange&&this.config.onChange(this.Editor.API.methods,t),this.batchingOnChangeQueue.clear()},this.batchTime))}redactorChanged(e){this.eventsDispatcher.emit(Le,{mutations:e})}}const wt=class extends C{constructor(){super(...arguments),this.MIME_TYPE="application/x-editor-js",this.toolsTags={},this.tagsByTool={},this.toolsPatterns=[],this.toolsFiles={},this.exceptionList=[],this.processTool=s=>{try{const e=s.create({},{},!1);if(s.pasteConfig===!1){this.exceptionList.push(s.name);return}if(!D(e.onPaste))return;this.getTagsConfig(s),this.getFilesConfig(s),this.getPatternsConfig(s)}catch(e){L(`Paste handling for «${s.name}» Tool hasn't been set up because of the error`,"warn",e)}},this.handlePasteEvent=async s=>{const{BlockManager:e,Toolbar:t}=this.Editor,o=e.setCurrentBlockByChildNode(s.target);!o||this.isNativeBehaviour(s.target)&&!s.clipboardData.types.includes("Files")||o&&this.exceptionList.includes(o.name)||(s.preventDefault(),this.processDataTransfer(s.clipboardData),e.clearFocused(),t.close())}}async prepare(){this.processTools()}toggleReadOnly(s){s?this.unsetCallback():this.setCallback()}async processDataTransfer(s,e=!1){const{Tools:t}=this.Editor,o=s.types;if((o.includes?o.includes("Files"):o.contains("Files"))&&!X(this.toolsFiles)){await this.processFiles(s.files);return}const n=s.getData(this.MIME_TYPE),r=s.getData("text/plain");let a=s.getData("text/html");if(n)try{this.insertEditorJSData(JSON.parse(n));return}catch{}e&&r.trim()&&a.trim()&&(a="

"+(a.trim()?a:r)+"

");const l=Object.keys(this.toolsTags).reduce((h,f)=>(h[f.toLowerCase()]=this.toolsTags[f].sanitizationConfig??{},h),{}),c=Object.assign({},l,t.getAllInlineToolsSanitizeConfig(),{br:{}}),u=Z(a,c);!u.trim()||u.trim()===r||!d.isHTMLString(u)?await this.processText(r):await this.processText(u,!0)}async processText(s,e=!1){const{Caret:t,BlockManager:o}=this.Editor,i=e?this.processHTML(s):this.processPlain(s);if(!i.length)return;if(i.length===1){i[0].isBlock?this.processSingleBlock(i.pop()):this.processInlinePaste(i.pop());return}const r=o.currentBlock&&o.currentBlock.tool.isDefault&&o.currentBlock.isEmpty;i.map(async(a,l)=>this.insertBlock(a,l===0&&r)),o.currentBlock&&t.setToBlock(o.currentBlock,t.positions.END)}setCallback(){this.listeners.on(this.Editor.UI.nodes.holder,"paste",this.handlePasteEvent)}unsetCallback(){this.listeners.off(this.Editor.UI.nodes.holder,"paste",this.handlePasteEvent)}processTools(){const s=this.Editor.Tools.blockTools;Array.from(s.values()).forEach(this.processTool)}collectTagNames(s){return Q(s)?[s]:H(s)?Object.keys(s):[]}getTagsConfig(s){if(s.pasteConfig===!1)return;const e=s.pasteConfig.tags||[],t=[];e.forEach(o=>{const i=this.collectTagNames(o);t.push(...i),i.forEach(n=>{if(Object.prototype.hasOwnProperty.call(this.toolsTags,n)){L(`Paste handler for «${s.name}» Tool on «${n}» tag is skipped because it is already used by «${this.toolsTags[n].tool.name}» Tool.`,"warn");return}const r=H(o)?o[n]:null;this.toolsTags[n.toUpperCase()]={tool:s,sanitizationConfig:r}})}),this.tagsByTool[s.name]=t.map(o=>o.toUpperCase())}getFilesConfig(s){if(s.pasteConfig===!1)return;const{files:e={}}=s.pasteConfig;let{extensions:t,mimeTypes:o}=e;!t&&!o||(t&&!Array.isArray(t)&&(L(`«extensions» property of the onDrop config for «${s.name}» Tool should be an array`),t=[]),o&&!Array.isArray(o)&&(L(`«mimeTypes» property of the onDrop config for «${s.name}» Tool should be an array`),o=[]),o&&(o=o.filter(i=>Dt(i)?!0:(L(`MIME type value «${i}» for the «${s.name}» Tool is not a valid MIME type`,"warn"),!1))),this.toolsFiles[s.name]={extensions:t||[],mimeTypes:o||[]})}getPatternsConfig(s){s.pasteConfig===!1||!s.pasteConfig.patterns||X(s.pasteConfig.patterns)||Object.entries(s.pasteConfig.patterns).forEach(([e,t])=>{t instanceof RegExp||L(`Pattern ${t} for «${s.name}» Tool is skipped because it should be a Regexp instance.`,"warn"),this.toolsPatterns.push({key:e,pattern:t,tool:s})})}isNativeBehaviour(s){return d.isNativeInput(s)}async processFiles(s){const{BlockManager:e}=this.Editor;let t;t=await Promise.all(Array.from(s).map(n=>this.processFile(n))),t=t.filter(n=>!!n);const i=e.currentBlock.tool.isDefault&&e.currentBlock.isEmpty;t.forEach((n,r)=>{e.paste(n.type,n.event,r===0&&i)})}async processFile(s){const e=Rt(s),t=Object.entries(this.toolsFiles).find(([n,{mimeTypes:r,extensions:a}])=>{const[l,c]=s.type.split("/"),u=a.find(f=>f.toLowerCase()===e.toLowerCase()),h=r.find(f=>{const[k,p]=f.split("/");return k===l&&(p===c||p==="*")});return!!u||!!h});if(!t)return;const[o]=t;return{event:this.composePasteEvent("file",{file:s}),type:o}}processHTML(s){const{Tools:e}=this.Editor,t=d.make("DIV");return t.innerHTML=s,this.getNodes(t).map(i=>{let n,r=e.defaultTool,a=!1;switch(i.nodeType){case Node.DOCUMENT_FRAGMENT_NODE:n=d.make("div"),n.appendChild(i);break;case Node.ELEMENT_NODE:n=i,a=!0,this.toolsTags[n.tagName]&&(r=this.toolsTags[n.tagName].tool);break}const{tags:l}=r.pasteConfig||{tags:[]},c=l.reduce((f,k)=>(this.collectTagNames(k).forEach(v=>{const A=H(k)?k[v]:null;f[v.toLowerCase()]=A||{}}),f),{}),u=Object.assign({},c,r.baseSanitizeConfig);if(n.tagName.toLowerCase()==="table"){const f=Z(n.outerHTML,u);n=d.make("div",void 0,{innerHTML:f}).firstChild}else n.innerHTML=Z(n.innerHTML,u);const h=this.composePasteEvent("tag",{data:n});return{content:n,isBlock:a,tool:r.name,event:h}}).filter(i=>{const n=d.isEmpty(i.content),r=d.isSingleTag(i.content);return!n||r})}processPlain(s){const{defaultBlock:e}=this.config;if(!s)return[];const t=e;return s.split(/\r?\n/).filter(o=>o.trim()).map(o=>{const i=d.make("div");i.textContent=o;const n=this.composePasteEvent("tag",{data:i});return{content:i,tool:t,isBlock:!1,event:n}})}async processSingleBlock(s){const{Caret:e,BlockManager:t}=this.Editor,{currentBlock:o}=t;if(!o||s.tool!==o.name||!d.containsOnlyInlineElements(s.content.innerHTML)){this.insertBlock(s,(o==null?void 0:o.tool.isDefault)&&o.isEmpty);return}e.insertContentAtCaretPosition(s.content.innerHTML)}async processInlinePaste(s){const{BlockManager:e,Caret:t}=this.Editor,{content:o}=s;if(e.currentBlock&&e.currentBlock.tool.isDefault&&o.textContent.length{const i=o.pattern.exec(s);return i?s===i.shift():!1});return e?{event:this.composePasteEvent("pattern",{key:e.key,data:s}),tool:e.tool.name}:void 0}insertBlock(s,e=!1){const{BlockManager:t,Caret:o}=this.Editor,{currentBlock:i}=t;let n;if(e&&i&&i.isEmpty){n=t.paste(s.tool,s.event,!0),o.setToBlock(n,o.positions.END);return}n=t.paste(s.tool,s.event),o.setToBlock(n,o.positions.END)}insertEditorJSData(s){const{BlockManager:e,Caret:t,Tools:o}=this.Editor;lt(s,n=>o.blockTools.get(n).sanitizeConfig).forEach(({tool:n,data:r},a)=>{let l=!1;a===0&&(l=e.currentBlock&&e.currentBlock.tool.isDefault&&e.currentBlock.isEmpty);const c=e.insert({tool:n,data:r,replace:l});t.setToBlock(c,t.positions.END)})}processElementNode(s,e,t){const o=Object.keys(this.toolsTags),i=s,{tool:n}=this.toolsTags[i.tagName]||{},r=this.tagsByTool[n==null?void 0:n.name]||[],a=o.includes(i.tagName),l=d.blockElements.includes(i.tagName.toLowerCase()),c=Array.from(i.children).some(({tagName:h})=>o.includes(h)&&!r.includes(h)),u=Array.from(i.children).some(({tagName:h})=>d.blockElements.includes(h.toLowerCase()));if(!l&&!a&&!c)return t.appendChild(i),[...e,t];if(a&&!c||l&&!u&&!c)return[...e,t,i]}getNodes(s){const e=Array.from(s.childNodes);let t;const o=(i,n)=>{if(d.isEmpty(n)&&!d.isSingleTag(n))return i;const r=i[i.length-1];let a=new DocumentFragment;switch(r&&d.isFragment(r)&&(a=i.pop()),n.nodeType){case Node.ELEMENT_NODE:if(t=this.processElementNode(n,i,a),t)return t;break;case Node.TEXT_NODE:return a.appendChild(n),[...i,a];default:return[...i,a]}return[...i,...Array.from(n.childNodes).reduce(o,[])]};return e.reduce(o,[])}composePasteEvent(s,e){return new CustomEvent(s,{detail:e})}};let yt=wt;yt.PATTERN_PROCESSING_MAX_LENGTH=450;class Zo extends C{constructor(){super(...arguments),this.toolsDontSupportReadOnly=[],this.readOnlyEnabled=!1}get isEnabled(){return this.readOnlyEnabled}async prepare(){const{Tools:e}=this.Editor,{blockTools:t}=e,o=[];Array.from(t.entries()).forEach(([i,n])=>{n.isReadOnlySupported||o.push(i)}),this.toolsDontSupportReadOnly=o,this.config.readOnly&&o.length>0&&this.throwCriticalError(),this.toggle(this.config.readOnly)}async toggle(e=!this.readOnlyEnabled){e&&this.toolsDontSupportReadOnly.length>0&&this.throwCriticalError();const t=this.readOnlyEnabled;this.readOnlyEnabled=e;for(const i in this.Editor)this.Editor[i].toggleReadOnly&&this.Editor[i].toggleReadOnly(e);if(t===e)return this.readOnlyEnabled;const o=await this.Editor.Saver.save();return await this.Editor.BlockManager.clear(),await this.Editor.Renderer.render(o.blocks),this.readOnlyEnabled}throwCriticalError(){throw new it(`To enable read-only mode all connected tools should support it. Tools ${this.toolsDontSupportReadOnly.join(", ")} don't support read-only mode.`)}}class me extends C{constructor(){super(...arguments),this.isRectSelectionActivated=!1,this.SCROLL_SPEED=3,this.HEIGHT_OF_SCROLL_ZONE=40,this.BOTTOM_SCROLL_ZONE=1,this.TOP_SCROLL_ZONE=2,this.MAIN_MOUSE_BUTTON=0,this.mousedown=!1,this.isScrolling=!1,this.inScrollZone=null,this.startX=0,this.startY=0,this.mouseX=0,this.mouseY=0,this.stackOfSelected=[],this.listenerIds=[]}static get CSS(){return{overlay:"codex-editor-overlay",overlayContainer:"codex-editor-overlay__container",rect:"codex-editor-overlay__rectangle",topScrollZone:"codex-editor-overlay__scroll-zone--top",bottomScrollZone:"codex-editor-overlay__scroll-zone--bottom"}}prepare(){this.enableModuleBindings()}startSelection(e,t){const o=document.elementFromPoint(e-window.pageXOffset,t-window.pageYOffset);o.closest(`.${this.Editor.Toolbar.CSS.toolbar}`)||(this.Editor.BlockSelection.allBlocksSelected=!1,this.clearSelection(),this.stackOfSelected=[]);const n=[`.${F.CSS.content}`,`.${this.Editor.Toolbar.CSS.toolbar}`,`.${this.Editor.InlineToolbar.CSS.inlineToolbar}`],r=o.closest("."+this.Editor.UI.CSS.editorWrapper),a=n.some(l=>!!o.closest(l));!r||a||(this.mousedown=!0,this.startX=e,this.startY=t)}endSelection(){this.mousedown=!1,this.startX=0,this.startY=0,this.overlayRectangle.style.display="none"}isRectActivated(){return this.isRectSelectionActivated}clearSelection(){this.isRectSelectionActivated=!1}enableModuleBindings(){const{container:e}=this.genHTML();this.listeners.on(e,"mousedown",t=>{this.processMouseDown(t)},!1),this.listeners.on(document.body,"mousemove",Te(t=>{this.processMouseMove(t)},10),{passive:!0}),this.listeners.on(document.body,"mouseleave",()=>{this.processMouseLeave()}),this.listeners.on(window,"scroll",Te(t=>{this.processScroll(t)},10),{passive:!0}),this.listeners.on(document.body,"mouseup",()=>{this.processMouseUp()},!1)}processMouseDown(e){if(e.button!==this.MAIN_MOUSE_BUTTON)return;e.target.closest(d.allInputsSelector)!==null||this.startSelection(e.pageX,e.pageY)}processMouseMove(e){this.changingRectangle(e),this.scrollByZones(e.clientY)}processMouseLeave(){this.clearSelection(),this.endSelection()}processScroll(e){this.changingRectangle(e)}processMouseUp(){this.clearSelection(),this.endSelection()}scrollByZones(e){if(this.inScrollZone=null,e<=this.HEIGHT_OF_SCROLL_ZONE&&(this.inScrollZone=this.TOP_SCROLL_ZONE),document.documentElement.clientHeight-e<=this.HEIGHT_OF_SCROLL_ZONE&&(this.inScrollZone=this.BOTTOM_SCROLL_ZONE),!this.inScrollZone){this.isScrolling=!1;return}this.isScrolling||(this.scrollVertical(this.inScrollZone===this.TOP_SCROLL_ZONE?-this.SCROLL_SPEED:this.SCROLL_SPEED),this.isScrolling=!0)}genHTML(){const{UI:e}=this.Editor,t=e.nodes.holder.querySelector("."+e.CSS.editorWrapper),o=d.make("div",me.CSS.overlay,{}),i=d.make("div",me.CSS.overlayContainer,{}),n=d.make("div",me.CSS.rect,{});return i.appendChild(n),o.appendChild(i),t.appendChild(o),this.overlayRectangle=n,{container:t,overlay:o}}scrollVertical(e){if(!(this.inScrollZone&&this.mousedown))return;const t=window.pageYOffset;window.scrollBy(0,e),this.mouseY+=window.pageYOffset-t,setTimeout(()=>{this.scrollVertical(e)},0)}changingRectangle(e){if(!this.mousedown)return;e.pageY!==void 0&&(this.mouseX=e.pageX,this.mouseY=e.pageY);const{rightPos:t,leftPos:o,index:i}=this.genInfoForMouseSelection(),n=this.startX>t&&this.mouseX>t,r=this.startX=this.startY?(this.overlayRectangle.style.top=`${this.startY-window.pageYOffset}px`,this.overlayRectangle.style.bottom=`calc(100% - ${this.mouseY-window.pageYOffset}px`):(this.overlayRectangle.style.bottom=`calc(100% - ${this.startY-window.pageYOffset}px`,this.overlayRectangle.style.top=`${this.mouseY-window.pageYOffset}px`),this.mouseX>=this.startX?(this.overlayRectangle.style.left=`${this.startX-window.pageXOffset}px`,this.overlayRectangle.style.right=`calc(100% - ${this.mouseX-window.pageXOffset}px`):(this.overlayRectangle.style.right=`calc(100% - ${this.startX-window.pageXOffset}px`,this.overlayRectangle.style.left=`${this.mouseX-window.pageXOffset}px`)}genInfoForMouseSelection(){const t=document.body.offsetWidth/2,o=this.mouseY-window.pageYOffset,i=document.elementFromPoint(t,o),n=this.Editor.BlockManager.getBlockByChildNode(i);let r;n!==void 0&&(r=this.Editor.BlockManager.blocks.findIndex(h=>h.holder===n.holder));const a=this.Editor.BlockManager.lastBlock.holder.querySelector("."+F.CSS.content),l=Number.parseInt(window.getComputedStyle(a).width,10)/2,c=t-l,u=t+l;return{index:r,leftPos:c,rightPos:u}}addBlockInSelection(e){this.rectCrossesBlocks&&this.Editor.BlockSelection.selectBlockByIndex(e),this.stackOfSelected.push(e)}trySelectNextBlock(e){const t=this.stackOfSelected[this.stackOfSelected.length-1]===e,o=this.stackOfSelected.length,i=1,n=-1,r=0;if(t)return;const a=this.stackOfSelected[o-1]-this.stackOfSelected[o-2]>0;let l=r;o>1&&(l=a?i:n);const c=e>this.stackOfSelected[o-1]&&l===i,u=ethis.stackOfSelected[o-1]||this.stackOfSelected[o-1]===void 0)){let v=this.stackOfSelected[o-1]+1||e;for(v;v<=e;v++)this.addBlockInSelection(v);return}if(!f&&e=e;v--)this.addBlockInSelection(v);return}if(!f)return;let k=o-1,p;for(e>this.stackOfSelected[o-1]?p=()=>e>this.stackOfSelected[k]:p=()=>e{const{Tools:o,BlockManager:i}=this.Editor,n=e.map(({type:r,data:a,tunes:l,id:c})=>{o.available.has(r)===!1&&(K(`Tool «${r}» is not found. Check 'tools' property at the Editor.js config.`,"warn"),a=this.composeStubDataForTool(r,a,c),r=o.stubTool);let u;try{u=i.composeBlock({id:c,tool:r,data:a,tunes:l})}catch(h){L(`Block «${r}» skipped because of plugins error`,"error",{data:a,error:h}),a=this.composeStubDataForTool(r,a,c),r=o.stubTool,u=i.composeBlock({id:c,tool:r,data:a,tunes:l})}return u});i.insertMany(n),window.requestIdleCallback(()=>{t()},{timeout:2e3})})}composeStubDataForTool(e,t,o){const{Tools:i}=this.Editor;let n=e;if(i.unavailable.has(e)){const r=i.unavailable.get(e).toolbox;r!==void 0&&r[0].title!==void 0&&(n=r[0].title)}return{savedData:{id:o,type:e,data:t},title:n}}}class Jo extends C{async save(){const{BlockManager:e,Tools:t}=this.Editor,o=e.blocks,i=[];try{o.forEach(a=>{i.push(this.getSavedData(a))});const n=await Promise.all(i),r=await lt(n,a=>t.blockTools.get(a).sanitizeConfig);return this.makeOutput(r)}catch(n){K("Saving failed due to the Error %o","error",n)}}async getSavedData(e){const t=await e.save(),o=t&&await e.validate(t.data);return{...t,isValid:o}}makeOutput(e){const t=[];return e.forEach(({id:o,tool:i,data:n,tunes:r,isValid:a})=>{if(!a){L(`Block «${i}» skipped because saved data is invalid`);return}if(i===this.Editor.Tools.stubTool){t.push(n);return}const l={id:o,type:i,data:n,...!X(r)&&{tunes:r}};t.push(l)}),{time:+new Date,blocks:t,version:"2.28.2"}}}var $e={},Qo={get exports(){return $e},set exports(s){$e=s}};(function(s,e){(function(t,o){s.exports=o()})(window,function(){return function(t){var o={};function i(n){if(o[n])return o[n].exports;var r=o[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=o,i.d=function(n,r,a){i.o(n,r)||Object.defineProperty(n,r,{enumerable:!0,get:a})},i.r=function(n){typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},i.t=function(n,r){if(1&r&&(n=i(n)),8&r||4&r&&typeof n=="object"&&n&&n.__esModule)return n;var a=Object.create(null);if(i.r(a),Object.defineProperty(a,"default",{enumerable:!0,value:n}),2&r&&typeof n!="string")for(var l in n)i.d(a,l,function(c){return n[c]}.bind(null,l));return a},i.n=function(n){var r=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(r,"a",r),r},i.o=function(n,r){return Object.prototype.hasOwnProperty.call(n,r)},i.p="/",i(i.s=4)}([function(t,o,i){var n=i(1),r=i(2);typeof(r=r.__esModule?r.default:r)=="string"&&(r=[[t.i,r,""]]);var a={insert:"head",singleton:!1};n(r,a),t.exports=r.locals||{}},function(t,o,i){var n,r=function(){return n===void 0&&(n=!!(window&&document&&document.all&&!window.atob)),n},a=function(){var y={};return function(x){if(y[x]===void 0){var w=document.querySelector(x);if(window.HTMLIFrameElement&&w instanceof window.HTMLIFrameElement)try{w=w.contentDocument.head}catch{w=null}y[x]=w}return y[x]}}(),l=[];function c(y){for(var x=-1,w=0;w',title:"Text"}}}]),l}()}]).default})})(Qo);const ei=J($e);class We{constructor(){this.commandName="bold",this.CSS={button:"ce-inline-tool",buttonActive:"ce-inline-tool--active",buttonModifier:"ce-inline-tool--bold"},this.nodes={button:void 0}}static get sanitize(){return{b:{}}}render(){return this.nodes.button=document.createElement("button"),this.nodes.button.type="button",this.nodes.button.classList.add(this.CSS.button,this.CSS.buttonModifier),this.nodes.button.innerHTML=wo,this.nodes.button}surround(){document.execCommand(this.commandName)}checkState(){const e=document.queryCommandState(this.commandName);return this.nodes.button.classList.toggle(this.CSS.buttonActive,e),e}get shortcut(){return"CMD+B"}}We.isInline=!0,We.title="Bold";class Ye{constructor(){this.commandName="italic",this.CSS={button:"ce-inline-tool",buttonActive:"ce-inline-tool--active",buttonModifier:"ce-inline-tool--italic"},this.nodes={button:null}}static get sanitize(){return{i:{}}}render(){return this.nodes.button=document.createElement("button"),this.nodes.button.type="button",this.nodes.button.classList.add(this.CSS.button,this.CSS.buttonModifier),this.nodes.button.innerHTML=To,this.nodes.button}surround(){document.execCommand(this.commandName)}checkState(){const e=document.queryCommandState(this.commandName);return this.nodes.button.classList.toggle(this.CSS.buttonActive,e),e}get shortcut(){return"CMD+I"}}Ye.isInline=!0,Ye.title="Italic";class Ke{constructor({api:e}){this.commandLink="createLink",this.commandUnlink="unlink",this.ENTER_KEY=13,this.CSS={button:"ce-inline-tool",buttonActive:"ce-inline-tool--active",buttonModifier:"ce-inline-tool--link",buttonUnlink:"ce-inline-tool--unlink",input:"ce-inline-tool-input",inputShowed:"ce-inline-tool-input--showed"},this.nodes={button:null,input:null},this.inputOpened=!1,this.toolbar=e.toolbar,this.inlineToolbar=e.inlineToolbar,this.notifier=e.notifier,this.i18n=e.i18n,this.selection=new m}static get sanitize(){return{a:{href:!0,target:"_blank",rel:"nofollow"}}}render(){return this.nodes.button=document.createElement("button"),this.nodes.button.type="button",this.nodes.button.classList.add(this.CSS.button,this.CSS.buttonModifier),this.nodes.button.innerHTML=ht,this.nodes.button}renderActions(){return this.nodes.input=document.createElement("input"),this.nodes.input.placeholder=this.i18n.t("Add a link"),this.nodes.input.classList.add(this.CSS.input),this.nodes.input.addEventListener("keydown",e=>{e.keyCode===this.ENTER_KEY&&this.enterPressed(e)}),this.nodes.input}surround(e){if(e){this.inputOpened?(this.selection.restore(),this.selection.removeFakeBackground()):(this.selection.setFakeBackground(),this.selection.save());const t=this.selection.findParentTag("A");if(t){this.selection.expandToTag(t),this.unlink(),this.closeActions(),this.checkState(),this.toolbar.close();return}}this.toggleActions()}checkState(){const e=this.selection.findParentTag("A");if(e){this.nodes.button.innerHTML=Mo,this.nodes.button.classList.add(this.CSS.buttonUnlink),this.nodes.button.classList.add(this.CSS.buttonActive),this.openActions();const t=e.getAttribute("href");this.nodes.input.value=t!=="null"?t:"",this.selection.save()}else this.nodes.button.innerHTML=ht,this.nodes.button.classList.remove(this.CSS.buttonUnlink),this.nodes.button.classList.remove(this.CSS.buttonActive);return!!e}clear(){this.closeActions()}get shortcut(){return"CMD+K"}toggleActions(){this.inputOpened?this.closeActions(!1):this.openActions(!0)}openActions(e=!1){this.nodes.input.classList.add(this.CSS.inputShowed),e&&this.nodes.input.focus(),this.inputOpened=!0}closeActions(e=!0){if(this.selection.isFakeBackgroundEnabled){const t=new m;t.save(),this.selection.restore(),this.selection.removeFakeBackground(),t.restore()}this.nodes.input.classList.remove(this.CSS.inputShowed),this.nodes.input.value="",e&&this.selection.clearSaved(),this.inputOpened=!1}enterPressed(e){let t=this.nodes.input.value||"";if(!t.trim()){this.selection.restore(),this.unlink(),e.preventDefault(),this.closeActions();return}if(!this.validateURL(t)){this.notifier.show({message:"Pasted link is not valid.",style:"error"}),L("Incorrect Link pasted","warn",t);return}t=this.prepareLink(t),this.selection.restore(),this.selection.removeFakeBackground(),this.insertLink(t),e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation(),this.selection.collapseToEnd(),this.inlineToolbar.close()}validateURL(e){return!/\s/.test(e)}prepareLink(e){return e=e.trim(),e=this.addProtocol(e),e}addProtocol(e){if(/^(\w+):(\/\/)?/.test(e))return e;const t=/^\/[^/\s]/.test(e),o=e.substring(0,1)==="#",i=/^\/\/[^/\s]/.test(e);return!t&&!o&&!i&&(e="http://"+e),e}insertLink(e){const t=this.selection.findParentTag("A");t&&this.selection.expandToTag(t),document.execCommand(this.commandLink,!1,e)}unlink(){document.execCommand(this.commandUnlink)}}Ke.isInline=!0,Ke.title="Link";class Et{constructor({data:e,api:t}){this.CSS={wrapper:"ce-stub",info:"ce-stub__info",title:"ce-stub__title",subtitle:"ce-stub__subtitle"},this.api=t,this.title=e.title||this.api.i18n.t("Error"),this.subtitle=this.api.i18n.t("The block can not be displayed correctly."),this.savedData=e.savedData,this.wrapper=this.make()}render(){return this.wrapper}save(){return this.savedData}make(){const e=d.make("div",this.CSS.wrapper),t=Lo,o=d.make("div",this.CSS.info),i=d.make("div",this.CSS.title,{textContent:this.title}),n=d.make("div",this.CSS.subtitle,{textContent:this.subtitle});return e.innerHTML=t,o.appendChild(i),o.appendChild(n),e.appendChild(o),e}}Et.isReadOnlySupported=!0;class ti extends ze{constructor(){super(...arguments),this.type=we.Inline}get title(){return this.constructable[je.Title]}create(){return new this.constructable({api:this.api.getMethodsForTool(this),config:this.settings})}}class oi extends ze{constructor(){super(...arguments),this.type=we.Tune}create(e,t){return new this.constructable({api:this.api.getMethodsForTool(this),config:this.settings,block:t,data:e})}}class U extends Map{get blockTools(){const e=Array.from(this.entries()).filter(([,t])=>t.isBlock());return new U(e)}get inlineTools(){const e=Array.from(this.entries()).filter(([,t])=>t.isInline());return new U(e)}get blockTunes(){const e=Array.from(this.entries()).filter(([,t])=>t.isTune());return new U(e)}get internalTools(){const e=Array.from(this.entries()).filter(([,t])=>t.isInternal);return new U(e)}get externalTools(){const e=Array.from(this.entries()).filter(([,t])=>!t.isInternal);return new U(e)}}var ii=Object.defineProperty,ni=Object.getOwnPropertyDescriptor,Bt=(s,e,t,o)=>{for(var i=o>1?void 0:o?ni(e,t):e,n=s.length-1,r;n>=0;n--)(r=s[n])&&(i=(o?r(e,t,i):r(i))||i);return o&&i&&ii(e,t,i),i};class Xe extends ze{constructor(){super(...arguments),this.type=we.Block,this.inlineTools=new U,this.tunes=new U}create(e,t,o){return new this.constructable({data:e,block:t,readOnly:o,api:this.api.getMethodsForTool(this),config:this.settings})}get isReadOnlySupported(){return this.constructable[de.IsReadOnlySupported]===!0}get isLineBreaksEnabled(){return this.constructable[de.IsEnabledLineBreaks]}get toolbox(){const e=this.constructable[de.Toolbox],t=this.config[ye.Toolbox];if(!X(e)&&t!==!1)return t?Array.isArray(e)?Array.isArray(t)?t.map((o,i)=>{const n=e[i];return n?{...n,...o}:o}):[t]:Array.isArray(t)?t:[{...e,...t}]:Array.isArray(e)?e:[e]}get conversionConfig(){return this.constructable[de.ConversionConfig]}get enabledInlineTools(){return this.config[ye.EnabledInlineTools]||!1}get enabledBlockTunes(){return this.config[ye.EnabledBlockTunes]}get pasteConfig(){return this.constructable[de.PasteConfig]??{}}get sanitizeConfig(){const e=super.sanitizeConfig,t=this.baseSanitizeConfig;if(X(e))return t;const o={};for(const i in e)if(Object.prototype.hasOwnProperty.call(e,i)){const n=e[i];H(n)?o[i]=Object.assign({},t,n):o[i]=n}return o}get baseSanitizeConfig(){const e={};return Array.from(this.inlineTools.values()).forEach(t=>Object.assign(e,t.sanitizeConfig)),Array.from(this.tunes.values()).forEach(t=>Object.assign(e,t.sanitizeConfig)),e}}Bt([re],Xe.prototype,"sanitizeConfig",1),Bt([re],Xe.prototype,"baseSanitizeConfig",1);class si{constructor(e,t,o){this.api=o,this.config=e,this.editorConfig=t}get(e){const{class:t,isInternal:o=!1,...i}=this.config[e],n=this.getConstructor(t);return new n({name:e,constructable:t,config:i,api:this.api,isDefault:e===this.editorConfig.defaultBlock,defaultPlaceholder:this.editorConfig.placeholder,isInternal:o})}getConstructor(e){switch(!0){case e[je.IsInline]:return ti;case e[mt.IsTune]:return oi;default:return Xe}}}class Tt{constructor({api:e}){this.CSS={animation:"wobble"},this.api=e}render(){return{icon:dt,title:this.api.i18n.t("Move down"),onActivate:()=>this.handleClick(),name:"move-down"}}handleClick(){const e=this.api.blocks.getCurrentBlockIndex(),t=this.api.blocks.getBlockByIndex(e+1);if(!t)throw new Error("Unable to move Block down since it is already the last");const o=t.holder,i=o.getBoundingClientRect();let n=Math.abs(window.innerHeight-o.offsetHeight);i.topthis.handleClick()}}}handleClick(){this.api.blocks.delete()}}Ct.isTune=!0;class St{constructor({api:e}){this.CSS={animation:"wobble"},this.api=e}render(){return{icon:yo,title:this.api.i18n.t("Move up"),onActivate:()=>this.handleClick(),name:"move-up"}}handleClick(){const e=this.api.blocks.getCurrentBlockIndex(),t=this.api.blocks.getBlockByIndex(e),o=this.api.blocks.getBlockByIndex(e-1);if(e===0||!t||!o)throw new Error("Unable to move Block up since it is already the first");const i=t.holder,n=o.holder,r=i.getBoundingClientRect(),a=n.getBoundingClientRect();let l;a.top>0?l=Math.abs(r.top)-Math.abs(a.top):l=Math.abs(r.top)+a.height,window.scrollBy(0,-1*l),this.api.blocks.move(e-1),this.api.toolbar.toggleBlockSettings(!0)}}St.isTune=!0;var ri=Object.defineProperty,ai=Object.getOwnPropertyDescriptor,li=(s,e,t,o)=>{for(var i=o>1?void 0:o?ai(e,t):e,n=s.length-1,r;n>=0;n--)(r=s[n])&&(i=(o?r(e,t,i):r(i))||i);return o&&i&&ri(e,t,i),i};class It extends C{constructor(){super(...arguments),this.stubTool="stub",this.toolsAvailable=new U,this.toolsUnavailable=new U}get available(){return this.toolsAvailable}get unavailable(){return this.toolsUnavailable}get inlineTools(){return this.available.inlineTools}get blockTools(){return this.available.blockTools}get blockTunes(){return this.available.blockTunes}get defaultTool(){return this.blockTools.get(this.config.defaultBlock)}get internal(){return this.available.internalTools}async prepare(){if(this.validateTools(),this.config.tools=Ce({},this.internalTools,this.config.tools),!Object.prototype.hasOwnProperty.call(this.config,"tools")||Object.keys(this.config.tools).length===0)throw Error("Can't start without tools");const e=this.prepareConfig();this.factory=new si(e,this.config,this.Editor.API);const t=this.getListOfPrepareFunctions(e);if(t.length===0)return Promise.resolve();await Nt(t,o=>{this.toolPrepareMethodSuccess(o)},o=>{this.toolPrepareMethodFallback(o)}),this.prepareBlockTools()}getAllInlineToolsSanitizeConfig(){const e={};return Array.from(this.inlineTools.values()).forEach(t=>{Object.assign(e,t.sanitizeConfig)}),e}destroy(){Object.values(this.available).forEach(async e=>{D(e.reset)&&await e.reset()})}get internalTools(){return{bold:{class:We,isInternal:!0},italic:{class:Ye,isInternal:!0},link:{class:Ke,isInternal:!0},paragraph:{class:ei,inlineToolbar:!0,isInternal:!0},stub:{class:Et,isInternal:!0},moveUp:{class:St,isInternal:!0},delete:{class:Ct,isInternal:!0},moveDown:{class:Tt,isInternal:!0}}}toolPrepareMethodSuccess(e){const t=this.factory.get(e.toolName);if(t.isInline()){const i=["render","surround","checkState"].filter(n=>!t.create()[n]);if(i.length){L(`Incorrect Inline Tool: ${t.name}. Some of required methods is not implemented %o`,"warn",i),this.toolsUnavailable.set(t.name,t);return}}this.toolsAvailable.set(t.name,t)}toolPrepareMethodFallback(e){this.toolsUnavailable.set(e.toolName,this.factory.get(e.toolName))}getListOfPrepareFunctions(e){const t=[];return Object.entries(e).forEach(([o,i])=>{t.push({function:D(i.class.prepare)?i.class.prepare:()=>{},data:{toolName:o,config:i.config}})}),t}prepareBlockTools(){Array.from(this.blockTools.values()).forEach(e=>{this.assignInlineToolsToBlockTool(e),this.assignBlockTunesToBlockTool(e)})}assignInlineToolsToBlockTool(e){if(this.config.inlineToolbar!==!1){if(e.enabledInlineTools===!0){e.inlineTools=new U(Array.isArray(this.config.inlineToolbar)?this.config.inlineToolbar.map(t=>[t,this.inlineTools.get(t)]):Array.from(this.inlineTools.entries()));return}Array.isArray(e.enabledInlineTools)&&(e.inlineTools=new U(e.enabledInlineTools.map(t=>[t,this.inlineTools.get(t)])))}}assignBlockTunesToBlockTool(e){if(e.enabledBlockTunes!==!1){if(Array.isArray(e.enabledBlockTunes)){const t=new U(e.enabledBlockTunes.map(o=>[o,this.blockTunes.get(o)]));e.tunes=new U([...t,...this.blockTunes.internalTools]);return}if(Array.isArray(this.config.tunes)){const t=new U(this.config.tunes.map(o=>[o,this.blockTunes.get(o)]));e.tunes=new U([...t,...this.blockTunes.internalTools]);return}e.tunes=this.blockTunes.internalTools}}validateTools(){for(const e in this.config.tools)if(Object.prototype.hasOwnProperty.call(this.config.tools,e)){if(e in this.internalTools)return;const t=this.config.tools[e];if(!D(t)&&!D(t.class))throw Error(`Tool «${e}» must be a constructor function or an object with function in the «class» property`)}}prepareConfig(){const e={};for(const t in this.config.tools)H(this.config.tools[t])?e[t]=this.config.tools[t]:e[t]={class:this.config.tools[t]};return e}}li([re],It.prototype,"getAllInlineToolsSanitizeConfig",1);const ci=`:root{--selectionColor: #e1f2ff;--inlineSelectionColor: #d4ecff;--bg-light: #eff2f5;--grayText: #707684;--color-dark: #1D202B;--color-active-icon: #388AE5;--color-gray-border: rgba(201, 201, 204, .48);--content-width: 650px;--narrow-mode-right-padding: 50px;--toolbox-buttons-size: 26px;--toolbox-buttons-size--mobile: 36px;--icon-size: 20px;--icon-size--mobile: 28px;--block-padding-vertical: .4em;--color-line-gray: #EFF0F1 }.codex-editor{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;z-index:1}.codex-editor .hide{display:none}.codex-editor__redactor [contenteditable]:empty:after{content:"\\feff"}@media (min-width: 651px){.codex-editor--narrow .codex-editor__redactor{margin-right:50px}}@media (min-width: 651px){.codex-editor--narrow.codex-editor--rtl .codex-editor__redactor{margin-left:50px;margin-right:0}}@media (min-width: 651px){.codex-editor--narrow .ce-toolbar__actions{right:-5px}}.codex-editor-copyable{position:absolute;height:1px;width:1px;top:-400%;opacity:.001}.codex-editor-overlay{position:fixed;top:0px;left:0px;right:0px;bottom:0px;z-index:999;pointer-events:none;overflow:hidden}.codex-editor-overlay__container{position:relative;pointer-events:auto;z-index:0}.codex-editor-overlay__rectangle{position:absolute;pointer-events:none;background-color:#2eaadc33;border:1px solid transparent}.codex-editor svg{max-height:100%}.codex-editor path{stroke:currentColor}.codex-editor ::-moz-selection{background-color:#d4ecff}.codex-editor ::selection{background-color:#d4ecff}.codex-editor--toolbox-opened [contentEditable=true][data-placeholder]:focus:before{opacity:0!important}.ce-scroll-locked{overflow:hidden}.ce-scroll-locked--hard{overflow:hidden;top:calc(-1 * var(--window-scroll-offset));position:fixed;width:100%}.ce-toolbar{position:absolute;left:0;right:0;top:0;-webkit-transition:opacity .1s ease;transition:opacity .1s ease;will-change:opacity,top;display:none}.ce-toolbar--opened{display:block}.ce-toolbar__content{max-width:650px;margin:0 auto;position:relative}.ce-toolbar__plus{color:#1d202b;cursor:pointer;width:26px;height:26px;border-radius:7px;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-ms-flex-negative:0;flex-shrink:0}@media (max-width: 650px){.ce-toolbar__plus{width:36px;height:36px}}@media (hover: hover){.ce-toolbar__plus:hover{background-color:#eff2f5}}.ce-toolbar__plus--active{background-color:#eff2f5;-webkit-animation:bounceIn .75s 1;animation:bounceIn .75s 1;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.ce-toolbar__plus-shortcut{opacity:.6;word-spacing:-2px;margin-top:5px}@media (max-width: 650px){.ce-toolbar__plus{position:absolute;background-color:#fff;border:1px solid #E8E8EB;-webkit-box-shadow:0 3px 15px -3px rgba(13,20,33,.13);box-shadow:0 3px 15px -3px #0d142121;border-radius:6px;z-index:2;position:static}.ce-toolbar__plus--left-oriented:before{left:15px;margin-left:0}.ce-toolbar__plus--right-oriented:before{left:auto;right:15px;margin-left:0}}.ce-toolbar__actions{position:absolute;right:100%;opacity:0;display:-webkit-box;display:-ms-flexbox;display:flex;padding-right:5px}.ce-toolbar__actions--opened{opacity:1}@media (max-width: 650px){.ce-toolbar__actions{right:auto}}.ce-toolbar__settings-btn{color:#1d202b;width:26px;height:26px;border-radius:7px;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;margin-left:3px;cursor:pointer;user-select:none}@media (max-width: 650px){.ce-toolbar__settings-btn{width:36px;height:36px}}@media (hover: hover){.ce-toolbar__settings-btn:hover{background-color:#eff2f5}}.ce-toolbar__settings-btn--active{background-color:#eff2f5;-webkit-animation:bounceIn .75s 1;animation:bounceIn .75s 1;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}@media (min-width: 651px){.ce-toolbar__settings-btn{width:24px}}.ce-toolbar__settings-btn--hidden{display:none}@media (max-width: 650px){.ce-toolbar__settings-btn{position:absolute;background-color:#fff;border:1px solid #E8E8EB;-webkit-box-shadow:0 3px 15px -3px rgba(13,20,33,.13);box-shadow:0 3px 15px -3px #0d142121;border-radius:6px;z-index:2;position:static}.ce-toolbar__settings-btn--left-oriented:before{left:15px;margin-left:0}.ce-toolbar__settings-btn--right-oriented:before{left:auto;right:15px;margin-left:0}}.ce-toolbar__plus svg,.ce-toolbar__settings-btn svg{width:24px;height:24px}@media (min-width: 651px){.codex-editor--narrow .ce-toolbar__plus{left:5px}}@media (min-width: 651px){.codex-editor--narrow .ce-toolbox .ce-popover{right:0;left:auto;left:initial}}.ce-inline-toolbar{--y-offset: 8px;position:absolute;background-color:#fff;border:1px solid #E8E8EB;-webkit-box-shadow:0 3px 15px -3px rgba(13,20,33,.13);box-shadow:0 3px 15px -3px #0d142121;border-radius:6px;z-index:2;-webkit-transform:translateX(-50%) translateY(8px) scale(.94);transform:translate(-50%) translateY(8px) scale(.94);opacity:0;visibility:hidden;-webkit-transition:opacity .25s ease,-webkit-transform .15s ease;transition:opacity .25s ease,-webkit-transform .15s ease;transition:transform .15s ease,opacity .25s ease;transition:transform .15s ease,opacity .25s ease,-webkit-transform .15s ease;will-change:transform,opacity;top:0;left:0;z-index:3}.ce-inline-toolbar--left-oriented:before{left:15px;margin-left:0}.ce-inline-toolbar--right-oriented:before{left:auto;right:15px;margin-left:0}.ce-inline-toolbar--showed{opacity:1;visibility:visible;-webkit-transform:translateX(-50%);transform:translate(-50%)}.ce-inline-toolbar--left-oriented{-webkit-transform:translateX(-23px) translateY(8px) scale(.94);transform:translate(-23px) translateY(8px) scale(.94)}.ce-inline-toolbar--left-oriented.ce-inline-toolbar--showed{-webkit-transform:translateX(-23px);transform:translate(-23px)}.ce-inline-toolbar--right-oriented{-webkit-transform:translateX(-100%) translateY(8px) scale(.94);transform:translate(-100%) translateY(8px) scale(.94);margin-left:23px}.ce-inline-toolbar--right-oriented.ce-inline-toolbar--showed{-webkit-transform:translateX(-100%);transform:translate(-100%)}.ce-inline-toolbar [hidden]{display:none!important}.ce-inline-toolbar__toggler-and-button-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%;padding:0 6px}.ce-inline-toolbar__buttons{display:-webkit-box;display:-ms-flexbox;display:flex}.ce-inline-toolbar__dropdown{display:-webkit-box;display:-ms-flexbox;display:flex;padding:6px;margin:0 6px 0 -6px;-webkit-box-align:center;-ms-flex-align:center;align-items:center;cursor:pointer;border-right:1px solid rgba(201,201,204,.48);-webkit-box-sizing:border-box;box-sizing:border-box}@media (hover: hover){.ce-inline-toolbar__dropdown:hover{background:#eff2f5}}.ce-inline-toolbar__dropdown--hidden{display:none}.ce-inline-toolbar__dropdown-content,.ce-inline-toolbar__dropdown-arrow{display:-webkit-box;display:-ms-flexbox;display:flex}.ce-inline-toolbar__dropdown-content svg,.ce-inline-toolbar__dropdown-arrow svg{width:20px;height:20px}.ce-inline-toolbar__shortcut{opacity:.6;word-spacing:-3px;margin-top:3px}.ce-inline-tool{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding:6px 1px;cursor:pointer;border:0;outline:none;background-color:transparent;vertical-align:bottom;color:inherit;margin:0;border-radius:0;line-height:normal}.ce-inline-tool svg{width:20px;height:20px}@media (max-width: 650px){.ce-inline-tool svg{width:28px;height:28px}}@media (hover: hover){.ce-inline-tool:hover{background-color:#eff2f5}}.ce-inline-tool--active{color:#388ae5}.ce-inline-tool--focused{background:rgba(34,186,255,.08)!important}.ce-inline-tool--focused{-webkit-box-shadow:inset 0 0 0px 1px rgba(7,161,227,.08);box-shadow:inset 0 0 0 1px #07a1e314}.ce-inline-tool--focused-animated{-webkit-animation-name:buttonClicked;animation-name:buttonClicked;-webkit-animation-duration:.25s;animation-duration:.25s}.ce-inline-tool--link .icon--unlink,.ce-inline-tool--unlink .icon--link{display:none}.ce-inline-tool--unlink .icon--unlink{display:inline-block;margin-bottom:-1px}.ce-inline-tool-input{outline:none;border:0;border-radius:0 0 4px 4px;margin:0;font-size:13px;padding:10px;width:100%;-webkit-box-sizing:border-box;box-sizing:border-box;display:none;font-weight:500;border-top:1px solid rgba(201,201,204,.48);-webkit-appearance:none;font-family:inherit}@media (max-width: 650px){.ce-inline-tool-input{font-size:15px;font-weight:500}}.ce-inline-tool-input::-webkit-input-placeholder{color:#707684}.ce-inline-tool-input::-moz-placeholder{color:#707684}.ce-inline-tool-input:-ms-input-placeholder{color:#707684}.ce-inline-tool-input::-ms-input-placeholder{color:#707684}.ce-inline-tool-input::placeholder{color:#707684}.ce-inline-tool-input--showed{display:block}.ce-conversion-toolbar{position:absolute;background-color:#fff;border:1px solid #E8E8EB;-webkit-box-shadow:0 3px 15px -3px rgba(13,20,33,.13);box-shadow:0 3px 15px -3px #0d142121;border-radius:6px;z-index:2;opacity:0;visibility:hidden;will-change:transform,opacity;-webkit-transition:opacity .1s ease,-webkit-transform .1s ease;transition:opacity .1s ease,-webkit-transform .1s ease;transition:transform .1s ease,opacity .1s ease;transition:transform .1s ease,opacity .1s ease,-webkit-transform .1s ease;-webkit-transform:translateY(-8px);transform:translateY(-8px);left:-1px;width:190px;margin-top:5px;-webkit-box-sizing:content-box;box-sizing:content-box}.ce-conversion-toolbar--left-oriented:before{left:15px;margin-left:0}.ce-conversion-toolbar--right-oriented:before{left:auto;right:15px;margin-left:0}.ce-conversion-toolbar--showed{opacity:1;visibility:visible;-webkit-transform:none;transform:none}.ce-conversion-toolbar [hidden]{display:none!important}.ce-conversion-toolbar__buttons{display:-webkit-box;display:-ms-flexbox;display:flex}.ce-conversion-toolbar__label{color:#707684;font-size:11px;font-weight:500;letter-spacing:.33px;padding:10px 10px 5px;text-transform:uppercase}.ce-conversion-tool{display:-webkit-box;display:-ms-flexbox;display:flex;padding:5px 10px;font-size:14px;line-height:20px;font-weight:500;cursor:pointer;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.ce-conversion-tool--hidden{display:none}.ce-conversion-tool--focused{background:rgba(34,186,255,.08)!important}.ce-conversion-tool--focused{-webkit-box-shadow:inset 0 0 0px 1px rgba(7,161,227,.08);box-shadow:inset 0 0 0 1px #07a1e314}.ce-conversion-tool--focused-animated{-webkit-animation-name:buttonClicked;animation-name:buttonClicked;-webkit-animation-duration:.25s;animation-duration:.25s}.ce-conversion-tool:hover{background:#eff2f5}.ce-conversion-tool__icon{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;width:26px;height:26px;-webkit-box-shadow:0 0 0 1px rgba(201,201,204,.48);box-shadow:0 0 0 1px #c9c9cc7a;border-radius:5px;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;background:#fff;-webkit-box-sizing:content-box;box-sizing:content-box;-ms-flex-negative:0;flex-shrink:0;margin-right:10px}.ce-conversion-tool__icon svg{width:20px;height:20px}@media (max-width: 650px){.ce-conversion-tool__icon{width:36px;height:36px;border-radius:8px}.ce-conversion-tool__icon svg{width:28px;height:28px}}.ce-conversion-tool--last{margin-right:0!important}.ce-conversion-tool--active{color:#388ae5!important}.ce-conversion-tool--active{-webkit-animation:bounceIn .75s 1;animation:bounceIn .75s 1;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.ce-conversion-tool__secondary-label{color:#707684;font-size:12px;margin-left:auto;white-space:nowrap;letter-spacing:-.1em;padding-right:5px;margin-bottom:-2px;opacity:.6}@media (max-width: 650px){.ce-conversion-tool__secondary-label{display:none}}.ce-settings__button{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding:6px 1px;border-radius:3px;cursor:pointer;border:0;outline:none;background-color:transparent;vertical-align:bottom;color:inherit;margin:0;line-height:32px}.ce-settings__button svg{width:20px;height:20px}@media (max-width: 650px){.ce-settings__button svg{width:28px;height:28px}}@media (hover: hover){.ce-settings__button:hover{background-color:#eff2f5}}.ce-settings__button--active{color:#388ae5}.ce-settings__button--focused{background:rgba(34,186,255,.08)!important}.ce-settings__button--focused{-webkit-box-shadow:inset 0 0 0px 1px rgba(7,161,227,.08);box-shadow:inset 0 0 0 1px #07a1e314}.ce-settings__button--focused-animated{-webkit-animation-name:buttonClicked;animation-name:buttonClicked;-webkit-animation-duration:.25s;animation-duration:.25s}.ce-settings__button:not(:nth-child(3n+3)){margin-right:3px}.ce-settings__button:nth-child(n+4){margin-top:3px}.ce-settings__button--disabled{cursor:not-allowed!important}.ce-settings__button--disabled{opacity:.3}.ce-settings__button--selected{color:#388ae5}@media (min-width: 651px){.codex-editor--narrow .ce-settings .ce-popover{right:0;left:auto;left:initial}}@-webkit-keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.ce-block{-webkit-animation:fade-in .3s ease;animation:fade-in .3s ease;-webkit-animation-fill-mode:none;animation-fill-mode:none;-webkit-animation-fill-mode:initial;animation-fill-mode:initial}.ce-block:first-of-type{margin-top:0}.ce-block--selected .ce-block__content{background:#e1f2ff}.ce-block--selected .ce-block__content [contenteditable]{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ce-block--selected .ce-block__content img,.ce-block--selected .ce-block__content .ce-stub{opacity:.55}.ce-block--stretched .ce-block__content{max-width:none}.ce-block__content{position:relative;max-width:650px;margin:0 auto;-webkit-transition:background-color .15s ease;transition:background-color .15s ease}.ce-block--drop-target .ce-block__content:before{content:"";position:absolute;top:100%;left:-20px;margin-top:-1px;height:8px;width:8px;border:solid #388AE5;border-width:1px 1px 0 0;-webkit-transform-origin:right;transform-origin:right;-webkit-transform:rotate(45deg);transform:rotate(45deg)}.ce-block--drop-target .ce-block__content:after{content:"";position:absolute;top:100%;height:1px;width:100%;color:#388ae5;background:repeating-linear-gradient(90deg,#388AE5,#388AE5 1px,#fff 1px,#fff 6px)}.ce-block a{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline}.ce-block b{font-weight:700}.ce-block i{font-style:italic}@media (min-width: 651px){.codex-editor--narrow .ce-block--focused{margin-right:-50px;padding-right:50px}}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}20%{-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}60%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}20%{-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}60%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}@-webkit-keyframes selectionBounce{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}50%{-webkit-transform:scale3d(1.01,1.01,1.01);transform:scale3d(1.01,1.01,1.01)}70%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}@keyframes selectionBounce{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}50%{-webkit-transform:scale3d(1.01,1.01,1.01);transform:scale3d(1.01,1.01,1.01)}70%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}@-webkit-keyframes buttonClicked{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{-webkit-transform:scale3d(.95,.95,.95);transform:scale3d(.95,.95,.95)}60%{-webkit-transform:scale3d(1.02,1.02,1.02);transform:scale3d(1.02,1.02,1.02)}80%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}@keyframes buttonClicked{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{-webkit-transform:scale3d(.95,.95,.95);transform:scale3d(.95,.95,.95)}60%{-webkit-transform:scale3d(1.02,1.02,1.02);transform:scale3d(1.02,1.02,1.02)}80%{-webkit-transform:scale3d(1,1,1);transform:scaleZ(1)}}.cdx-block{padding:.4em 0}.cdx-block::-webkit-input-placeholder{line-height:normal!important}.cdx-input{border:1px solid rgba(201,201,204,.48);-webkit-box-shadow:inset 0 1px 2px 0 rgba(35,44,72,.06);box-shadow:inset 0 1px 2px #232c480f;border-radius:3px;padding:10px 12px;outline:none;width:100%;-webkit-box-sizing:border-box;box-sizing:border-box}.cdx-input[data-placeholder]:before{position:static!important}.cdx-input[data-placeholder]:before{display:inline-block;width:0;white-space:nowrap;pointer-events:none}.cdx-settings-button{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding:6px 1px;border-radius:3px;cursor:pointer;border:0;outline:none;background-color:transparent;vertical-align:bottom;color:inherit;margin:0;min-width:26px;min-height:26px}.cdx-settings-button svg{width:20px;height:20px}@media (max-width: 650px){.cdx-settings-button svg{width:28px;height:28px}}@media (hover: hover){.cdx-settings-button:hover{background-color:#eff2f5}}.cdx-settings-button--focused{background:rgba(34,186,255,.08)!important}.cdx-settings-button--focused{-webkit-box-shadow:inset 0 0 0px 1px rgba(7,161,227,.08);box-shadow:inset 0 0 0 1px #07a1e314}.cdx-settings-button--focused-animated{-webkit-animation-name:buttonClicked;animation-name:buttonClicked;-webkit-animation-duration:.25s;animation-duration:.25s}.cdx-settings-button--active{color:#388ae5}.cdx-settings-button svg{width:auto;height:auto}@media (max-width: 650px){.cdx-settings-button{width:36px;height:36px;border-radius:8px}}.cdx-loader{position:relative;border:1px solid rgba(201,201,204,.48)}.cdx-loader:before{content:"";position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-11px 0 0 -11px;border:2px solid rgba(201,201,204,.48);border-left-color:#388ae5;border-radius:50%;-webkit-animation:cdxRotation 1.2s infinite linear;animation:cdxRotation 1.2s infinite linear}@-webkit-keyframes cdxRotation{0%{-webkit-transform:rotate(0deg);transform:rotate(0)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes cdxRotation{0%{-webkit-transform:rotate(0deg);transform:rotate(0)}to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.cdx-button{padding:13px;border-radius:3px;border:1px solid rgba(201,201,204,.48);font-size:14.9px;background:#fff;-webkit-box-shadow:0 2px 2px 0 rgba(18,30,57,.04);box-shadow:0 2px 2px #121e390a;color:#707684;text-align:center;cursor:pointer}@media (hover: hover){.cdx-button:hover{background:#FBFCFE;-webkit-box-shadow:0 1px 3px 0 rgba(18,30,57,.08);box-shadow:0 1px 3px #121e3914}}.cdx-button svg{height:20px;margin-right:.2em;margin-top:-2px}.ce-stub{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:12px 18px;margin:10px 0;border-radius:10px;background:#eff2f5;border:1px solid #EFF0F1;color:#707684;font-size:14px}.ce-stub svg{width:20px;height:20px}.ce-stub__info{margin-left:14px}.ce-stub__title{font-weight:500;text-transform:capitalize}.codex-editor.codex-editor--rtl{direction:rtl}.codex-editor.codex-editor--rtl .cdx-list{padding-left:0;padding-right:40px}.codex-editor.codex-editor--rtl .ce-toolbar__plus{right:-26px;left:auto}.codex-editor.codex-editor--rtl .ce-toolbar__actions{right:auto;left:-26px}@media (max-width: 650px){.codex-editor.codex-editor--rtl .ce-toolbar__actions{margin-left:0;margin-right:auto;padding-right:0;padding-left:10px}}.codex-editor.codex-editor--rtl .ce-settings{left:5px;right:auto}.codex-editor.codex-editor--rtl .ce-settings:before{right:auto;left:25px}.codex-editor.codex-editor--rtl .ce-settings__button:not(:nth-child(3n+3)){margin-left:3px;margin-right:0}.codex-editor.codex-editor--rtl .ce-conversion-tool__icon{margin-right:0;margin-left:10px}.codex-editor.codex-editor--rtl .ce-inline-toolbar__dropdown{border-right:0px solid transparent;border-left:1px solid rgba(201,201,204,.48);margin:0 -6px 0 6px}.codex-editor.codex-editor--rtl .ce-inline-toolbar__dropdown .icon--toggler-down{margin-left:0;margin-right:4px}@media (min-width: 651px){.codex-editor--narrow.codex-editor--rtl .ce-toolbar__plus{left:0px;right:5px}}@media (min-width: 651px){.codex-editor--narrow.codex-editor--rtl .ce-toolbar__actions{left:-5px}}.cdx-search-field{--icon-margin-right: 10px;background:rgba(232,232,235,.49);border:1px solid rgba(226,226,229,.2);border-radius:6px;padding:2px;display:grid;grid-template-columns:auto auto 1fr;grid-template-rows:auto}.cdx-search-field__icon{width:26px;height:26px;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-right:var(--icon-margin-right)}.cdx-search-field__icon svg{width:20px;height:20px;color:#707684}.cdx-search-field__input{font-size:14px;outline:none;font-weight:500;font-family:inherit;border:0;background:transparent;margin:0;padding:0;line-height:22px;min-width:calc(100% - 26px - var(--icon-margin-right))}.cdx-search-field__input::-webkit-input-placeholder{color:#707684;font-weight:500}.cdx-search-field__input::-moz-placeholder{color:#707684;font-weight:500}.cdx-search-field__input:-ms-input-placeholder{color:#707684;font-weight:500}.cdx-search-field__input::-ms-input-placeholder{color:#707684;font-weight:500}.cdx-search-field__input::placeholder{color:#707684;font-weight:500}.ce-popover{--border-radius: 6px;--width: 200px;--max-height: 270px;--padding: 6px;--offset-from-target: 8px;--color-border: #e8e8eb;--color-shadow: rgba(13,20,33,.13);--color-background: white;--color-text-primary: black;--color-text-secondary: #707684;--color-border-icon: rgba(201, 201, 204, .48);--color-border-icon-disabled: #EFF0F1;--color-text-icon-active: #388AE5;--color-background-icon-active: rgba(56, 138, 229, .1);--color-background-item-focus: rgba(34, 186, 255, .08);--color-shadow-item-focus: rgba(7, 161, 227, .08);--color-background-item-hover: #eff2f5;--color-background-item-confirm: #E24A4A;--color-background-item-confirm-hover: #CE4343;min-width:var(--width);width:var(--width);max-height:var(--max-height);border-radius:var(--border-radius);overflow:hidden;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-box-shadow:0 3px 15px -3px var(--color-shadow);box-shadow:0 3px 15px -3px var(--color-shadow);position:absolute;left:0;top:calc(100% + var(--offset-from-target));background:var(--color-background);display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;z-index:4;opacity:0;max-height:0;pointer-events:none;padding:0;border:none}.ce-popover--opened{opacity:1;padding:var(--padding);max-height:var(--max-height);pointer-events:auto;-webkit-animation:panelShowing .1s ease;animation:panelShowing .1s ease;border:1px solid var(--color-border)}@media (max-width: 650px){.ce-popover--opened{-webkit-animation:panelShowingMobile .25s ease;animation:panelShowingMobile .25s ease}}.ce-popover__items{overflow-y:auto;-ms-scroll-chaining:none;overscroll-behavior:contain}@media (max-width: 650px){.ce-popover__overlay{position:fixed;top:0;bottom:0;left:0;right:0;background:#1D202B;z-index:3;opacity:.5;-webkit-transition:opacity .12s ease-in;transition:opacity .12s ease-in;will-change:opacity;visibility:visible}}.ce-popover__overlay--hidden{display:none}.ce-popover--open-top{top:calc(-1 * (var(--offset-from-target) + var(--popover-height)))}@media (max-width: 650px){.ce-popover{--offset: 5px;position:fixed;max-width:none;min-width:calc(100% - var(--offset) * 2);left:var(--offset);right:var(--offset);bottom:calc(var(--offset) + env(safe-area-inset-bottom));top:auto;border-radius:10px}.ce-popover .ce-popover__search{display:none}}.ce-popover__search,.ce-popover__custom-content:not(:empty){margin-bottom:5px}.ce-popover__nothing-found-message{color:#707684;display:none;cursor:default;padding:3px;font-size:14px;line-height:20px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ce-popover__nothing-found-message--displayed{display:block}.ce-popover__custom-content:not(:empty){padding:4px}@media (min-width: 651px){.ce-popover__custom-content:not(:empty){padding:0}}.ce-popover__custom-content--hidden{display:none}.ce-popover-item{--border-radius: 6px;--icon-size: 20px;--icon-size-mobile: 28px;border-radius:var(--border-radius);display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:3px;color:var(--color-text-primary);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@media (max-width: 650px){.ce-popover-item{padding:4px}}.ce-popover-item:not(:last-of-type){margin-bottom:1px}.ce-popover-item__icon{border-radius:5px;width:26px;height:26px;-webkit-box-shadow:0 0 0 1px var(--color-border-icon);box-shadow:0 0 0 1px var(--color-border-icon);background:#fff;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-right:10px}.ce-popover-item__icon svg{width:20px;height:20px}@media (max-width: 650px){.ce-popover-item__icon{width:36px;height:36px;border-radius:8px}.ce-popover-item__icon svg{width:var(--icon-size-mobile);height:var(--icon-size-mobile)}}.ce-popover-item__title{font-size:14px;line-height:20px;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}@media (max-width: 650px){.ce-popover-item__title{font-size:16px}}.ce-popover-item__secondary-title{color:var(--color-text-secondary);font-size:12px;margin-left:auto;white-space:nowrap;letter-spacing:-.1em;padding-right:5px;margin-bottom:-2px;opacity:.6}@media (max-width: 650px){.ce-popover-item__secondary-title{display:none}}.ce-popover-item--active{background:var(--color-background-icon-active);color:var(--color-text-icon-active)}.ce-popover-item--active .ce-popover-item__icon{-webkit-box-shadow:none;box-shadow:none}.ce-popover-item--disabled{color:var(--color-text-secondary);cursor:default;pointer-events:none}.ce-popover-item--disabled .ce-popover-item__icon{-webkit-box-shadow:0 0 0 1px var(--color-border-icon-disabled);box-shadow:0 0 0 1px var(--color-border-icon-disabled)}.ce-popover-item--focused:not(.ce-popover-item--no-focus){background:var(--color-background-item-focus)!important}.ce-popover-item--focused:not(.ce-popover-item--no-focus){-webkit-box-shadow:inset 0 0 0px 1px var(--color-shadow-item-focus);box-shadow:inset 0 0 0 1px var(--color-shadow-item-focus)}.ce-popover-item--hidden{display:none}@media (hover: hover){.ce-popover-item:hover{cursor:pointer}.ce-popover-item:hover:not(.ce-popover-item--no-hover){background-color:var(--color-background-item-hover)}.ce-popover-item:hover .ce-popover-item__icon{-webkit-box-shadow:none;box-shadow:none}}.ce-popover-item--confirmation{background:var(--color-background-item-confirm)}.ce-popover-item--confirmation .ce-popover-item__icon{color:var(--color-background-item-confirm)}.ce-popover-item--confirmation .ce-popover-item__title{color:#fff}@media (hover: hover){.ce-popover-item--confirmation:not(.ce-popover-item--no-hover):hover{background:var(--color-background-item-confirm-hover)}}.ce-popover-item--confirmation:not(.ce-popover-item--no-focus).ce-popover-item--focused{background:var(--color-background-item-confirm-hover)!important}.ce-popover-item--confirmation .ce-popover-item__icon,.ce-popover-item--active .ce-popover-item__icon,.ce-popover-item--focused .ce-popover-item__icon{-webkit-box-shadow:none;box-shadow:none}@-webkit-keyframes panelShowing{0%{opacity:0;-webkit-transform:translateY(-8px) scale(.9);transform:translateY(-8px) scale(.9)}70%{opacity:1;-webkit-transform:translateY(2px);transform:translateY(2px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes panelShowing{0%{opacity:0;-webkit-transform:translateY(-8px) scale(.9);transform:translateY(-8px) scale(.9)}70%{opacity:1;-webkit-transform:translateY(2px);transform:translateY(2px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes panelShowingMobile{0%{opacity:0;-webkit-transform:translateY(14px) scale(.98);transform:translateY(14px) scale(.98)}70%{opacity:1;-webkit-transform:translateY(-4px);transform:translateY(-4px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes panelShowingMobile{0%{opacity:0;-webkit-transform:translateY(14px) scale(.98);transform:translateY(14px) scale(.98)}70%{opacity:1;-webkit-transform:translateY(-4px);transform:translateY(-4px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}.wobble{-webkit-animation-name:wobble;animation-name:wobble;-webkit-animation-duration:.4s;animation-duration:.4s}@-webkit-keyframes wobble{0%{-webkit-transform:translate3d(0,0,0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-9%,0,0);transform:translate3d(-9%,0,0)}30%{-webkit-transform:translate3d(9%,0,0);transform:translate3d(9%,0,0)}45%{-webkit-transform:translate3d(-4%,0,0);transform:translate3d(-4%,0,0)}60%{-webkit-transform:translate3d(4%,0,0);transform:translate3d(4%,0,0)}75%{-webkit-transform:translate3d(-1%,0,0);transform:translate3d(-1%,0,0)}to{-webkit-transform:translate3d(0,0,0);transform:translateZ(0)}}@keyframes wobble{0%{-webkit-transform:translate3d(0,0,0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-9%,0,0);transform:translate3d(-9%,0,0)}30%{-webkit-transform:translate3d(9%,0,0);transform:translate3d(9%,0,0)}45%{-webkit-transform:translate3d(-4%,0,0);transform:translate3d(-4%,0,0)}60%{-webkit-transform:translate3d(4%,0,0);transform:translate3d(4%,0,0)}75%{-webkit-transform:translate3d(-1%,0,0);transform:translate3d(-1%,0,0)}to{-webkit-transform:translate3d(0,0,0);transform:translateZ(0)}} +`;class di extends C{constructor(){super(...arguments),this.isMobile=!1,this.contentRectCache=void 0,this.resizeDebouncer=Pt(()=>{this.windowResize()},200)}get CSS(){return{editorWrapper:"codex-editor",editorWrapperNarrow:"codex-editor--narrow",editorZone:"codex-editor__redactor",editorZoneHidden:"codex-editor__redactor--hidden",editorEmpty:"codex-editor--empty",editorRtlFix:"codex-editor--rtl"}}get contentRect(){if(this.contentRectCache)return this.contentRectCache;const e=this.nodes.wrapper.querySelector(`.${F.CSS.content}`);return e?(this.contentRectCache=e.getBoundingClientRect(),this.contentRectCache):{width:650,left:0,right:0}}async prepare(){this.checkIsMobile(),this.make(),this.loadStyles()}toggleReadOnly(e){e?this.disableModuleBindings():this.enableModuleBindings()}checkEmptiness(){const{BlockManager:e}=this.Editor;this.nodes.wrapper.classList.toggle(this.CSS.editorEmpty,e.isEditorEmpty)}get someToolbarOpened(){const{Toolbar:e,BlockSettings:t,InlineToolbar:o,ConversionToolbar:i}=this.Editor;return t.opened||o.opened||i.opened||e.toolbox.opened}get someFlipperButtonFocused(){return this.Editor.Toolbar.toolbox.hasFocus()?!0:Object.entries(this.Editor).filter(([e,t])=>t.flipper instanceof G).some(([e,t])=>t.flipper.hasFocus())}destroy(){this.nodes.holder.innerHTML=""}closeAllToolbars(){const{Toolbar:e,BlockSettings:t,InlineToolbar:o,ConversionToolbar:i}=this.Editor;t.close(),o.close(),i.close(),e.toolbox.close()}checkIsMobile(){this.isMobile=window.innerWidth{this.redactorClicked(e)},!1),this.readOnlyMutableListeners.on(this.nodes.redactor,"mousedown",e=>{this.documentTouched(e)},!0),this.readOnlyMutableListeners.on(this.nodes.redactor,"touchstart",e=>{this.documentTouched(e)},!0),this.readOnlyMutableListeners.on(document,"keydown",e=>{this.documentKeydown(e)},!0),this.readOnlyMutableListeners.on(document,"mousedown",e=>{this.documentClicked(e)},!0),this.readOnlyMutableListeners.on(document,"selectionchange",()=>{this.selectionChanged()},!0),this.readOnlyMutableListeners.on(window,"resize",()=>{this.resizeDebouncer()},{passive:!0}),this.watchBlockHoveredEvents()}watchBlockHoveredEvents(){let e;this.readOnlyMutableListeners.on(this.nodes.redactor,"mousemove",Te(t=>{const o=t.target.closest(".ce-block");this.Editor.BlockSelection.anyBlockSelected||o&&e!==o&&(e=o,this.eventsDispatcher.emit(gt,{block:this.Editor.BlockManager.getBlockByChildNode(o)}))},20),{passive:!0})}disableModuleBindings(){this.readOnlyMutableListeners.clearAll()}windowResize(){this.contentRectCache=null,this.checkIsMobile()}documentKeydown(e){switch(e.keyCode){case B.ENTER:this.enterPressed(e);break;case B.BACKSPACE:case B.DELETE:this.backspacePressed(e);break;case B.ESC:this.escapePressed(e);break;default:this.defaultBehaviour(e);break}}defaultBehaviour(e){const{currentBlock:t}=this.Editor.BlockManager,o=e.target.closest(`.${this.CSS.editorWrapper}`),i=e.altKey||e.ctrlKey||e.metaKey||e.shiftKey;if(t!==void 0&&o===null){this.Editor.BlockEvents.keydown(e);return}o||t&&i||(this.Editor.BlockManager.dropPointer(),this.Editor.Toolbar.close())}backspacePressed(e){const{BlockManager:t,BlockSelection:o,Caret:i}=this.Editor;if(o.anyBlockSelected&&!m.isSelectionExists){const n=t.removeSelectedBlocks();i.setToBlock(t.insertDefaultBlockAtIndex(n,!0),i.positions.START),o.clearSelection(e),e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation()}}escapePressed(e){this.Editor.BlockSelection.clearSelection(e),this.Editor.Toolbar.toolbox.opened?(this.Editor.Toolbar.toolbox.close(),this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock)):this.Editor.BlockSettings.opened?this.Editor.BlockSettings.close():this.Editor.ConversionToolbar.opened?this.Editor.ConversionToolbar.close():this.Editor.InlineToolbar.opened?this.Editor.InlineToolbar.close():this.Editor.Toolbar.close()}enterPressed(e){const{BlockManager:t,BlockSelection:o}=this.Editor,i=t.currentBlockIndex>=0;if(o.anyBlockSelected&&!m.isSelectionExists){o.clearSelection(e),e.preventDefault(),e.stopImmediatePropagation(),e.stopPropagation();return}if(!this.someToolbarOpened&&i&&e.target.tagName==="BODY"){const n=this.Editor.BlockManager.insert();this.Editor.Caret.setToBlock(n),this.Editor.BlockManager.highlightCurrentNode(),this.Editor.Toolbar.moveAndOpen(n)}this.Editor.BlockSelection.clearSelection(e)}documentClicked(e){if(!e.isTrusted)return;const t=e.target;this.nodes.holder.contains(t)||m.isAtEditor||(this.Editor.BlockManager.dropPointer(),this.Editor.Toolbar.close());const i=this.Editor.BlockSettings.nodes.wrapper.contains(t),n=this.Editor.Toolbar.nodes.settingsToggler.contains(t),r=i||n;if(this.Editor.BlockSettings.opened&&!r){this.Editor.BlockSettings.close();const a=this.Editor.BlockManager.getBlockByChildNode(t);this.Editor.Toolbar.moveAndOpen(a)}this.Editor.BlockSelection.clearSelection(e)}documentTouched(e){let t=e.target;if(t===this.nodes.redactor){const o=e instanceof MouseEvent?e.clientX:e.touches[0].clientX,i=e instanceof MouseEvent?e.clientY:e.touches[0].clientY;t=document.elementFromPoint(o,i)}try{this.Editor.BlockManager.setCurrentBlockByChildNode(t),this.Editor.BlockManager.highlightCurrentNode()}catch{this.Editor.RectangleSelection.isRectActivated()||this.Editor.Caret.setToTheLastBlock()}this.Editor.Toolbar.moveAndOpen()}redactorClicked(e){const{BlockSelection:t}=this.Editor;if(!m.isCollapsed)return;const o=()=>{e.stopImmediatePropagation(),e.stopPropagation()},i=e.target,n=e.metaKey||e.ctrlKey;if(d.isAnchor(i)&&n){o();const u=i.getAttribute("href"),h=Ht(u);zt(h);return}const r=this.Editor.BlockManager.getBlockByIndex(-1),a=d.offset(r.holder).bottom,l=e.pageY;if(e.target instanceof Element&&e.target.isEqualNode(this.nodes.redactor)&&!t.anyBlockSelected&&a{t=i,o=n}),Promise.resolve().then(async()=>{this.configuration=e,this.validate(),this.init(),await this.start(),await this.render();const{BlockManager:i,Caret:n,UI:r,ModificationsObserver:a}=this.moduleInstances;r.checkEmptiness(),a.enable(),this.configuration.autofocus&&(n.setToBlock(i.blocks[0],n.positions.START),i.highlightCurrentNode()),t()}).catch(i=>{L(`Editor.js is not ready because of ${i}`,"error"),o(i)})}set configuration(e){var o,i;H(e)?this.config={...e}:this.config={holder:e},Ie(!!this.config.holderId,"config.holderId","config.holder"),this.config.holderId&&!this.config.holder&&(this.config.holder=this.config.holderId,this.config.holderId=null),this.config.holder==null&&(this.config.holder="editorjs"),this.config.logLevel||(this.config.logLevel=qe.VERBOSE),At(this.config.logLevel),Ie(!!this.config.initialBlock,"config.initialBlock","config.defaultBlock"),this.config.defaultBlock=this.config.defaultBlock||this.config.initialBlock||"paragraph",this.config.minHeight=this.config.minHeight!==void 0?this.config.minHeight:300;const t={type:this.config.defaultBlock,data:{}};this.config.placeholder=this.config.placeholder||!1,this.config.sanitizer=this.config.sanitizer||{p:!0,b:!0,a:!0},this.config.hideToolbar=this.config.hideToolbar?this.config.hideToolbar:!1,this.config.tools=this.config.tools||{},this.config.i18n=this.config.i18n||{},this.config.data=this.config.data||{blocks:[]},this.config.onReady=this.config.onReady||(()=>{}),this.config.onChange=this.config.onChange||(()=>{}),this.config.inlineToolbar=this.config.inlineToolbar!==void 0?this.config.inlineToolbar:!0,(X(this.config.data)||!this.config.data.blocks||this.config.data.blocks.length===0)&&(this.config.data={blocks:[t]}),this.config.readOnly=this.config.readOnly||!1,(o=this.config.i18n)!=null&&o.messages&&$.setDictionary(this.config.i18n.messages),this.config.i18n.direction=((i=this.config.i18n)==null?void 0:i.direction)||"ltr"}get configuration(){return this.config}validate(){const{holderId:e,holder:t}=this.config;if(e&&t)throw Error("«holderId» and «holder» param can't assign at the same time.");if(Q(t)&&!d.get(t))throw Error(`element with ID «${t}» is missing. Pass correct holder's ID.`);if(t&&H(t)&&!d.isElement(t))throw Error("«holder» value must be an Element node")}init(){this.constructModules(),this.configureModules()}async start(){await["Tools","UI","BlockManager","Paste","BlockSelection","RectangleSelection","CrossBlockSelection","ReadOnly"].reduce((t,o)=>t.then(async()=>{try{await this.moduleInstances[o].prepare()}catch(i){if(i instanceof it)throw new Error(i.message);L(`Module ${o} was skipped because of %o`,"warn",i)}}),Promise.resolve())}render(){return this.moduleInstances.Renderer.render(this.config.data.blocks)}constructModules(){Object.entries(hi).forEach(([e,t])=>{try{this.moduleInstances[e]=new t({config:this.configuration,eventsDispatcher:this.eventsDispatcher})}catch(o){L("[constructModules]",`Module ${e} skipped because`,"error",o)}})}configureModules(){for(const e in this.moduleInstances)Object.prototype.hasOwnProperty.call(this.moduleInstances,e)&&(this.moduleInstances[e].state=this.getModulesDiff(e))}getModulesDiff(e){const t={};for(const o in this.moduleInstances)o!==e&&(t[o]=this.moduleInstances[o]);return t}}/** + * Editor.js + * + * @license Apache-2.0 + * @see Editor.js + * @author CodeX Team + */class pi{static get version(){return"2.28.2"}constructor(e){let t=()=>{};H(e)&&D(e.onReady)&&(t=e.onReady);const o=new ui(e);this.isReady=o.isReady.then(()=>{this.exportAPI(o),t()})}exportAPI(e){const t=["configuration"],o=()=>{Object.values(e.moduleInstances).forEach(n=>{D(n.destroy)&&n.destroy(),n.listeners.removeAll()}),e=null;for(const n in this)Object.prototype.hasOwnProperty.call(this,n)&&delete this[n];Object.setPrototypeOf(this,null)};t.forEach(n=>{this[n]=e[n]}),this.destroy=o,Object.setPrototypeOf(this,e.moduleInstances.API.methods),delete this.exportAPI,Object.entries({blocks:{clear:"clear",render:"render"},caret:{focus:"focus"},events:{on:"on",off:"off",emit:"emit"},saver:{save:"save"}}).forEach(([n,r])=>{Object.entries(r).forEach(([a,l])=>{this[l]=e.moduleInstances.API.methods[n][a]})})}}return pi}); diff --git a/app/src/main/assets/editor/embed.umd.js b/app/src/main/assets/editor/embed.umd.js new file mode 100644 index 0000000..965d485 --- /dev/null +++ b/app/src/main/assets/editor/embed.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.embed-tool--loading .embed-tool__caption{display:none}.embed-tool--loading .embed-tool__preloader{display:block}.embed-tool--loading .embed-tool__content{display:none}.embed-tool__preloader{display:none;position:relative;height:200px;box-sizing:border-box;border-radius:5px;border:1px solid #e6e9eb}.embed-tool__preloader:before{content:"";position:absolute;z-index:3;left:50%;top:50%;width:30px;height:30px;margin-top:-25px;margin-left:-15px;border-radius:50%;border:2px solid #cdd1e0;border-top-color:#388ae5;box-sizing:border-box;animation:embed-preloader-spin 2s infinite linear}.embed-tool__url{position:absolute;bottom:20px;left:50%;transform:translate(-50%);max-width:250px;color:#7b7e89;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.embed-tool__content{width:100%}.embed-tool__caption{margin-top:7px}.embed-tool__caption[contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.embed-tool__caption[contentEditable=true][data-placeholder]:empty:before{opacity:1}.embed-tool__caption[contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}@keyframes embed-preloader-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}')),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})(); +(function(c,u){typeof exports=="object"&&typeof module<"u"?module.exports=u():typeof define=="function"&&define.amd?define(u):(c=typeof globalThis<"u"?globalThis:c||self,c.Embed=u())})(this,function(){"use strict";const c={vimeo:{regex:/(?:http[s]?:\/\/)?(?:www.)?(?:player.)?vimeo\.co(?:.+\/([^\/]\d+)(?:#t=[\d]+)?s?$)/,embedUrl:"https://player.vimeo.com/video/<%= remote_id %>?title=0&byline=0",html:'',height:320,width:580},youtube:{regex:/(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,embedUrl:"https://www.youtube.com/embed/<%= remote_id %>",html:'',height:320,width:580,id:([o,t])=>{if(!t&&o)return o;const i={start:"start",end:"end",t:"start",time_continue:"start",list:"list"};return t=t.slice(1).split("&").map(e=>{const[s,n]=e.split("=");return!o&&s==="v"?(o=n,null):!i[s]||n==="LL"||n.startsWith("RDMM")||n.startsWith("FL")?null:`${i[s]}=${n}`}).filter(e=>!!e),o+"?"+t.join("&")}},coub:{regex:/https?:\/\/coub\.com\/view\/([^\/\?\&]+)/,embedUrl:"https://coub.com/embed/<%= remote_id %>",html:'',height:320,width:580},vine:{regex:/https?:\/\/vine\.co\/v\/([^\/\?\&]+)/,embedUrl:"https://vine.co/v/<%= remote_id %>/embed/simple/",html:'',height:320,width:580},imgur:{regex:/https?:\/\/(?:i\.)?imgur\.com.*\/([a-zA-Z0-9]+)(?:\.gifv)?/,embedUrl:"http://imgur.com/<%= remote_id %>/embed",html:'',height:500,width:540},gfycat:{regex:/https?:\/\/gfycat\.com(?:\/detail)?\/([a-zA-Z]+)/,embedUrl:"https://gfycat.com/ifr/<%= remote_id %>",html:``,height:436,width:580},"twitch-channel":{regex:/https?:\/\/www\.twitch\.tv\/([^\/\?\&]*)\/?$/,embedUrl:"https://player.twitch.tv/?channel=<%= remote_id %>",html:'',height:366,width:600},"twitch-video":{regex:/https?:\/\/www\.twitch\.tv\/(?:[^\/\?\&]*\/v|videos)\/([0-9]*)/,embedUrl:"https://player.twitch.tv/?video=v<%= remote_id %>",html:'',height:366,width:600},"yandex-music-album":{regex:/https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/?$/,embedUrl:"https://music.yandex.ru/iframe/#album/<%= remote_id %>/",html:'',height:400,width:540},"yandex-music-track":{regex:/https?:\/\/music\.yandex\.ru\/album\/([0-9]*)\/track\/([0-9]*)/,embedUrl:"https://music.yandex.ru/iframe/#track/<%= remote_id %>/",html:'',height:100,width:540,id:o=>o.join("/")},"yandex-music-playlist":{regex:/https?:\/\/music\.yandex\.ru\/users\/([^\/\?\&]*)\/playlists\/([0-9]*)/,embedUrl:"https://music.yandex.ru/iframe/#playlist/<%= remote_id %>/show/cover/description/",html:'',height:400,width:540,id:o=>o.join("/")},codepen:{regex:/https?:\/\/codepen\.io\/([^\/\?\&]*)\/pen\/([^\/\?\&]*)/,embedUrl:"https://codepen.io/<%= remote_id %>?height=300&theme-id=0&default-tab=css,result&embed-version=2",html:"",height:300,width:600,id:o=>o.join("/embed/")},instagram:{regex:/https?:\/\/www\.instagram\.com\/p\/([^\/\?\&]+)\/?.*/,embedUrl:"https://www.instagram.com/p/<%= remote_id %>/embed",html:'',height:505,width:400},twitter:{regex:/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+?.*)?$/,embedUrl:"https://twitframe.com/show?url=https://twitter.com/<%= remote_id %>",html:'',height:300,width:600,id:o=>o.join("/status/")},pinterest:{regex:/https?:\/\/([^\/\?\&]*).pinterest.com\/pin\/([^\/\?\&]*)\/?$/,embedUrl:"https://assets.pinterest.com/ext/embed.html?id=<%= remote_id %>",html:"",id:o=>o[1]},facebook:{regex:/https?:\/\/www.facebook.com\/([^\/\?\&]*)\/(.*)/,embedUrl:"https://www.facebook.com/plugins/post.php?href=https://www.facebook.com/<%= remote_id %>&width=500",html:"",id:o=>o.join("/")},aparat:{regex:/(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,embedUrl:"https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame",html:'',height:300,width:600},miro:{regex:/https:\/\/miro.com\/\S+(\S{12})\/(\S+)?/,embedUrl:"https://miro.com/app/live-embed/<%= remote_id %>",html:''},github:{regex:/https?:\/\/gist.github.com\/([^\/\?\&]*)\/([^\/\?\&]*)/,embedUrl:'data:text/html;charset=utf-8, + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/app/src/main/assets/editor/inline-code.umd.js b/app/src/main/assets/editor/inline-code.umd.js new file mode 100644 index 0000000..dcd9052 --- /dev/null +++ b/app/src/main/assets/editor/inline-code.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".inline-code{background:rgba(250,239,240,.78);color:#b44437;padding:3px 4px;border-radius:5px;margin:0 1px;font-family:inherit;font-size:.86em;font-weight:500;letter-spacing:.3px}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})(); +(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.InlineCode=s())})(this,function(){"use strict";const i="",s='';class n{static get CSS(){return"inline-code"}constructor({api:t}){this.api=t,this.button=null,this.tag="CODE",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{code:{class:n.CSS}}}}return n}); diff --git a/app/src/main/assets/editor/list.umd.js b/app/src/main/assets/editor/list.umd.js new file mode 100644 index 0000000..391ba33 --- /dev/null +++ b/app/src/main/assets/editor/list.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-list{margin:0;padding-left:40px;outline:none}.cdx-list__item{padding:5.5px 0 5.5px 3px;line-height:1.6em}.cdx-list--unordered{list-style:disc}.cdx-list--ordered{list-style:decimal}.cdx-list-settings{display:flex}.cdx-list-settings .cdx-settings-button{width:50%}")),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})(); +(function(o,n){typeof exports=="object"&&typeof module<"u"?module.exports=n():typeof define=="function"&&define.amd?define(n):(o=typeof globalThis<"u"?globalThis:o||self,o.List=n())})(this,function(){"use strict";const o="",n='',l='';class d{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:n,title:"List"}}constructor({data:e,config:t,api:r,readOnly:s}){this._elements={wrapper:null},this.api=r,this.readOnly=s,this.settings=[{name:"unordered",label:this.api.i18n.t("Unordered"),icon:n,default:t.defaultStyle==="unordered"||!1},{name:"ordered",label:this.api.i18n.t("Ordered"),icon:l,default:t.defaultStyle==="ordered"||!0}],this._data={style:this.settings.find(i=>i.default===!0).name,items:[]},this.data=e}render(){return this._elements.wrapper=this.makeMainTag(this._data.style),this._data.items.length?this._data.items.forEach(e=>{this._elements.wrapper.appendChild(this._make("li",this.CSS.item,{innerHTML:e}))}):this._elements.wrapper.appendChild(this._make("li",this.CSS.item)),this.readOnly||this._elements.wrapper.addEventListener("keydown",e=>{const[t,r]=[13,8];switch(e.keyCode){case t:this.getOutofList(e);break;case r:this.backspace(e);break}},!1),this._elements.wrapper}save(){return this.data}static get conversionConfig(){return{export:e=>e.items.join(". "),import:e=>({items:[e],style:"unordered"})}}static get sanitize(){return{style:{},items:{br:!0}}}renderSettings(){return this.settings.map(e=>({...e,isActive:this._data.style===e.name,closeOnActivate:!0,onActivate:()=>this.toggleTune(e.name)}))}onPaste(e){const t=e.detail.data;this.data=this.pasteHandler(t)}static get pasteConfig(){return{tags:["OL","UL","LI"]}}makeMainTag(e){const t=e==="ordered"?this.CSS.wrapperOrdered:this.CSS.wrapperUnordered,r=e==="ordered"?"ol":"ul";return this._make(r,[this.CSS.baseBlock,this.CSS.wrapper,t],{contentEditable:!this.readOnly})}toggleTune(e){const t=this.makeMainTag(e);for(;this._elements.wrapper.hasChildNodes();)t.appendChild(this._elements.wrapper.firstChild);this._elements.wrapper.replaceWith(t),this._elements.wrapper=t,this._data.style=e}get CSS(){return{baseBlock:this.api.styles.block,wrapper:"cdx-list",wrapperOrdered:"cdx-list--ordered",wrapperUnordered:"cdx-list--unordered",item:"cdx-list__item"}}set data(e){e||(e={}),this._data.style=e.style||this.settings.find(r=>r.default===!0).name,this._data.items=e.items||[];const t=this._elements.wrapper;t&&t.parentNode.replaceChild(this.render(),t)}get data(){this._data.items=[];const e=this._elements.wrapper.querySelectorAll(`.${this.CSS.item}`);for(let t=0;t"," ").trim()&&this._data.items.push(e[t].innerHTML);return this._data}_make(e,t=null,r={}){const s=document.createElement(e);Array.isArray(t)?s.classList.add(...t):t&&s.classList.add(t);for(const i in r)s[i]=r[i];return s}get currentItem(){let e=window.getSelection().anchorNode;return e.nodeType!==Node.ELEMENT_NODE&&(e=e.parentNode),e.closest(`.${this.CSS.item}`)}getOutofList(e){const t=this._elements.wrapper.querySelectorAll("."+this.CSS.item);if(t.length<2)return;const r=t[t.length-1],s=this.currentItem;s===r&&!r.textContent.trim().length&&(s.parentElement.removeChild(s),this.api.blocks.insert(),this.api.caret.setToBlock(this.api.blocks.getCurrentBlockIndex()),e.preventDefault(),e.stopPropagation())}backspace(e){const t=this._elements.wrapper.querySelectorAll("."+this.CSS.item),r=t[0];r&&t.length<2&&!r.innerHTML.replace("
"," ").trim()&&e.preventDefault()}selectItem(e){e.preventDefault();const t=window.getSelection(),r=t.anchorNode.parentNode,s=r.closest("."+this.CSS.item),i=new Range;i.selectNodeContents(s),t.removeAllRanges(),t.addRange(i)}pasteHandler(e){const{tagName:t}=e;let r;switch(t){case"OL":r="ordered";break;case"UL":case"LI":r="unordered"}const s={style:r,items:[]};if(t==="LI")s.items=[e.innerHTML];else{const i=Array.from(e.querySelectorAll("LI"));s.items=i.map(a=>a.innerHTML).filter(a=>!!a.trim())}return s}}return d}); diff --git a/app/src/main/assets/editor/marker.umd.js b/app/src/main/assets/editor/marker.umd.js new file mode 100644 index 0000000..2824ac5 --- /dev/null +++ b/app/src/main/assets/editor/marker.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-marker{background:rgba(245,235,111,.29);padding:3px 0}")),document.head.appendChild(e)}}catch(d){console.error("vite-plugin-css-injected-by-js",d)}})(); +(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.Marker=s())})(this,function(){"use strict";const i="",s='';class n{static get CSS(){return"cdx-marker"}constructor({api:t}){this.api=t,this.button=null,this.tag="MARK",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{mark:{class:n.CSS}}}}return n}); diff --git a/app/src/main/assets/editor/quote.umd.js b/app/src/main/assets/editor/quote.umd.js new file mode 100644 index 0000000..3e8e09b --- /dev/null +++ b/app/src/main/assets/editor/quote.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var t=document.createElement("style");t.appendChild(document.createTextNode(".cdx-quote-icon svg{transform:rotate(180deg)}.cdx-quote{margin:0}.cdx-quote__text{min-height:158px;margin-bottom:10px}.cdx-quote [contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.cdx-quote [contentEditable=true][data-placeholder]:empty:before{opacity:1}.cdx-quote [contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}.cdx-quote-settings{display:flex}.cdx-quote-settings .cdx-settings-button{width:50%}")),document.head.appendChild(t)}}catch(e){console.error("vite-plugin-css-injected-by-js",e)}})(); +(function(s,o){typeof exports=="object"&&typeof module<"u"?module.exports=o():typeof define=="function"&&define.amd?define(o):(s=typeof globalThis<"u"?globalThis:s||self,s.Quote=o())})(this,function(){"use strict";const s="",o='',c='',l='';class i{static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:l,title:"Quote"}}static get contentless(){return!0}static get enableLineBreaks(){return!0}static get DEFAULT_QUOTE_PLACEHOLDER(){return"Enter a quote"}static get DEFAULT_CAPTION_PLACEHOLDER(){return"Enter a caption"}static get ALIGNMENTS(){return{left:"left",center:"center"}}static get DEFAULT_ALIGNMENT(){return i.ALIGNMENTS.left}static get conversionConfig(){return{import:"text",export:function(t){return t.caption?`${t.text} — ${t.caption}`:t.text}}}get CSS(){return{baseClass:this.api.styles.block,wrapper:"cdx-quote",text:"cdx-quote__text",input:this.api.styles.input,caption:"cdx-quote__caption"}}get settings(){return[{name:"left",icon:c},{name:"center",icon:o}]}constructor({data:t,config:e,api:n,readOnly:r}){const{ALIGNMENTS:a,DEFAULT_ALIGNMENT:d}=i;this.api=n,this.readOnly=r,this.quotePlaceholder=e.quotePlaceholder||i.DEFAULT_QUOTE_PLACEHOLDER,this.captionPlaceholder=e.captionPlaceholder||i.DEFAULT_CAPTION_PLACEHOLDER,this.data={text:t.text||"",caption:t.caption||"",alignment:Object.values(a).includes(t.alignment)&&t.alignment||e.defaultAlignment||d}}render(){const t=this._make("blockquote",[this.CSS.baseClass,this.CSS.wrapper]),e=this._make("div",[this.CSS.input,this.CSS.text],{contentEditable:!this.readOnly,innerHTML:this.data.text}),n=this._make("div",[this.CSS.input,this.CSS.caption],{contentEditable:!this.readOnly,innerHTML:this.data.caption});return e.dataset.placeholder=this.quotePlaceholder,n.dataset.placeholder=this.captionPlaceholder,t.appendChild(e),t.appendChild(n),t}save(t){const e=t.querySelector(`.${this.CSS.text}`),n=t.querySelector(`.${this.CSS.caption}`);return Object.assign(this.data,{text:e.innerHTML,caption:n.innerHTML})}static get sanitize(){return{text:{br:!0},caption:{br:!0},alignment:{}}}renderSettings(){const t=e=>e[0].toUpperCase()+e.substr(1);return this.settings.map(e=>({icon:e.icon,label:this.api.i18n.t(`Align ${t(e.name)}`),onActivate:()=>this._toggleTune(e.name),isActive:this.data.alignment===e.name,closeOnActivate:!0}))}_toggleTune(t){this.data.alignment=t}_make(t,e=null,n={}){const r=document.createElement(t);Array.isArray(e)?r.classList.add(...e):e&&r.classList.add(e);for(const a in n)r[a]=n[a];return r}}return i}); diff --git a/app/src/main/assets/editor/style.css b/app/src/main/assets/editor/style.css new file mode 100644 index 0000000..6ef11d8 --- /dev/null +++ b/app/src/main/assets/editor/style.css @@ -0,0 +1,253 @@ +:root { + --bg-color: #ffffff; + --text-color: #000000; + --code-bg: #f6f8fa; + --border-color: #e5e7eb; + --table-header-bg: #f9fafb; + --scrollbar-track: #f1f1f1; + --scrollbar-thumb: #c1c1c1; + --scrollbar-thumb-hover: #a1a1a1; + --placeholder-color: #9ca3af; + --quote-color: #4b5563; + --caption-color: #6b7280; +} + +html.dark-mode { + --bg-color: #1a1a1a; + --text-color: #e0e0e0; + --code-bg: #2d2d2d; + --border-color: #404040; + --table-header-bg: #2a2a2a; + --scrollbar-track: #2d2d2d; + --scrollbar-thumb: #555555; + --scrollbar-thumb-hover: #666666; + --placeholder-color: #6b7280; + --quote-color: #9ca3af; + --caption-color: #9ca3af; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + transition: background-color 0.3s, color 0.3s; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* Editor.js container */ +#editorjs { + min-height: calc(100vh - 100px); + padding: 16px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.ce-block__content { + max-width: 100%; +} + +.ce-toolbar__content { + max-width: 100%; +} + +/* Editor.js table styles */ +.tc-table { + display: block; + max-width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.tc-row { + position: relative; +} + +.tc-table__cell { + border: 1px solid var(--border-color); + padding: 8px 12px; + min-width: 50px; + color: var(--text-color); + position: relative; +} + +.tc-table__header { + background: var(--table-header-bg); + font-weight: 600; + color: var(--text-color); +} + +/* Editor.js code block */ +.ce-code { + background: var(--code-bg); + color: var(--text-color); + border-radius: 6px; + padding: 16px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace; + font-size: 14px; + line-height: 1.5; + overflow-x: auto; +} + +/* Editor.js quote */ +.ce-quote { + border-left: 3px solid var(--border-color); + padding-left: 16px; + margin: 0.5em 0; + color: var(--quote-color); +} + +/* Editor.js checklist */ +.ce-checklist__item { + display: flex; + align-items: center; + margin: 0.25em 0; +} + +.ce-checklist__item-checkbox { + margin-right: 8px; +} + +/* Undo/Redo floating buttons */ +.undo-redo-container { + position: fixed; + top: 16px; + right: 16px; + display: flex; + gap: 8px; + z-index: 1000; + pointer-events: none; +} + +.undo-btn, .redo-btn { + pointer-events: auto; + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-color) 85%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + opacity: 0.65; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + cursor: pointer; + transition: opacity 0.2s, background 0.2s; + color: var(--text-color); + -webkit-tap-highlight-color: transparent; + user-select: none; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); + padding: 0; + line-height: 1; +} + +html.dark-mode .undo-btn, +html.dark-mode .redo-btn { + background: color-mix(in srgb, var(--bg-color) 80%, transparent); + box-shadow: 0 1px 4px rgba(0,0,0,0.3); +} + +.undo-btn:active, .redo-btn:active { + opacity: 0.9; + background: var(--border-color); +} + +.undo-btn[disabled], .redo-btn[disabled] { + opacity: 0.15; + pointer-events: none; +} + +/* Editor.js delimiter */ +.ce-delimiter { + margin: 1.5em 0; + border: none; + border-top: 2px solid var(--border-color); + padding: 0; + height: 0; + overflow: hidden; +} + +.ce-delimiter::before, +.ce-delimiter::after { + display: none !important; +} + +.ce-delimiter * { + display: none !important; +} + +.ce-delimiter__content { + display: none !important; +} + +.ce-delimiter__content * { + display: none !important; +} + +/* Editor.js header styles */ +.ce-header[data-item='1'], +.ce-header[style*="font-size: 32px"], +.ce-header h1, +[data-tool="header1"] .ce-header { + font-size: 2em; + font-weight: 700; + margin: 0.67em 0; +} + +.ce-header[data-item='2'], +.ce-header[style*="font-size: 24px"], +.ce-header h2, +[data-tool="header2"] .ce-header { + font-size: 1.5em; + font-weight: 600; + margin: 0.75em 0; +} + +.ce-header[data-item='3'], +.ce-header[style*="font-size: 20px"], +.ce-header h3, +[data-tool="header3"] .ce-header { + font-size: 1.25em; + font-weight: 600; + margin: 0.83em 0; +} + +/* Custom File Tool (Attachments) */ +.custom-file-tool { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + margin: 8px 0; + background-color: var(--code-bg); +} + + diff --git a/app/src/main/assets/editor/table.umd.js b/app/src/main/assets/editor/table.umd.js new file mode 100644 index 0000000..e37ff3c --- /dev/null +++ b/app/src/main/assets/editor/table.umd.js @@ -0,0 +1,2 @@ +(function(){"use strict";try{if(typeof document<"u"){var o=document.createElement("style");o.appendChild(document.createTextNode('.tc-wrap{--color-background:#f9f9fb;--color-text-secondary:#7b7e89;--color-border:#e8e8eb;--cell-size:34px;--toolbox-icon-size:18px;--toolbox-padding:6px;--toolbox-aiming-field-size:calc(var(--toolbox-icon-size) + var(--toolbox-padding)*2);border-left:0;position:relative;height:100%;width:100%;margin-top:var(--toolbox-icon-size);box-sizing:border-box;display:grid;grid-template-columns:calc(100% - var(--cell-size)) var(--cell-size)}.tc-wrap--readonly{grid-template-columns:100% var(--cell-size)}.tc-wrap svg{vertical-align:top}@media print{.tc-wrap{border-left-color:var(--color-border);border-left-style:solid;border-left-width:1px;grid-template-columns:100% var(--cell-size)}}@media print{.tc-wrap .tc-row:after{display:none}}.tc-table{position:relative;width:100%;height:100%;display:grid;font-size:14px;border-top:1px solid var(--color-border);line-height:1.4}.tc-table:after{width:calc(var(--cell-size));height:100%;left:calc(var(--cell-size)*-1);top:0}.tc-table:after,.tc-table:before{position:absolute;content:""}.tc-table:before{width:100%;height:var(--toolbox-aiming-field-size);top:calc(var(--toolbox-aiming-field-size)*-1);left:0}.tc-table--heading .tc-row:first-child{font-weight:600;border-bottom:2px solid var(--color-border)}.tc-table--heading .tc-row:first-child [contenteditable]:empty:before{content:attr(heading);color:var(--color-text-secondary)}.tc-table--heading .tc-row:first-child:after{bottom:-2px;border-bottom:2px solid var(--color-border)}.tc-add-column,.tc-add-row{display:flex;color:var(--color-text-secondary)}@media print{.tc-add{display:none}}.tc-add-column{padding:4px 0;justify-content:center;border-top:1px solid var(--color-border)}@media print{.tc-add-column{display:none}}.tc-add-row{height:var(--cell-size);align-items:center;padding-left:4px;position:relative}.tc-add-row:before{content:"";position:absolute;right:calc(var(--cell-size)*-1);width:var(--cell-size);height:100%}@media print{.tc-add-row{display:none}}.tc-add-column,.tc-add-row{transition:0s;cursor:pointer;will-change:background-color}.tc-add-column:hover,.tc-add-row:hover{transition:background-color .1s ease;background-color:var(--color-background)}.tc-add-row{margin-top:1px}.tc-add-row:hover:before{transition:.1s;background-color:var(--color-background)}.tc-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(10px,1fr));position:relative;border-bottom:1px solid var(--color-border)}.tc-row:after{content:"";pointer-events:none;position:absolute;width:var(--cell-size);height:100%;bottom:-1px;right:calc(var(--cell-size)*-1);border-bottom:1px solid var(--color-border)}.tc-row--selected{background:var(--color-background)}.tc-row--selected:after{background:var(--color-background)}.tc-cell{border-right:1px solid var(--color-border);padding:6px 12px;overflow:hidden;outline:none;line-break:normal}.tc-cell--selected{background:var(--color-background)}.tc-wrap--readonly .tc-row:after{display:none}.tc-toolbox{--toolbox-padding:6px;--popover-margin:30px;--toggler-click-zone-size:30px;--toggler-dots-color:#7b7e89;--toggler-dots-color-hovered:#1d202b;position:absolute;cursor:pointer;z-index:1;opacity:0;transition:opacity .1s;will-change:left,opacity}.tc-toolbox--column{top:calc(var(--toggler-click-zone-size)*-1);transform:translate(calc(var(--toggler-click-zone-size)*-1/2));will-change:left,opacity}.tc-toolbox--row{left:calc(var(--popover-margin)*-1);transform:translateY(calc(var(--toggler-click-zone-size)*-1/2));margin-top:-1px;will-change:top,opacity}.tc-toolbox--showed{opacity:1}.tc-toolbox .tc-popover{position:absolute;top:0;left:var(--popover-margin)}.tc-toolbox__toggler{display:flex;align-items:center;justify-content:center;width:var(--toggler-click-zone-size);height:var(--toggler-click-zone-size);color:var(--toggler-dots-color);opacity:0;transition:opacity .15s ease;will-change:opacity}.tc-toolbox__toggler:hover{color:var(--toggler-dots-color-hovered)}.tc-toolbox__toggler svg{fill:currentColor}.tc-wrap:hover .tc-toolbox__toggler{opacity:1}.tc-settings .cdx-settings-button{width:50%;margin:0}.tc-popover{--color-border:#eaeaea;--color-background:#fff;--color-background-hover:rgba(232,232,235,.49);--color-background-confirm:#e24a4a;--color-background-confirm-hover:#d54040;--color-text-confirm:#fff;background:var(--color-background);border:1px solid var(--color-border);box-shadow:0 3px 15px -3px #0d142121;border-radius:6px;padding:6px;display:none;will-change:opacity,transform}.tc-popover--opened{display:block;animation:menuShowing .1s cubic-bezier(.215,.61,.355,1) forwards}.tc-popover__item{display:flex;align-items:center;padding:2px 14px 2px 2px;border-radius:5px;cursor:pointer;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;user-select:none}.tc-popover__item:hover{background:var(--color-background-hover)}.tc-popover__item:not(:last-of-type){margin-bottom:2px}.tc-popover__item-icon{display:inline-flex;width:26px;height:26px;align-items:center;justify-content:center;background:var(--color-background);border-radius:5px;border:1px solid var(--color-border);margin-right:8px}.tc-popover__item-label{line-height:22px;font-size:14px;font-weight:500}.tc-popover__item--confirm{background:var(--color-background-confirm);color:var(--color-text-confirm)}.tc-popover__item--confirm:hover{background-color:var(--color-background-confirm-hover)}.tc-popover__item--confirm .tc-popover__item-icon{background:var(--color-background-confirm);border-color:#0000001a}.tc-popover__item--confirm .tc-popover__item-icon svg{transition:transform .2s ease-in;transform:rotate(90deg) scale(1.2)}.tc-popover__item--hidden{display:none}@keyframes menuShowing{0%{opacity:0;transform:translateY(-8px) scale(.9)}70%{opacity:1;transform:translateY(2px)}to{transform:translateY(0)}}')),document.head.appendChild(o)}}catch(r){console.error("vite-plugin-css-injected-by-js",r)}})(); +(function(d,w){typeof exports=="object"&&typeof module<"u"?module.exports=w():typeof define=="function"&&define.amd?define(w):(d=typeof globalThis<"u"?globalThis:d||self,d.Table=w())})(this,function(){"use strict";function d(a,e,t={}){const o=document.createElement(a);Array.isArray(e)?o.classList.add(...e):e&&o.classList.add(e);for(const i in t)Object.prototype.hasOwnProperty.call(t,i)&&(o[i]=t[i]);return o}function w(a){const e=a.getBoundingClientRect();return{y1:Math.floor(e.top+window.pageYOffset),x1:Math.floor(e.left+window.pageXOffset),x2:Math.floor(e.right+window.pageXOffset),y2:Math.floor(e.bottom+window.pageYOffset)}}function g(a,e){const t=w(a),o=w(e);return{fromTopBorder:o.y1-t.y1,fromLeftBorder:o.x1-t.x1,fromRightBorder:t.x2-o.x2,fromBottomBorder:t.y2-o.y2}}function R(a,e){const t=a.getBoundingClientRect(),{width:o,height:i,x:r,y:n}=t,{clientX:h,clientY:l}=e;return{width:o,height:i,x:h-r,y:l-n}}function C(a,e){return e.parentNode.insertBefore(a,e)}function m(a,e=!0){const t=document.createRange(),o=window.getSelection();t.selectNodeContents(a),t.collapse(e),o.removeAllRanges(),o.addRange(t)}class c{constructor({items:e}){this.items=e,this.wrapper=void 0,this.itemEls=[]}static get CSS(){return{popover:"tc-popover",popoverOpened:"tc-popover--opened",item:"tc-popover__item",itemHidden:"tc-popover__item--hidden",itemConfirmState:"tc-popover__item--confirm",itemIcon:"tc-popover__item-icon",itemLabel:"tc-popover__item-label"}}render(){return this.wrapper=d("div",c.CSS.popover),this.items.forEach((e,t)=>{const o=d("div",c.CSS.item),i=d("div",c.CSS.itemIcon,{innerHTML:e.icon}),r=d("div",c.CSS.itemLabel,{textContent:e.label});o.dataset.index=t,o.appendChild(i),o.appendChild(r),this.wrapper.appendChild(o),this.itemEls.push(o)}),this.wrapper.addEventListener("click",e=>{this.popoverClicked(e)}),this.wrapper}popoverClicked(e){const t=e.target.closest(`.${c.CSS.item}`);if(!t)return;const o=t.dataset.index,i=this.items[o];if(i.confirmationRequired&&!this.hasConfirmationState(t)){this.setConfirmationState(t);return}i.onClick()}setConfirmationState(e){e.classList.add(c.CSS.itemConfirmState)}clearConfirmationState(e){e.classList.remove(c.CSS.itemConfirmState)}hasConfirmationState(e){return e.classList.contains(c.CSS.itemConfirmState)}get opened(){return this.wrapper.classList.contains(c.CSS.popoverOpened)}open(){this.items.forEach((e,t)=>{typeof e.hideIf=="function"&&this.itemEls[t].classList.toggle(c.CSS.itemHidden,e.hideIf())}),this.wrapper.classList.add(c.CSS.popoverOpened)}close(){this.wrapper.classList.remove(c.CSS.popoverOpened),this.itemEls.forEach(e=>{this.clearConfirmationState(e)})}}const b='',k='',S='',x='',y='',T='',v='',L='',M='',O='';class u{constructor({api:e,items:t,onOpen:o,onClose:i,cssModifier:r=""}){this.api=e,this.items=t,this.onOpen=o,this.onClose=i,this.cssModifier=r,this.popover=null,this.wrapper=this.createToolbox()}static get CSS(){return{toolbox:"tc-toolbox",toolboxShowed:"tc-toolbox--showed",toggler:"tc-toolbox__toggler"}}get element(){return this.wrapper}createToolbox(){const e=d("div",[u.CSS.toolbox,this.cssModifier?`${u.CSS.toolbox}--${this.cssModifier}`:""]);e.dataset.mutationFree="true";const t=this.createPopover(),o=this.createToggler();return e.appendChild(o),e.appendChild(t),e}createToggler(){const e=d("div",u.CSS.toggler,{innerHTML:T});return e.addEventListener("click",()=>{this.togglerClicked()}),e}createPopover(){return this.popover=new c({items:this.items}),this.popover.render()}togglerClicked(){this.popover.opened?(this.popover.close(),this.onClose()):(this.popover.open(),this.onOpen())}show(e){const t=e();Object.entries(t).forEach(([o,i])=>{this.wrapper.style[o]=i}),this.wrapper.classList.add(u.CSS.toolboxShowed)}hide(){this.popover.close(),this.wrapper.classList.remove(u.CSS.toolboxShowed)}}function H(a,e){let t=0;return function(...o){const i=new Date().getTime();if(!(i-t{const n=r.target.closest(`.${s.table}`)!==null,h=r.target.closest(`.${s.wrapper}`)===null;(n||h)&&this.hideToolboxes();const p=r.target.closest(`.${s.addRow}`),f=r.target.closest(`.${s.addColumn}`);p&&p.parentNode===this.wrapper?(this.addRow(void 0,!0),this.hideToolboxes()):f&&f.parentNode===this.wrapper&&(this.addColumn(void 0,!0),this.hideToolboxes())},this.readOnly||this.bindEvents()}getWrapper(){return this.wrapper}bindEvents(){document.addEventListener("click",this.documentClicked),this.table.addEventListener("mousemove",H(150,e=>this.onMouseMoveInTable(e)),{passive:!0}),this.table.onkeypress=e=>this.onKeyPressListener(e),this.table.addEventListener("keydown",e=>this.onKeyDownListener(e)),this.table.addEventListener("focusin",e=>this.focusInTableListener(e))}createColumnToolbox(){return new u({api:this.api,cssModifier:"column",items:[{label:this.api.i18n.t("Add column to left"),icon:S,onClick:()=>{this.addColumn(this.selectedColumn,!0),this.hideToolboxes()}},{label:this.api.i18n.t("Add column to right"),icon:x,onClick:()=>{this.addColumn(this.selectedColumn+1,!0),this.hideToolboxes()}}],onOpen:()=>{this.selectColumn(this.hoveredColumn),this.hideRowToolbox()},onClose:()=>{this.unselectColumn()}})}createRowToolbox(){return new u({api:this.api,cssModifier:"row",items:[{label:this.api.i18n.t("Add row above"),icon:y,onClick:()=>{this.addRow(this.selectedRow,!0),this.hideToolboxes()}},{label:this.api.i18n.t("Add row below"),icon:k,onClick:()=>{this.addRow(this.selectedRow+1,!0),this.hideToolboxes()}}],onOpen:()=>{this.selectRow(this.hoveredRow),this.hideColumnToolbox()},onClose:()=>{this.unselectRow()}})}moveCursorToNextRow(){this.focusedCell.row!==this.numberOfRows?(this.focusedCell.row+=1,this.focusCell(this.focusedCell)):(this.addRow(),this.focusedCell.row+=1,this.focusCell(this.focusedCell),this.updateToolboxesPosition(0,0))}getCell(e,t){return this.table.querySelectorAll(`.${s.row}:nth-child(${e}) .${s.cell}`)[t-1]}getRow(e){return this.table.querySelector(`.${s.row}:nth-child(${e})`)}getRowByCell(e){return e.parentElement}getRowFirstCell(e){return e.querySelector(`.${s.cell}:first-child`)}setCellContent(e,t,o){const i=this.getCell(e,t);i.innerHTML=o}addColumn(e=-1,t=!1){let o=this.numberOfColumns;for(let i=1;i<=this.numberOfRows;i++){let r;const n=this.createCell();if(e>0&&e<=o?(r=this.getCell(i,e),C(n,r)):r=this.getRow(i).appendChild(n),i===1){const h=this.getCell(i,e>0?e:o+1);h&&t&&m(h)}}this.addHeadingAttrToFirstRow()}addRow(e=-1,t=!1){let o,i=d("div",s.row);this.tunes.withHeadings&&this.removeHeadingAttrFromFirstRow();let r=this.numberOfColumns;if(e>0&&e<=this.numberOfRows){let h=this.getRow(e);o=C(i,h)}else o=this.table.appendChild(i);this.fillRow(o,r),this.tunes.withHeadings&&this.addHeadingAttrToFirstRow();const n=this.getRowFirstCell(o);return n&&t&&m(n),o}deleteColumn(e){for(let t=1;t<=this.numberOfRows;t++){const o=this.getCell(t,e);if(!o)return;o.remove()}}deleteRow(e){this.getRow(e).remove(),this.addHeadingAttrToFirstRow()}createTableWrapper(){if(this.wrapper=d("div",s.wrapper),this.table=d("div",s.table),this.readOnly&&this.wrapper.classList.add(s.wrapperReadOnly),this.wrapper.appendChild(this.toolboxRow.element),this.wrapper.appendChild(this.toolboxColumn.element),this.wrapper.appendChild(this.table),!this.readOnly){const e=d("div",s.addColumn,{innerHTML:v}),t=d("div",s.addRow,{innerHTML:v});this.wrapper.appendChild(e),this.wrapper.appendChild(t)}}computeInitialSize(){const e=this.data&&this.data.content,t=Array.isArray(e),o=t?e.length:!1,i=t?e.length:void 0,r=o?e[0].length:void 0,n=Number.parseInt(this.config&&this.config.rows),h=Number.parseInt(this.config&&this.config.cols),l=!isNaN(n)&&n>0?n:void 0,p=!isNaN(h)&&h>0?h:void 0;return{rows:i||l||2,cols:r||p||2}}resize(){const{rows:e,cols:t}=this.computeInitialSize();for(let o=0;o0&&t<=this.numberOfColumns&&this.toolboxColumn.show(()=>({left:`calc((100% - var(--cell-size)) / (${this.numberOfColumns} * 2) * (1 + (${t} - 1) * 2))`})),this.isRowMenuShowing||e>0&&e<=this.numberOfRows&&this.toolboxRow.show(()=>{const o=this.getRow(e),{fromTopBorder:i}=g(this.table,o),{height:r}=o.getBoundingClientRect();return{top:`${Math.ceil(i+r/2)}px`}})}setHeadingsSetting(e){this.tunes.withHeadings=e,e?(this.table.classList.add(s.withHeadings),this.addHeadingAttrToFirstRow()):(this.table.classList.remove(s.withHeadings),this.removeHeadingAttrFromFirstRow())}addHeadingAttrToFirstRow(){for(let e=1;e<=this.numberOfColumns;e++){let t=this.getCell(1,e);t&&t.setAttribute("heading",this.api.i18n.t("Heading"))}}removeHeadingAttrFromFirstRow(){for(let e=1;e<=this.numberOfColumns;e++){let t=this.getCell(1,e);t&&t.removeAttribute("heading")}}selectRow(e){const t=this.getRow(e);t&&(this.selectedRow=e,t.classList.add(s.rowSelected))}unselectRow(){if(this.selectedRow<=0)return;const e=this.table.querySelector(`.${s.rowSelected}`);e&&e.classList.remove(s.rowSelected),this.selectedRow=0}selectColumn(e){for(let t=1;t<=this.numberOfRows;t++){const o=this.getCell(t,e);o&&o.classList.add(s.cellSelected)}this.selectedColumn=e}unselectColumn(){if(this.selectedColumn<=0)return;let e=this.table.querySelectorAll(`.${s.cellSelected}`);Array.from(e).forEach(t=>{t.classList.remove(s.cellSelected)}),this.selectedColumn=0}getHoveredCell(e){let t=this.hoveredRow,o=this.hoveredColumn;const{width:i,height:r,x:n,y:h}=R(this.table,e);return n>=0&&(o=this.binSearch(this.numberOfColumns,l=>this.getCell(1,l),({fromLeftBorder:l})=>nn>i-l)),h>=0&&(t=this.binSearch(this.numberOfRows,l=>this.getCell(l,1),({fromTopBorder:l})=>hh>r-l)),{row:t||this.hoveredRow,column:o||this.hoveredColumn}}binSearch(e,t,o,i){let r=0,n=e+1,h=0,l;for(;r!n.textContent.trim())||e.push(i.map(n=>n.innerHTML))}return e}destroy(){document.removeEventListener("click",this.documentClicked)}}class E{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}constructor({data:e,config:t,api:o,readOnly:i}){this.api=o,this.readOnly=i,this.config=t,this.data={withHeadings:this.getConfig("withHeadings",!1,e),content:e&&e.content?e.content:[]},this.table=null}static get toolbox(){return{icon:O,title:"Table"}}render(){return this.table=new A(this.readOnly,this.api,this.data,this.config),this.container=d("div",this.api.styles.block),this.container.appendChild(this.table.getWrapper()),this.table.setHeadingsSetting(this.data.withHeadings),this.container}renderSettings(){return[{label:this.api.i18n.t("With headings"),icon:L,isActive:this.data.withHeadings,closeOnActivate:!0,toggle:!0,onActivate:()=>{this.data.withHeadings=!0,this.table.setHeadingsSetting(this.data.withHeadings)}},{label:this.api.i18n.t("Without headings"),icon:M,isActive:!this.data.withHeadings,closeOnActivate:!0,toggle:!0,onActivate:()=>{this.data.withHeadings=!1,this.table.setHeadingsSetting(this.data.withHeadings)}}]}save(){const e=this.table.getData();return{withHeadings:this.data.withHeadings,content:e}}destroy(){this.table.destroy()}getConfig(e,t=void 0,o=void 0){const i=this.data||o;return i?i[e]?i[e]:t:this.config&&this.config[e]?this.config[e]:t}static get pasteConfig(){return{tags:["TABLE","TR","TH","TD"]}}onPaste(e){const t=e.detail.data,o=t.querySelector(":scope > thead, tr:first-of-type th"),r=Array.from(t.querySelectorAll("tr")).map(n=>Array.from(n.querySelectorAll("th, td")).map(l=>l.innerHTML));this.data={withHeadings:o!==null,content:r},this.table.wrapper&&this.table.wrapper.replaceWith(this.render())}}const B="";return E}); diff --git a/app/src/main/java/com/docmost/app/DocmostApp.kt b/app/src/main/java/com/docmost/app/DocmostApp.kt new file mode 100644 index 0000000..277fb7c --- /dev/null +++ b/app/src/main/java/com/docmost/app/DocmostApp.kt @@ -0,0 +1,5 @@ +package com.docmost.app + +import android.app.Application + +class DocmostApp : Application() diff --git a/app/src/main/java/com/docmost/app/api/CachedApi.kt b/app/src/main/java/com/docmost/app/api/CachedApi.kt new file mode 100644 index 0000000..eab339d --- /dev/null +++ b/app/src/main/java/com/docmost/app/api/CachedApi.kt @@ -0,0 +1,368 @@ +package com.docmost.app.api + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.docmost.app.utils.NetworkConnectivityObserver +import com.docmost.app.data.PageCacheManager +import com.docmost.app.data.SpaceCacheManager +import com.docmost.app.data.SyncManager +import java.io.File +import java.io.IOException + +class CachedApi( + private val api: DocmostApi, + private val pageCache: PageCacheManager, + private val spaceCache: SpaceCacheManager, + private val syncManager: SyncManager, + private val networkObserver: NetworkConnectivityObserver +) { + var forcedOffline: Boolean = false + + val isOffline: Boolean + get() = forcedOffline || networkObserver.isOffline() + + private fun cacheOrThrow(block: () -> T): T { + return try { + block() + } catch (e: Exception) { + throw OfflineNotAvailableException("No disponible sin conexión: ${e.message}") + } + } + + // ── Spaces ── + + suspend fun getSpaces(): List { + if (isOffline) { + val cached = spaceCache.getCachedSpaces() + if (cached.isNotEmpty()) return cached + throw OfflineNotAvailableException("No hay espacios en caché") + } + val spaces = api.getSpaces() + spaceCache.cacheSpaces(spaces) + return spaces + } + + suspend fun createSpace(name: String, slug: String, description: String?): Space { + if (isOffline) { + val tempId = "temp_${System.currentTimeMillis()}" + syncManager.enqueueCreateSpace(name, slug, description, tempId) + val localSpace = Space( + id = tempId, + name = name, + description = description + ) + spaceCache.cacheSpace(localSpace) + return localSpace + } + val space = api.createSpace(name, slug, description) + spaceCache.cacheSpace(space) + return space + } + + suspend fun updateSpace(spaceId: String, name: String?, description: String?): Space { + if (isOffline) { + syncManager.enqueueUpdateSpace(spaceId, name, description) + val cached = spaceCache.getCachedSpace(spaceId) + if (cached != null) { + val updated = cached.copy( + name = name ?: cached.name, + description = description ?: cached.description + ) + spaceCache.cacheSpace(updated) + return updated + } + throw OfflineNotAvailableException("Espacio no encontrado en caché") + } + val space = api.updateSpace(spaceId, name, description) + spaceCache.cacheSpace(space) + return space + } + + suspend fun deleteSpace(spaceId: String) { + if (isOffline) { + syncManager.enqueueDeleteSpace(spaceId) + spaceCache.removeCachedSpace(spaceId) + return + } + api.deleteSpace(spaceId) + spaceCache.removeCachedSpace(spaceId) + } + + suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String { + if (isOffline) { + throw OfflineNotAvailableException("Subir iconos no disponible sin conexión") + } + return api.uploadSpaceIcon(spaceId, imageUri, context) + } + + suspend fun removeSpaceIcon(spaceId: String) { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + api.removeSpaceIcon(spaceId) + } + + // ── Pages ── + + suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List { + if (isOffline) { + val cached = pageCache.getCachedPagesForSpace(spaceId) + if (cached != null) return cached + return emptyList() + } + val pages = api.getSidebarPages(spaceId, pageId) + pageCache.cachePagesForSpace(spaceId, pages) + return pages + } + + suspend fun getPageInfo(pageId: String): Page { + if (isOffline) { + val cached = pageCache.getCachedPage(pageId) + if (cached != null) return cached + throw OfflineNotAvailableException("Página no encontrada en caché") + } + val page = api.getPageInfo(pageId) + pageCache.cachePage(page) + return page + } + + suspend fun createPage(title: String, spaceId: String): Page { + if (isOffline) { + val tempId = "temp_${System.currentTimeMillis()}" + syncManager.enqueueCreatePage(title, spaceId, tempId) + val localPage = Page( + id = tempId, + title = title, + spaceId = spaceId + ) + pageCache.cachePage(localPage) + val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf() + existing.add(localPage) + pageCache.cachePagesForSpace(spaceId, existing) + return localPage + } + val page = api.createPage(title, spaceId) + pageCache.cachePage(page) + val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf() + existing.add(page) + pageCache.cachePagesForSpace(spaceId, existing) + return page + } + + suspend fun updatePage(pageId: String, content: String?, title: String?, icon: String? = null, isJson: Boolean = false): Page { + if (isOffline) { + syncManager.enqueueUpdatePage(pageId, content ?: "", title ?: "", icon) + val cached = pageCache.getCachedPage(pageId) + if (cached != null) { + val updated = cached.copy( + title = title ?: cached.title, + icon = icon ?: cached.icon, + content = content ?: cached.content + ) + pageCache.cachePage(updated) + return updated + } + throw OfflineNotAvailableException("Página no encontrada en caché") + } + val page = api.updatePage(pageId, content, title, icon, isJson) + pageCache.cachePage(page) + return page + } + + suspend fun deletePage(pageId: String, spaceId: String? = null) { + if (isOffline) { + syncManager.enqueueDeletePage(pageId) + pageCache.removeFromCache(pageId) + if (spaceId != null) { + val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId } + if (cached != null) pageCache.cachePagesForSpace(spaceId, cached) + } + return + } + api.deletePage(pageId) + pageCache.removeFromCache(pageId) + if (spaceId != null) { + val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId } + if (cached != null) pageCache.cachePagesForSpace(spaceId, cached) + } + } + + suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page { + if (isOffline) throw OfflineNotAvailableException("Duplicar no disponible sin conexión") + + val page = api.duplicatePage(pageId, targetSpaceId) + pageCache.cachePage(page) + return page + } + + suspend fun movePageToSpace(pageId: String, spaceId: String) { + if (isOffline) { + syncManager.enqueueUpdatePage(pageId, "", "", null) + return + } + api.movePageToSpace(pageId, spaceId) + } + + suspend fun movePageToPage(pageId: String, parentPageId: String?, position: String) { + if (isOffline) { + syncManager.enqueueUpdatePage(pageId, "", "", null) + return + } + api.movePageToPage(pageId, parentPageId, position) + } + + // ── Recent ── + + suspend fun getRecentPages(): List { + if (isOffline) throw OfflineNotAvailableException("Recientes no disponible sin conexión") + return api.getRecentPages() + } + + // ── Shares ── + + suspend fun getShares(): List { + if (isOffline) throw OfflineNotAvailableException("Compartir no disponible sin conexión") + return api.getShares() + } + + suspend fun createShare(pageId: String): Share { + if (isOffline) throw OfflineNotAvailableException("Crear enlaces no disponible sin conexión") + return api.createShare(pageId) + } + + suspend fun deleteShare(shareId: String) { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + api.deleteShare(shareId) + } + + // ── Comments ── + + suspend fun getComments(pageId: String): List { + if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión") + return api.getComments(pageId) + } + + suspend fun createComment(pageId: String, content: String): Comment { + if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión") + return api.createComment(pageId, content) + } + + suspend fun updateComment(commentId: String, content: String): Comment { + if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión") + return api.updateComment(commentId, content) + } + + suspend fun deleteComment(commentId: String) { + if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión") + api.deleteComment(commentId) + } + + // ── History ── + + suspend fun getPageHistory(pageId: String): List { + if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión") + return api.getPageHistory(pageId) + } + + suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory { + if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión") + return api.getPageHistoryInfo(pageId, historyId) + } + + // ── User ── + + suspend fun getCurrentUser(): User { + if (isOffline) throw OfflineNotAvailableException("Usuario no disponible sin conexión") + return api.getCurrentUser() + } + + suspend fun updateUser(name: String?): User { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + return api.updateUser(name) + } + + // ── Search ── + + suspend fun searchPages(query: String): List { + if (isOffline) throw OfflineNotAvailableException("Búsqueda no disponible sin conexión") + return api.searchPages(query) + } + + // ── Attachments ── + + suspend fun uploadAttachment(pageId: String, fileUri: Uri, context: Context): Attachment { + if (isOffline) { + val fileName = getFileNameFromUri(context, fileUri) ?: "file" + val mimeType = context.contentResolver.getType(fileUri) ?: "application/octet-stream" + val pendingDir = File(context.cacheDir, "attachments_pending/$pageId") + pendingDir.mkdirs() + val localFile = File(pendingDir, fileName) + try { + context.contentResolver.openInputStream(fileUri)?.use { input -> + localFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + throw IOException("No se pudo guardar el archivo localmente: ${e.message}") + } + syncManager.enqueueUploadAttachment(pageId, localFile.absolutePath, fileName, mimeType) + + return Attachment( + id = "pending_${System.currentTimeMillis()}", + fileName = fileName, + fileSize = localFile.length(), + mimeType = mimeType, + url = localFile.toURI().toString(), + pageId = pageId + ) + } + return api.uploadAttachment(pageId, fileUri, context) + } + + suspend fun getPageAttachments(pageId: String): List { + if (isOffline) throw OfflineNotAvailableException("Archivos adjuntos no disponible sin conexión") + return api.getPageAttachments(pageId) + } + + suspend fun getPageContent(pageId: String): PageContent { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + return api.getPageContent(pageId) + } + + // ── Trash ── + + suspend fun getDeletedPages(spaceId: String): List { + if (isOffline) throw OfflineNotAvailableException("Papelera no disponible sin conexión") + return api.getDeletedPages(spaceId) + } + + suspend fun restorePage(pageId: String, spaceId: String? = null) { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + api.restorePage(pageId, spaceId) + } + + // ── Helpers ── + + suspend fun getPageHtml(pageId: String): String { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + return api.getPageHtml(pageId) + } + + fun getAttachmentUrl(attachmentType: String, fileName: String): String { + return api.getAttachmentUrl(attachmentType, fileName) + } + + private fun getFileNameFromUri(context: Context, uri: Uri): String? { + var fileName: String? = null + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (displayNameIndex >= 0) { + fileName = it.getString(displayNameIndex) + } + } + } + return fileName ?: uri.lastPathSegment + } +} diff --git a/app/src/main/java/com/docmost/app/api/DocmostApi.kt b/app/src/main/java/com/docmost/app/api/DocmostApi.kt new file mode 100644 index 0000000..5a2c297 --- /dev/null +++ b/app/src/main/java/com/docmost/app/api/DocmostApi.kt @@ -0,0 +1,488 @@ +package com.docmost.app.api + +import android.util.Log +import com.docmost.app.BuildConfig +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.content.Context +import android.net.Uri +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.concurrent.TimeUnit + +class DocmostApi(private val baseUrl: String) { + + private val gson = Gson() + private val jsonMediaType = "application/json".toMediaType() + + private var authToken: String? = null + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + fun setAuthToken(token: String?) { + authToken = token + } + + private fun buildUrl(path: String): String { + val url = baseUrl.trimEnd('/') + return if (path.startsWith("/")) "$url$path" else "$url/$path" + } + + private fun post(path: String, body: Any? = null): String { + val url = buildUrl(path) + val jsonBody = if (body != null) gson.toJson(body) else "{}" + val requestBody = jsonBody.toRequestBody(jsonMediaType) + if (BuildConfig.DEBUG) Log.d("DocmostAPI", "POST $url") + + val builder = Request.Builder() + .url(url) + .post(requestBody) + .addHeader("Content-Type", "application/json") + + authToken?.let { + builder.addHeader("Cookie", "authToken=$it") + } + + val request = builder.build() + val response = client.newCall(request).execute() + + val responseBody = response.body?.string() ?: "" + if (BuildConfig.DEBUG) Log.d("DocmostAPI", "HTTP ${response.code}") + + if (!response.isSuccessful) { + val message = try { + val err = gson.fromJson(responseBody, ApiError::class.java) + err.message + } catch (e: Exception) { + "HTTP ${response.code}" + } + throw IOException(message) + } + + return responseBody + } + + suspend fun login(email: String, password: String): String = withContext(Dispatchers.IO) { + val url = buildUrl("/api/auth/login") + val jsonBody = gson.toJson(LoginRequest(email, password)) + val requestBody = jsonBody.toRequestBody(jsonMediaType) + if (BuildConfig.DEBUG) Log.d("DocmostAPI", "POST $url") + + val request = Request.Builder() + .url(url) + .post(requestBody) + .addHeader("Content-Type", "application/json") + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + if (BuildConfig.DEBUG) Log.d("DocmostAPI", "HTTP ${response.code}") + + if (!response.isSuccessful) { + val message = try { + val err = gson.fromJson(responseBody, ApiError::class.java) + err.message + } catch (e: Exception) { + "Login failed (HTTP ${response.code})" + } + throw IOException(message) + } + + val cookies = response.headers("Set-Cookie") + var token: String? = null + for (cookie in cookies) { + if (cookie.startsWith("authToken=", ignoreCase = true)) { + val parts = cookie.split(";")[0].trim() + token = parts.removePrefix("authToken=") + break + } + } + + if (token.isNullOrBlank()) { + throw IOException("No auth token received") + } + + authToken = token + token + } + + suspend fun getSpaces(): List = withContext(Dispatchers.IO) { + val json = post("/api/spaces") + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + val items = response.data?.items ?: emptyList() + items + } + + suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List = withContext(Dispatchers.IO) { + val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList) { + if (parentPage.hasChildren) { + val children = getSidebarPages(spaceId, parentPage.id) + allPages.addAll(children) + children.forEach { child -> + fetchPageChildren(spaceId, child, allPages) + } + } + } + + suspend fun getAllPagesInSpace(spaceId: String): List = withContext(Dispatchers.IO) { + val allPages = mutableListOf() + val rootPages = getSidebarPages(spaceId) + allPages.addAll(rootPages) + + rootPages.forEach { page -> + fetchPageChildren(spaceId, page, allPages) + } + + allPages + } + + suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) { + val json = post("/api/pages/info", PageInfoRequest(pageId, format)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Page not found in response") + } + + suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) { + val page = getPageInfo(pageId, "html") + val content = page.content as? String ?: throw IOException("Page content is not a string") + content + } + + fun getAttachmentUrl(attachmentType: String, fileName: String): String { + return buildUrl("/api/attachments/img/$attachmentType/$fileName") + } + + suspend fun updateSpace(spaceId: String, name: String?, description: String?): Space = + withContext(Dispatchers.IO) { + val json = post( + "/api/spaces/update", + SpaceUpdateRequest( + spaceId = spaceId, + name = name, + description = description + ) + ) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Space update response missing data") + } + + suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String = + withContext(Dispatchers.IO) { + val url = buildUrl("/api/attachments/upload-image") + val inputStream = context.contentResolver.openInputStream(imageUri) + ?: throw IOException("Cannot open image") + val bytes = inputStream.readBytes() + inputStream.close() + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("type", AttachmentType.SPACE_ICON) + .addFormDataPart("spaceId", spaceId) + .addFormDataPart("file", "icon.jpg", bytes.toRequestBody("image/*".toMediaType())) + .build() + + val builder = Request.Builder() + .url(url) + .post(requestBody) + .addHeader("Cookie", "authToken=$authToken") + + val request = builder.build() + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (!response.isSuccessful) { + throw IOException("Icon upload failed: $responseBody") + } + + val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java) + attachment.fileName + } + + suspend fun removeSpaceIcon(spaceId: String) = withContext(Dispatchers.IO) { + post("/api/attachments/remove-icon", RemoveIconRequest(AttachmentType.SPACE_ICON, spaceId)) + } + + suspend fun updatePage(pageId: String, content: String?, title: String?, icon: String? = null, isJson: Boolean = false): Page = + withContext(Dispatchers.IO) { + val json = post( + "/api/pages/update", + PageUpdateRequest( + pageId = pageId, + content = content, + title = title, + icon = icon, + format = if (isJson) "json" else "markdown", + operation = "replace" + ) + ) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Update response missing data") + } + + suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) { + val json = post("/api/users/me") + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.user ?: throw IOException("User not found in response") + } + + suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) { + val json = post("/api/users/update", UserUpdateRequest(name = name)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Update user response missing data") + } + + suspend fun searchPages(query: String): List = withContext(Dispatchers.IO) { + val json = post("/api/search", SearchRequest(query = query)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun createSpace(name: String, slug: String, description: String? = null): Space = + withContext(Dispatchers.IO) { + val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Create space response missing data") + } + + suspend fun removeAvatar() = withContext(Dispatchers.IO) { + post("/api/attachments/remove-icon", RemoveIconRequest(AttachmentType.AVATAR)) + } + + suspend fun uploadAvatar(imageUri: Uri, context: Context): String = + withContext(Dispatchers.IO) { + val url = buildUrl("/api/attachments/upload-image") + val inputStream = context.contentResolver.openInputStream(imageUri) + ?: throw IOException("Cannot open image") + val bytes = inputStream.readBytes() + inputStream.close() + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("type", AttachmentType.AVATAR) + .addFormDataPart("file", "avatar.jpg", bytes.toRequestBody("image/*".toMediaType())) + .build() + + val builder = Request.Builder() + .url(url) + .post(requestBody) + .addHeader("Cookie", "authToken=$authToken") + + val request = builder.build() + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (!response.isSuccessful) { + throw IOException("Avatar upload failed: $responseBody") + } + + val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java) + attachment.fileName + } + + suspend fun getRecentPages(): List = withContext(Dispatchers.IO) { + val json = post("/api/pages/recent", RecentPageRequest()) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) { + val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Create page response missing data") + } + + suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) { + post("/api/pages/delete", DeletePageRequest(pageId = pageId)) + } + + suspend fun deleteSpace(spaceId: String) = withContext(Dispatchers.IO) { + post("/api/spaces/delete", SpaceIdDto(spaceId = spaceId)) + } + + suspend fun getShares(): List = withContext(Dispatchers.IO) { + val json = post("/api/shares/", mapOf("limit" to 50)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) { + val json = post("/api/shares/create", CreateShareDto(pageId = pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Create share response missing data") + } + + suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) { + post("/api/shares/delete", ShareIdDto(shareId = shareId)) + } + + suspend fun getComments(pageId: String): List = withContext(Dispatchers.IO) { + val json = post("/api/comments/", mapOf("pageId" to pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) { + val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Create comment response missing data") + } + + suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) { + val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Update comment response missing data") + } + + suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) { + post("/api/comments/delete", CommentIdDto(commentId = commentId)) + } + + suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) { + val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Duplicate response missing data") + } + + suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) { + post("/api/pages/move-to-space", MovePageToSpaceDto(pageId = pageId, spaceId = spaceId)) + } + + suspend fun movePageToPage(pageId: String, parentPageId: String?, position: String) = withContext(Dispatchers.IO) { + val body: Map = mapOf( + "pageId" to pageId, + "parentPageId" to parentPageId, + "position" to position + ) + post("/api/pages/move", body) + } + + suspend fun getPageHistory(pageId: String): List = withContext(Dispatchers.IO) { + val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) { + val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data ?: throw IOException("Page history not found in response") + } + + suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) { + val json = post("/api/pages/info", PageInfoRequest(pageId = pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + val page = response.data ?: throw IOException("Page not found in response") + PageContent(source = page.source ?: "") + } + + suspend fun getDeletedPages(spaceId: String): List = withContext(Dispatchers.IO) { + val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) { + post("/api/pages/restore", RestorePageRequest(pageId = pageId, spaceId = spaceId)) + } + + suspend fun uploadAttachment(pageId: String, fileUri: android.net.Uri, context: android.content.Context): Attachment = + withContext(Dispatchers.IO) { + val url = buildUrl("/api/attachments/upload") + val inputStream = context.contentResolver.openInputStream(fileUri) + ?: throw IOException("Cannot open file") + val bytes = inputStream.readBytes() + inputStream.close() + + val fileName = getFileNameFromUri(context, fileUri) ?: "file" + val mimeType = context.contentResolver.getType(fileUri) ?: "application/octet-stream" + + val requestBody = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("type", AttachmentType.FILE) + .addFormDataPart("pageId", pageId) + .addFormDataPart("file", fileName, bytes.toRequestBody(mimeType.toMediaTypeOrNull())) + .build() + + val builder = okhttp3.Request.Builder() + .url(url) + .post(requestBody) + .addHeader("Cookie", "authToken=$authToken") + + val request = builder.build() + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (!response.isSuccessful) { + throw IOException("Attachment upload failed: $responseBody") + } + + val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java) + + Attachment( + id = attachment.id, + fileName = attachment.fileName, + fileSize = attachment.fileSize, + mimeType = attachment.mimeType, + url = getAttachmentUrl(AttachmentType.FILE, attachment.fileName), + pageId = pageId + ) + } + + suspend fun getPageAttachments(pageId: String): List = withContext(Dispatchers.IO) { + val json = post("/api/attachments/page", mapOf("pageId" to pageId)) + val type = object : TypeToken>() {}.type + val response = gson.fromJson>(json, type) + response.data?.items ?: emptyList() + } + + private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? { + var fileName: String? = null + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (displayNameIndex >= 0) { + fileName = it.getString(displayNameIndex) + } + } + } + return fileName ?: uri.lastPathSegment + } +} diff --git a/app/src/main/java/com/docmost/app/api/Models.kt b/app/src/main/java/com/docmost/app/api/Models.kt new file mode 100644 index 0000000..8a9e734 --- /dev/null +++ b/app/src/main/java/com/docmost/app/api/Models.kt @@ -0,0 +1,366 @@ +package com.docmost.app.api + +import java.io.IOException + +data class LoginRequest( + val email: String, + val password: String +) + +data class Space( + val id: String = "", + val name: String = "", + val description: String? = null, + val logo: String? = null, + val slug: String? = null, + val creatorId: String? = null, + val isPersonal: Boolean = false, + val memberCount: Int? = null, + val membership: SpaceMembership? = null +) + +data class SpaceMembership( + val userId: String = "", + val role: String = "" +) + +data class User( + val id: String = "", + val email: String = "", + val name: String? = null, + val avatarUrl: String? = null, + val role: String? = null +) + +data class UserInfo( + val id: String = "", + val name: String? = null, + val avatarUrl: String? = null +) + +data class UserUpdateRequest( + val name: String? = null +) + +data class UserInfoResponse( + val user: User? = null +) + +object AttachmentType { + const val AVATAR = "avatar" + const val WORKSPACE_ICON = "workspace-icon" + const val SPACE_ICON = "space-icon" + const val PAGE_IMAGE = "page-image" + const val FILE = "file" +} + +data class PaginationMeta( + val limit: Int = 0, + val hasNextPage: Boolean = false, + val hasPrevPage: Boolean = false, + val nextCursor: String? = null, + val prevCursor: String? = null +) + +data class SpacesData( + val items: List = emptyList(), + val meta: PaginationMeta? = null +) + +data class PagePermissions( + val canEdit: Boolean = true, + val hasRestriction: Boolean = false +) + +data class Page( + val id: String = "", + val title: String = "", + val content: Any? = null, + val spaceId: String? = null, + val parentPageId: String? = null, + val icon: String? = null, + val slugId: String? = null, + val hasChildren: Boolean = false, + val position: String? = null, + val source: String? = null, + val creator: UserInfo? = null, + val lastUpdatedBy: UserInfo? = null, + val createdAt: String = "", + val updatedAt: String = "", + val labels: List? = null, + val permissions: PagePermissions? = null +) + +data class PagesData( + val items: List = emptyList(), + val meta: PaginationMeta? = null +) + +data class PageInfoRequest( + val pageId: String, + val format: String = "json" +) + +data class PageUpdateRequest( + val pageId: String, + val content: Any? = null, + val title: String? = null, + val icon: String? = null, + val parentPageId: String? = null, + val format: String = "markdown", + val operation: String = "replace" +) + +data class SidebarPagesRequest( + val spaceId: String, + val pageId: String? = null +) + +data class ApiError( + val message: String = "Unknown error" +) + +data class SpaceUpdateRequest( + val spaceId: String, + val name: String? = null, + val description: String? = null, + val slug: String? = null +) + +data class RemoveIconRequest( + val type: String, + val spaceId: String? = null +) + +data class AttachmentUploadResponse( + val id: String = "", + val fileName: String = "", + val fileSize: Long = 0, + val mimeType: String = "" +) + +data class SearchRequest( + val query: String, + val limit: Int = 25, + val offset: Int = 0 +) + +data class SearchResult( + val id: String = "", + val title: String = "", + val icon: String? = null, + val highlight: String? = null, + val spaceId: String? = null, + val spaceName: String? = null +) + +data class SearchResultsResponse( + val items: List = emptyList() +) + +data class CreateSpaceRequest( + val name: String, + val slug: String, + val description: String? = null +) + +data class CreatePageRequest( + val title: String, + val spaceId: String, + val icon: String? = null, + val content: String? = null, + val format: String? = "markdown", + val parentPageId: String? = null +) + +data class DeletePageRequest( + val pageId: String +) + +data class SpaceIdDto( + val spaceId: String +) + +data class RecentPageRequest( + val limit: Int = 50, + val offset: Int = 0 +) + +data class RecentPagesResponse( + val items: List = emptyList() +) + +data class DocmostResponse( + val data: T? = null, + val success: Boolean = false, + val status: Int = 0 +) + +data class DuplicatePageRequest( + val pageId: String, + val spaceId: String? = null +) + +data class MovePageToSpaceDto( + val pageId: String, + val spaceId: String +) + +data class Share( + val id: String = "", + val pageId: String = "", + val key: String = "", + val spaceId: String = "", + val includeSubPages: Boolean = false, + val searchIndexing: Boolean = false, + val createdAt: String = "", + val page: SharePageInfo? = null +) + +data class SharePageInfo( + val id: String = "", + val title: String = "", + val slugId: String = "", + val icon: String? = null +) + +data class SharesData( + val items: List = emptyList() +) + +data class CreateShareDto( + val pageId: String, + val includeSubPages: Boolean = false, + val searchIndexing: Boolean = false +) + +data class ShareIdDto( + val shareId: String +) + +data class Comment( + val id: String = "", + val pageId: String = "", + val content: com.google.gson.JsonObject? = null, + val parentCommentId: String? = null, + val creatorId: String = "", + val createdAt: String = "", + val updatedAt: String = "", + val creator: CommentCreator? = null +) + +data class CommentCreator( + val id: String = "", + val name: String? = null, + val avatarUrl: String? = null +) + +data class CommentsData( + val items: List = emptyList() +) + +data class CreateCommentDto( + val pageId: String, + val content: String +) + +data class UpdateCommentDto( + val commentId: String, + val content: String +) + +data class CommentIdDto( + val commentId: String +) + +data class PageHistory( + val id: String = "", + val pageId: String = "", + val version: Int = 0, + val createdAt: String = "", + val lastUpdatedBy: UserInfo? = null, + val contributorIds: List = emptyList(), + val content: Any? = null +) + +data class PageHistoryRequest( + val pageId: String, + val limit: Int = 20, + val cursor: String? = null +) + +data class PageHistoryResponse( + val items: List = emptyList(), + val hasNextPage: Boolean = false, + val nextCursor: String? = null +) + +data class FavoriteCreateDto( + val type: String, + val pageId: String +) + +data class FavoriteRemoveDto( + val type: String, + val pageId: String +) + +data class FavoriteIdsDto( + val type: String +) + +data class FavoriteIdsResponse( + val items: List = emptyList() +) + +data class PageContent( + val source: String +) + +data class DeletedPage( + val id: String = "", + val title: String = "", + val icon: String? = null, + val spaceId: String? = null, + val deletedAt: String = "", + val deletedBy: UserInfo? = null +) + +data class DeletedPagesResponse( + val items: List = emptyList() +) + +data class DeletedPagesRequest( + val spaceId: String, + val limit: Int = 50, + val cursor: String? = null +) + +data class RestorePageRequest( + val pageId: String, + val spaceId: String? = null +) + +data class Attachment( + val id: String = "", + val fileName: String = "", + val fileSize: Long = 0, + val mimeType: String = "", + val url: String = "", + val pageId: String = "", + val uploadedBy: UserInfo? = null, + val createdAt: String = "" +) + +data class AttachmentUploadRequest( + val pageId: String, + val type: String = "attachment" +) + +data class AttachmentsResponse( + val items: List = emptyList() +) + +const val MAX_ATTACHMENT_SIZE_MB = 30 + +class OfflineNotAvailableException(message: String) : IOException(message) diff --git a/app/src/main/java/com/docmost/app/data/PageCacheManager.kt b/app/src/main/java/com/docmost/app/data/PageCacheManager.kt new file mode 100644 index 0000000..dbf700c --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/PageCacheManager.kt @@ -0,0 +1,187 @@ +package com.docmost.app.data + +import android.content.Context +import com.docmost.app.api.Page +import com.docmost.app.BuildConfig +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.security.MessageDigest + +data class CachedPageMeta( + val pageId: String, + val title: String, + val icon: String? = null, + val spaceId: String? = null, + val isPending: Boolean = false, + val updatedAt: Long = System.currentTimeMillis() +) + +class PageCacheManager(private val context: Context) { + + private val gson = Gson() + private var currentUserId: String = "default" + + private val cacheDir: File + get() { + val userDir = File(context.cacheDir, "pages_cache") + val userSpecificDir = File(userDir, currentUserId) + return userSpecificDir.also { it.mkdirs() } + } + private val indexFile: File + get() = File(cacheDir, "index.json") + private val draftsDir: File + get() { + val dir = File(cacheDir, "drafts") + return dir.also { it.mkdirs() } + } + + private val spacePagesDir: File + get() { + val dir = File(cacheDir, "space_pages") + return dir.also { it.mkdirs() } + } + + fun setCurrentUser(email: String, serverUrl: String) { + val uniqueId = "${email.lowercase()}@${serverUrl.hash()}" + currentUserId = uniqueId + } + + private fun String.hash(): String { + return MessageDigest.getInstance("SHA-256") + .digest(this.toByteArray()) + .joinToString("") { "%02x".format(it) } + .take(16) + } + + fun cachePage(page: Page) { + try { + val pageFile = File(cacheDir, "${page.id}.json") + pageFile.writeText(gson.toJson(page)) + + val meta = CachedPageMeta( + pageId = page.id, + title = page.title, + icon = page.icon, + spaceId = page.spaceId, + isPending = false, + updatedAt = System.currentTimeMillis() + ) + val index = getIndex().toMutableList() + index.removeAll { it.pageId == page.id } + index.add(meta) + indexFile.writeText(gson.toJson(index)) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Cache error: ${e.message}") + } + } + + fun saveDraft(pageId: String, content: String, title: String, icon: String?) { + try { + val draftFile = File(draftsDir, "$pageId.json") + val draft = mapOf( + "pageId" to pageId, + "content" to content, + "title" to title, + "icon" to icon, + "updatedAt" to System.currentTimeMillis() + ) + draftFile.writeText(gson.toJson(draft)) + if (BuildConfig.DEBUG) android.util.Log.d("PageCache", "Draft saved for page: $pageId") + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Draft save error: ${e.message}") + } + } + + fun getDraft(pageId: String): Map? { + return try { + val draftFile = File(draftsDir, "$pageId.json") + if (!draftFile.exists()) return null + @Suppress("UNCHECKED_CAST") + gson.fromJson(draftFile.readText(), Map::class.java) as? Map + } catch (e: Exception) { + null + } + } + + fun deleteDraft(pageId: String) { + try { + File(draftsDir, "$pageId.json").delete() + } catch (_: Exception) { } + } + + fun getCachedPage(pageId: String): Page? { + return try { + val pageFile = File(cacheDir, "$pageId.json") + if (!pageFile.exists()) return null + gson.fromJson(pageFile.readText(), Page::class.java) + } catch (e: Exception) { + null + } + } + + fun getCachedPages(): List { + return try { + if (!indexFile.exists()) return emptyList() + val type = object : TypeToken>() {}.type + gson.fromJson(indexFile.readText(), type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + fun removeFromCache(pageId: String) { + try { + File(cacheDir, "$pageId.json").delete() + deleteDraft(pageId) + val index = getIndex().toMutableList() + index.removeAll { it.pageId == pageId } + indexFile.writeText(gson.toJson(index)) + } catch (_: Exception) { } + } + + fun cachePagesForSpace(spaceId: String, pages: List) { + try { + val file = File(spacePagesDir, "$spaceId.json") + file.writeText(gson.toJson(pages)) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Cache space pages error: ${e.message}") + } + } + + fun getCachedPagesForSpace(spaceId: String): List? { + return try { + val file = File(spacePagesDir, "$spaceId.json") + if (!file.exists()) return null + val type = object : TypeToken>() {}.type + gson.fromJson(file.readText(), type) + } catch (e: Exception) { + null + } + } + + fun clearAll() { + try { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + } catch (_: Exception) { } + } + + fun clearUserCache() { + try { + cacheDir.deleteRecursively() + } catch (_: Exception) { } + } + + fun getCachedPageCount(): Int = getIndex().size + + private fun getIndex(): List { + return try { + if (!indexFile.exists()) return emptyList() + val type = object : TypeToken>() {}.type + gson.fromJson(indexFile.readText(), type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/java/com/docmost/app/data/SessionManager.kt b/app/src/main/java/com/docmost/app/data/SessionManager.kt new file mode 100644 index 0000000..96338dd --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SessionManager.kt @@ -0,0 +1,139 @@ +package com.docmost.app.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.google.gson.Gson + + +data class Credentials( + val url: String, + val email: String, + val password: String, + val authToken: String = "", + val avatarUrl: String? = null +) + +class SessionManager(private val context: Context) { + + private val gson = Gson() + + private val prefs: SharedPreferences by lazy { + createEncryptedPrefs(context, PREFS_NAME) + } + + private val savedAccountsPrefs: SharedPreferences by lazy { + createEncryptedPrefs(context, SAVED_ACCOUNTS_PREFS) + } + + private fun createEncryptedPrefs(context: Context, prefsName: String): SharedPreferences { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return try { + EncryptedSharedPreferences.create( + prefsName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + context.getSharedPreferences(prefsName, Context.MODE_PRIVATE).edit().clear().apply() + EncryptedSharedPreferences.create( + prefsName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + } + + fun saveCredentials(credentials: Credentials) { + try { + val json = gson.toJson(credentials) + prefs.edit().putString(KEY_CREDENTIALS, json).apply() + } catch (e: Exception) { + clear() + } + } + + fun getCredentials(): Credentials? { + val json = try { + prefs.getString(KEY_CREDENTIALS, null) + } catch (e: Exception) { + clear() + null + } ?: return null + return try { + gson.fromJson(json, Credentials::class.java) + } catch (e: Exception) { + null + } + } + + fun saveAuthToken(token: String) { + val current = getCredentials() ?: return + saveCredentials(current.copy(authToken = token)) + } + + fun clear() { + try { + prefs.edit().clear().apply() + } catch (_: Exception) {} + } + + fun hasSession(): Boolean { + val creds = getCredentials() ?: return false + return creds.url.isNotBlank() && creds.email.isNotBlank() && creds.authToken.isNotBlank() + } + + fun saveAccount(credentials: Credentials) { + try { + val accounts = getSavedAccounts().toMutableList() + accounts.removeAll { it.email == credentials.email && it.url == credentials.url } + accounts.add(credentials) + val json = gson.toJson(accounts) + savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).apply() + } catch (e: Exception) { + context.getSharedPreferences(SAVED_ACCOUNTS_PREFS, Context.MODE_PRIVATE).edit().clear().commit() + val json = gson.toJson(listOf(credentials)) + savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).commit() + } + } + + fun getSavedAccounts(): List { + val json = try { + savedAccountsPrefs.getString(KEY_SAVED_ACCOUNTS, null) + } catch (e: Exception) { + context.getSharedPreferences(SAVED_ACCOUNTS_PREFS, Context.MODE_PRIVATE).edit().clear().commit() + null + } ?: return emptyList() + return try { + val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, Credentials::class.java).type + gson.fromJson(json, type) + } catch (e: Exception) { + emptyList() + } + } + + fun removeAccount(email: String, url: String) { + try { + val accounts = getSavedAccounts().toMutableList() + accounts.removeAll { it.email == email && it.url == url } + val json = gson.toJson(accounts) + savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).apply() + } catch (_: Exception) {} + } + + fun hasAccount(email: String, url: String): Boolean { + return getSavedAccounts().any { it.email == email && it.url == url } + } + + companion object { + private const val PREFS_NAME = "docmost_session" + private const val KEY_CREDENTIALS = "credentials" + private const val SAVED_ACCOUNTS_PREFS = "docmost_saved_accounts" + private const val KEY_SAVED_ACCOUNTS = "saved_accounts" + } +} diff --git a/app/src/main/java/com/docmost/app/data/SpaceCacheManager.kt b/app/src/main/java/com/docmost/app/data/SpaceCacheManager.kt new file mode 100644 index 0000000..e8e4994 --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SpaceCacheManager.kt @@ -0,0 +1,135 @@ +package com.docmost.app.data + +import android.content.Context +import com.docmost.app.api.Space +import com.docmost.app.BuildConfig +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.security.MessageDigest + +data class CachedSpaceMeta( + val spaceId: String, + val name: String, + val slug: String? = null, + val isPending: Boolean = false, + val updatedAt: Long = System.currentTimeMillis() +) + +class SpaceCacheManager(private val context: Context) { + + private val gson = Gson() + private var currentUserId: String = "default" + + private val cacheDir: File + get() { + val userDir = File(context.cacheDir, "spaces_cache") + val userSpecificDir = File(userDir, currentUserId) + return userSpecificDir.also { it.mkdirs() } + } + + private val indexFile: File + get() = File(cacheDir, "index.json") + + private fun String.hash(): String { + return MessageDigest.getInstance("SHA-256") + .digest(this.toByteArray()) + .joinToString("") { "%02x".format(it) } + .take(16) + } + + fun setCurrentUser(email: String, serverUrl: String) { + val uniqueId = "${email.lowercase()}@${serverUrl.hash()}" + currentUserId = uniqueId + } + + fun cacheSpaces(spaces: List) { + try { + val index = mutableListOf() + for (space in spaces) { + val spaceFile = File(cacheDir, "${space.id}.json") + spaceFile.writeText(gson.toJson(space)) + + index.add(CachedSpaceMeta( + spaceId = space.id, + name = space.name, + slug = space.id, + isPending = false, + updatedAt = System.currentTimeMillis() + )) + } + indexFile.writeText(gson.toJson(index)) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("SpaceCache", "Cache spaces error: ${e.message}") + } + } + + fun cacheSpace(space: Space) { + try { + val spaceFile = File(cacheDir, "${space.id}.json") + spaceFile.writeText(gson.toJson(space)) + + val index = getIndex().toMutableList() + index.removeAll { it.spaceId == space.id } + index.add(CachedSpaceMeta( + spaceId = space.id, + name = space.name, + slug = space.id, + isPending = false, + updatedAt = System.currentTimeMillis() + )) + indexFile.writeText(gson.toJson(index)) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("SpaceCache", "Cache space error: ${e.message}") + } + } + + fun getCachedSpaces(): List { + return try { + val index = getIndex() + index.mapNotNull { meta -> + val spaceFile = File(cacheDir, "${meta.spaceId}.json") + if (!spaceFile.exists()) return@mapNotNull null + gson.fromJson(spaceFile.readText(), Space::class.java) + } + } catch (e: Exception) { + emptyList() + } + } + + fun getCachedSpace(spaceId: String): Space? { + return try { + val spaceFile = File(cacheDir, "$spaceId.json") + if (!spaceFile.exists()) return null + gson.fromJson(spaceFile.readText(), Space::class.java) + } catch (e: Exception) { + null + } + } + + fun removeCachedSpace(spaceId: String) { + try { + File(cacheDir, "$spaceId.json").delete() + val index = getIndex().toMutableList() + index.removeAll { it.spaceId == spaceId } + indexFile.writeText(gson.toJson(index)) + } catch (_: Exception) { } + } + + fun getIndex(): List { + return try { + if (!indexFile.exists()) return emptyList() + val type = object : TypeToken>() {}.type + gson.fromJson(indexFile.readText(), type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + fun clearAll() { + try { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + } catch (_: Exception) { } + } +} diff --git a/app/src/main/java/com/docmost/app/data/SyncManager.kt b/app/src/main/java/com/docmost/app/data/SyncManager.kt new file mode 100644 index 0000000..0d04d18 --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SyncManager.kt @@ -0,0 +1,504 @@ +package com.docmost.app.data + +import android.content.Context +import android.util.Log +import com.docmost.app.BuildConfig +import androidx.work.* +import com.docmost.app.api.DocmostApi +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +data class SyncResult( + val success: Boolean, + val message: String, + val conflictsResolved: Int = 0, + val operationsProcessed: Int = 0 +) + +class SyncManager( + private val context: Context, + private val api: DocmostApi, + private val pageCacheManager: PageCacheManager, + private val spaceCacheManager: SpaceCacheManager +) { + private val db = SyncQueueDatabase.getDatabase(context) + private val dao = db.syncQueueDao() + private val gson = Gson() + + private val _syncStatus = MutableStateFlow(SyncStatus.IDLE) + val syncStatus: Flow = _syncStatus.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.IO) + private val syncMutex = Mutex() + + init { + setupWorkManager() + } + + private fun setupWorkManager() { + val syncRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + "sync_queue_worker", + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + + suspend fun enqueueOperation(operation: PendingOperation) { + dao.insertOperation(operation) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Enqueued operation: ${operation.type} for ${operation.entityId}") + + if (operation.type == OperationType.CREATE_PAGE || operation.type == OperationType.CREATE_SPACE) { + scheduleImmediateSync() + } + } + + suspend fun enqueueCreateSpace(name: String, slug: String, description: String?, tempId: String) { + val payload = gson.toJson(mapOf( + "name" to name, + "slug" to slug, + "description" to description, + "tempId" to tempId + )) + val operation = PendingOperation( + type = OperationType.CREATE_SPACE, + entityType = "space", + entityId = null, + payload = payload, + timestamp = System.currentTimeMillis(), + tempId = tempId + ) + enqueueOperation(operation) + } + + suspend fun enqueueUpdateSpace(spaceId: String, name: String?, description: String?) { + val payload = gson.toJson(mapOf( + "spaceId" to spaceId, + "name" to name, + "description" to description + )) + val operation = PendingOperation( + type = OperationType.UPDATE_SPACE, + entityType = "space", + entityId = spaceId, + payload = payload, + timestamp = System.currentTimeMillis() + ) + enqueueOperation(operation) + } + + suspend fun enqueueCreatePage(title: String, spaceId: String, tempId: String, content: String? = null) { + val payload = gson.toJson(mapOf( + "title" to title, + "spaceId" to spaceId, + "content" to content, + "tempId" to tempId + )) + val operation = PendingOperation( + type = OperationType.CREATE_PAGE, + entityType = "page", + entityId = null, + payload = payload, + timestamp = System.currentTimeMillis(), + tempId = tempId + ) + enqueueOperation(operation) + } + + suspend fun enqueueUpdatePage(pageId: String, content: String, title: String, icon: String?) { + // Reemplazar operaciones UPDATE_PAGE previas para la misma página + // para que solo se sincronice el contenido más reciente + val existing = dao.getOperationByEntity(pageId, OperationType.UPDATE_PAGE) + if (existing != null) { + dao.deleteOperation(existing) + } + + val payload = gson.toJson(mapOf( + "pageId" to pageId, + "content" to content, + "title" to title, + "icon" to icon, + "updatedAt" to System.currentTimeMillis() + )) + val operation = PendingOperation( + type = OperationType.UPDATE_PAGE, + entityType = "page", + entityId = pageId, + payload = payload, + timestamp = System.currentTimeMillis() + ) + enqueueOperation(operation) + } + + suspend fun enqueueDeletePage(pageId: String) { + val payload = gson.toJson(mapOf("pageId" to pageId)) + val operation = PendingOperation( + type = OperationType.DELETE_PAGE, + entityType = "page", + entityId = pageId, + payload = payload, + timestamp = System.currentTimeMillis() + ) + enqueueOperation(operation) + } + + suspend fun enqueueDeleteSpace(spaceId: String) { + val payload = gson.toJson(mapOf("spaceId" to spaceId)) + val operation = PendingOperation( + type = OperationType.DELETE_SPACE, + entityType = "space", + entityId = spaceId, + payload = payload, + timestamp = System.currentTimeMillis() + ) + enqueueOperation(operation) + } + + suspend fun enqueueUploadAttachment(pageId: String, localPath: String, fileName: String, mimeType: String) { + val payload = gson.toJson(mapOf( + "pageId" to pageId, + "localPath" to localPath, + "fileName" to fileName, + "mimeType" to mimeType + )) + val operation = PendingOperation( + type = OperationType.UPLOAD_ATTACHMENT, + entityType = "attachment", + entityId = pageId, + payload = payload, + timestamp = System.currentTimeMillis() + ) + enqueueOperation(operation) + } + + suspend fun processQueue(): SyncResult { + syncMutex.withLock { + if (_syncStatus.value == SyncStatus.SYNCING) { + if (BuildConfig.DEBUG) Log.d("SyncManager", "Already syncing, skipping concurrent call") + return SyncResult(success = true, message = "Ya sincronizando", operationsProcessed = 0) + } + _syncStatus.value = SyncStatus.SYNCING + var conflictsResolved = 0 + var operationsProcessed = 0 + + try { + val operations = dao.getAllOperationsList() + if (BuildConfig.DEBUG) Log.d("SyncManager", "Processing ${operations.size} pending operations") + + for (operation in operations) { + try { + when (operation.type) { + OperationType.CREATE_SPACE -> handleCreateSpace(operation) + OperationType.UPDATE_SPACE -> handleUpdateSpace(operation) + OperationType.DELETE_SPACE -> handleDeleteSpace(operation) + OperationType.CREATE_PAGE -> handleCreatePage(operation) + OperationType.UPDATE_PAGE -> handleUpdatePage(operation) + OperationType.DELETE_PAGE -> handleDeletePage(operation) + OperationType.UPLOAD_ATTACHMENT -> handleUploadAttachment(operation) + else -> if (BuildConfig.DEBUG) Log.w("SyncManager", "Unknown operation type: ${operation.type}") + } + + dao.deleteOperation(operation) + operationsProcessed++ + if (BuildConfig.DEBUG) Log.d("SyncManager", "Completed operation: ${operation.type}") + + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e("SyncManager", "Error processing operation ${operation.type}: ${e.message}") + + if (e.message?.contains("409") == true || e.message?.contains("conflict") == true) { + conflictsResolved++ + dao.deleteOperation(operation) + } else if (operation.retryCount < 3) { + val updated = operation.copy(retryCount = operation.retryCount + 1) + dao.updateOperation(updated) + } else { + dao.deleteOperation(operation) + if (BuildConfig.DEBUG) Log.e("SyncManager", "Operation failed after 3 retries: ${operation.type}") + } + } + } + + val result = SyncResult( + success = true, + message = "Sincronización completada: $operationsProcessed operaciones", + conflictsResolved = conflictsResolved, + operationsProcessed = operationsProcessed + ) + _syncStatus.value = SyncStatus.IDLE + return result + + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e("SyncManager", "Sync failed: ${e.message}") + _syncStatus.value = SyncStatus.IDLE + return SyncResult( + success = false, + message = "Error de sincronización: ${e.message}", + conflictsResolved = conflictsResolved + ) + } + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleCreateSpace(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val name = payload["name"] as String + var slug = payload["slug"] as String + val description = payload["description"] as? String + val tempId = payload["tempId"] as? String + + var space: com.docmost.app.api.Space? = null + var lastError: Exception? = null + for (attempt in 0 until 3) { + try { + space = api.createSpace(name, slug, description) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Space created on server: ${space.id} (was temp: $tempId)") + break + } catch (e: Exception) { + lastError = e + if (e.message?.contains("slug exists", ignoreCase = true) == true) { + slug = "${slug}_${System.currentTimeMillis()}" + if (BuildConfig.DEBUG) Log.d("SyncManager", "Slug collision, retrying with: $slug") + } else { + throw e + } + } + } + if (space == null) throw lastError ?: Exception("Failed to create space") + + // Replace tempId with real ID in queued operations that reference it + if (tempId != null) { + val pendingOps = dao.getAllOperationsList() + for (op in pendingOps) { + try { + val opPayload = gson.fromJson(op.payload, Map::class.java) as Map + val updatedPayload: Map? = when { + op.type == OperationType.UPDATE_SPACE && opPayload["spaceId"] == tempId -> { + opPayload + ("spaceId" to space.id) + } + op.type == OperationType.CREATE_PAGE && opPayload["spaceId"] == tempId -> { + opPayload + ("spaceId" to space.id) + } + else -> null + } + if (updatedPayload != null) { + val updatedOp = op.copy(payload = gson.toJson(updatedPayload)) + dao.updateOperation(updatedOp) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Updated ${op.type} payload: temp $tempId → ${space.id}") + } + } catch (_: Exception) { } + } + + // Update space cache: replace temp entry with real server data + spaceCacheManager.cacheSpaces(listOf(space)) + } + + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleUpdateSpace(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val spaceId = payload["spaceId"] as String + val name = payload["name"] as? String + val description = payload["description"] as? String + + api.updateSpace(spaceId, name, description) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Space updated on server: $spaceId") + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleDeleteSpace(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val spaceId = payload["spaceId"] as String + api.deleteSpace(spaceId) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Space deleted on server: $spaceId") + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleCreatePage(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val title = payload["title"] as String + val spaceId = payload["spaceId"] as String + val content = payload["content"] as? String + val tempId = payload["tempId"] as? String + + val page = api.createPage(title, spaceId) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Page created on server: ${page.id} (was temp: $tempId)") + + if (tempId != null) { + val pendingOps = dao.getAllOperationsList() + for (op in pendingOps) { + try { + val opPayload = gson.fromJson(op.payload, Map::class.java) as Map + if (op.type == OperationType.DELETE_PAGE && opPayload["pageId"] == tempId) { + val updatedPayload = opPayload + ("pageId" to page.id) + val updatedOp = op.copy(payload = gson.toJson(updatedPayload)) + dao.updateOperation(updatedOp) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Updated DELETE_PAGE payload: temp $tempId → ${page.id}") + } + } catch (_: Exception) { } + } + + + pageCacheManager.cachePage(page) + } + + if (content != null) { + try { + api.updatePage(page.id, content, title, null) + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e("SyncManager", "Failed to set page content: ${e.message}") + } + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleUpdatePage(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val pageId = payload["pageId"] as String + val content = payload["content"] as String + val title = payload["title"] as String + val icon = payload["icon"] as? String + + try { + val remotePage = api.getPageInfo(pageId) + + api.updatePage(pageId, content, title, icon) + pageCacheManager.cachePage(remotePage.copy( + title = title, + icon = icon, + content = content + )) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Page updated on server: $pageId") + } catch (e: Exception) { + if (e.message?.contains("404") == true) { + api.updatePage(pageId, content, title, icon) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Page updated on server (page not found remotely): $pageId") + } else { + throw e + } + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleDeletePage(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val pageId = payload["pageId"] as String + api.deletePage(pageId) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Page deleted on server: $pageId") + } + + @Suppress("UNCHECKED_CAST") + private suspend fun handleUploadAttachment(operation: PendingOperation) { + val payload = gson.fromJson(operation.payload, Map::class.java) as Map + val pageId = payload["pageId"] as String + val localPath = payload["localPath"] as String + val fileName = payload["fileName"] as String + val mimeType = payload["mimeType"] as String + + val fileUri = android.net.Uri.parse(localPath) + api.uploadAttachment(pageId, fileUri, context) + if (BuildConfig.DEBUG) Log.d("SyncManager", "Attachment uploaded for page: $pageId") + } + + fun observePendingCount(): Flow { + return dao.observePendingCount() + } + + suspend fun getPendingCount(): Int { + return dao.getPendingCount() + } + + suspend fun clearAll() { + dao.deleteAll() + } + + private fun scheduleImmediateSync() { + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + WorkManager.getInstance(context).enqueue(syncRequest) + } + + companion object { + @Volatile + private var INSTANCE: SyncManager? = null + + fun getInstance( + context: Context, + api: DocmostApi, + pageCacheManager: PageCacheManager, + spaceCacheManager: SpaceCacheManager + ): SyncManager { + return INSTANCE ?: synchronized(this) { + val instance = SyncManager(context.applicationContext, api, pageCacheManager, spaceCacheManager) + INSTANCE = instance + instance + } + } + } +} + +enum class SyncStatus { + IDLE, + SYNCING, + ERROR +} + +class SyncWorker( + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val syncManager = SyncManager.getInstance( + context, + createApi(), + PageCacheManager(context), + SpaceCacheManager(context) + ) + val result = syncManager.processQueue() + + if (result.success) { + Result.success() + } else { + Result.retry() + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e("SyncWorker", "Sync failed: ${e.message}") + Result.retry() + } + } + + private fun createApi(): DocmostApi { + val sessionManager = com.docmost.app.data.SessionManager(context) + val creds = sessionManager.getCredentials() + val api = DocmostApi(creds?.url ?: "") + api.setAuthToken(creds?.authToken) + return api + } +} diff --git a/app/src/main/java/com/docmost/app/data/SyncQueue.kt b/app/src/main/java/com/docmost/app/data/SyncQueue.kt new file mode 100644 index 0000000..cefbaae --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SyncQueue.kt @@ -0,0 +1,56 @@ +package com.docmost.app.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +enum class OperationType { + CREATE_SPACE, + UPDATE_SPACE, + DELETE_SPACE, + CREATE_PAGE, + UPDATE_PAGE, + DELETE_PAGE, + UPLOAD_IMAGE, + UPLOAD_ATTACHMENT, + UPDATE_COMMENT, + DELETE_COMMENT +} + +@Entity( + tableName = "pending_operations", + indices = [ + Index(value = ["entityId"]), + Index(value = ["entityType"]), + Index(value = ["type"]) + ] +) +data class PendingOperation( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val type: OperationType, + val entityType: String, + val entityId: String?, + val payload: String, + val timestamp: Long, + val retryCount: Int = 0, + val createdAt: Long = System.currentTimeMillis(), + val tempId: String? = null +) + +class Converters { + private val gson = Gson() + + @TypeConverter + fun fromOperationType(value: OperationType): String { + return value.name + } + + @TypeConverter + fun toOperationType(value: String): OperationType { + return OperationType.valueOf(value) + } +} diff --git a/app/src/main/java/com/docmost/app/data/SyncQueueDao.kt b/app/src/main/java/com/docmost/app/data/SyncQueueDao.kt new file mode 100644 index 0000000..0b3ce01 --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SyncQueueDao.kt @@ -0,0 +1,49 @@ +package com.docmost.app.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncQueueDao { + @Query("SELECT * FROM pending_operations ORDER BY timestamp ASC") + fun getAllOperations(): Flow> + + @Query("SELECT * FROM pending_operations ORDER BY timestamp ASC") + suspend fun getAllOperationsList(): List + + @Query("SELECT * FROM pending_operations WHERE entityId = :entityId AND type = :type") + suspend fun getOperationByEntity(entityId: String, type: OperationType): PendingOperation? + + @Query("SELECT COUNT(*) FROM pending_operations") + suspend fun getPendingCount(): Int + + @Query("SELECT COUNT(*) FROM pending_operations") + fun observePendingCount(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOperation(operation: PendingOperation): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOperations(operations: List) + + @Update + suspend fun updateOperation(operation: PendingOperation) + + @Delete + suspend fun deleteOperation(operation: PendingOperation) + + @Query("DELETE FROM pending_operations WHERE id = :id") + suspend fun deleteOperationById(id: Long) + + @Query("DELETE FROM pending_operations WHERE entityId = :entityId") + suspend fun deleteOperationsByEntity(entityId: String) + + @Query("DELETE FROM pending_operations WHERE entityType = :entityType") + suspend fun deleteOperationsByEntityType(entityType: String) + + @Query("DELETE FROM pending_operations") + suspend fun deleteAll() + + @Query("DELETE FROM pending_operations WHERE retryCount >= 3") + suspend fun deleteFailedOperations() +} diff --git a/app/src/main/java/com/docmost/app/data/SyncQueueDatabase.kt b/app/src/main/java/com/docmost/app/data/SyncQueueDatabase.kt new file mode 100644 index 0000000..337b3f1 --- /dev/null +++ b/app/src/main/java/com/docmost/app/data/SyncQueueDatabase.kt @@ -0,0 +1,36 @@ +package com.docmost.app.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database( + entities = [PendingOperation::class], + version = 2, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class SyncQueueDatabase : RoomDatabase() { + abstract fun syncQueueDao(): SyncQueueDao + + companion object { + @Volatile + private var INSTANCE: SyncQueueDatabase? = null + + fun getDatabase(context: Context): SyncQueueDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + SyncQueueDatabase::class.java, + "sync_queue_database" + ) + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/docmost/app/ui/GradientBorderDrawable.kt b/app/src/main/java/com/docmost/app/ui/GradientBorderDrawable.kt new file mode 100644 index 0000000..095dae5 --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/GradientBorderDrawable.kt @@ -0,0 +1,80 @@ +package com.docmost.app.ui + +import android.graphics.* +import android.graphics.drawable.Drawable + +class GradientBorderDrawable : Drawable() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val clipPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + private var colorStart = Color.TRANSPARENT + private var colorEnd = Color.TRANSPARENT + private var strokeWidth = 0f + private var cornerRadius = 0f + + private var gradientShader: Shader? = null + + fun setStroke(strokeWidth: Float, cornerRadius: Float) { + this.strokeWidth = strokeWidth + this.cornerRadius = cornerRadius + invalidateSelf() + } + + fun setGradientColors(start: Int, end: Int) { + this.colorStart = start + this.colorEnd = end + gradientShader = null + invalidateSelf() + } + + override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + super.setBounds(left, top, right, bottom) + gradientShader = null + } + + override fun draw(canvas: Canvas) { + val b = bounds + if (b.width() <= 0 || b.height() <= 0) return + + if (gradientShader == null) { + gradientShader = LinearGradient( + 0f, 0f, b.width().toFloat(), 0f, + colorStart, colorEnd, Shader.TileMode.CLAMP + ) + } + + paint.shader = gradientShader + paint.style = Paint.Style.FILL + + val saved = canvas.saveLayer(b.left.toFloat(), b.top.toFloat(), b.right.toFloat(), b.bottom.toFloat(), null) + + canvas.drawRoundRect( + 0f, 0f, b.width().toFloat(), b.height().toFloat(), + cornerRadius, cornerRadius, paint + ) + + val inset = strokeWidth + canvas.drawRoundRect( + inset, inset, + b.width() - inset, b.height() - inset, + maxOf(0f, cornerRadius - inset), maxOf(0f, cornerRadius - inset), + clipPaint + ) + + canvas.restoreToCount(saved) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} diff --git a/app/src/main/java/com/docmost/app/ui/LoginActivity.kt b/app/src/main/java/com/docmost/app/ui/LoginActivity.kt new file mode 100644 index 0000000..3c430c3 --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/LoginActivity.kt @@ -0,0 +1,95 @@ +package com.docmost.app.ui + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.docmost.app.R +import com.docmost.app.api.DocmostApi +import com.docmost.app.data.Credentials +import com.docmost.app.data.SessionManager +import com.docmost.app.databinding.ActivityLoginBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LoginActivity : AppCompatActivity() { + + private lateinit var binding: ActivityLoginBinding + private lateinit var sessionManager: SessionManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.hide() + + sessionManager = SessionManager(this) + + if (sessionManager.hasSession()) { + navigateToSpaces() + return + } + + val saved = sessionManager.getCredentials() + if (saved != null) { + binding.etUrl.setText(saved.url) + binding.etEmail.setText(saved.email) + binding.etPassword.setText(saved.password) + } + + binding.btnConnect.setOnClickListener { + val url = binding.etUrl.text.toString().trim() + val email = binding.etEmail.text.toString().trim() + val password = binding.etPassword.text.toString() + + if (url.isBlank() || email.isBlank() || password.isBlank()) { + Toast.makeText(this, R.string.fill_all_fields, Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + doLogin(url, email, password) + } + } + + private fun doLogin(url: String, email: String, password: String) { + binding.btnConnect.isEnabled = false + binding.btnConnect.text = getString(R.string.connecting) + + CoroutineScope(Dispatchers.IO).launch { + try { + val api = DocmostApi(url) + val token = api.login(email, password) + + val user = api.getCurrentUser() + val avatarUrl = user?.avatarUrl + + val credentials = Credentials(url, email, password, token, avatarUrl) + sessionManager.saveCredentials(credentials) + sessionManager.saveAccount(credentials) + + withContext(Dispatchers.Main) { + navigateToSpaces() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.btnConnect.isEnabled = true + binding.btnConnect.text = getString(R.string.connect) + Toast.makeText( + this@LoginActivity, + "Error: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + } + + private fun navigateToSpaces() { + val intent = Intent(this, SpacesActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + } +} diff --git a/app/src/main/java/com/docmost/app/ui/PageEditorActivity.kt b/app/src/main/java/com/docmost/app/ui/PageEditorActivity.kt new file mode 100644 index 0000000..486ce57 --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/PageEditorActivity.kt @@ -0,0 +1,2657 @@ +package com.docmost.app.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.print.PrintAttributes +import android.print.PrintManager +import android.view.GestureDetector +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.webkit.JavascriptInterface +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.ArrayAdapter +import android.widget.GridView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.docmost.app.R +import com.docmost.app.api.Comment +import com.docmost.app.api.DocmostApi +import com.docmost.app.api.CachedApi +import com.docmost.app.api.OfflineNotAvailableException +import com.docmost.app.data.SpaceCacheManager +import com.docmost.app.api.AttachmentType +import com.docmost.app.api.Page +import com.docmost.app.api.PageHistory +import com.docmost.app.data.PageCacheManager +import com.docmost.app.data.SessionManager +import com.docmost.app.data.SyncManager +import com.docmost.app.data.SyncStatus +import com.docmost.app.utils.ContentConverter +import com.docmost.app.utils.NetworkConnectivityObserver +import com.docmost.app.utils.NetworkStatus +import com.docmost.app.databinding.ActivityPageEditorBinding +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.lifecycle.lifecycleScope +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Base64 +import com.docmost.app.BuildConfig +import java.util.concurrent.TimeUnit + +class PageEditorActivity : AppCompatActivity() { + + data class IndexItem(val level: Int, val text: String, val position: Int) + + private lateinit var binding: ActivityPageEditorBinding + private lateinit var sessionManager: SessionManager + private lateinit var api: DocmostApi + private lateinit var cachedApi: CachedApi + private lateinit var spaceCacheManager: SpaceCacheManager + private val gson = GsonBuilder().setPrettyPrinting().create() + + private var pageId: String? = null + private var originalTitle: String = "" + private var pageEmoji: String? = null + private var canEdit: Boolean = true + private lateinit var pageCacheManager: PageCacheManager + private lateinit var networkObserver: NetworkConnectivityObserver + private lateinit var syncManager: SyncManager + private var currentPageContent: String = "" + private var originalContent: String = "" + private var isOfflineMode: Boolean = false + private var originalEmoji: String? = null + private var isEditorReadOnly: Boolean = true + private var currentPage: Page? = null + + private var fileUploadCallback: ValueCallback>? = null + + private val filePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "filePickerLauncher result: ${result.resultCode}") + if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + val uri = data?.data + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Selected file URI: $uri") + if (uri != null) { + fileUploadCallback?.onReceiveValue(arrayOf(uri)) + handleSelectedFile(uri) + } else { + fileUploadCallback?.onReceiveValue(null) + } + } else { + fileUploadCallback?.onReceiveValue(null) + } + fileUploadCallback = null + } + + private val versionViewerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + if (data?.getBooleanExtra("loadVersion", false) == true) { + val tipTapJson = data.getStringExtra("tipTapJson") ?: "" + if (tipTapJson.isNotEmpty()) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Loading version with TipTap JSON: ${tipTapJson.length} chars") + val content = tiptapToMarkdown(tipTapJson) + currentPageContent = content + originalContent = content + loadEditorContent(content) + binding.historyCard.visibility = View.GONE + binding.btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + Toast.makeText(this, "Versión cargada", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun handleSelectedFile(uri: Uri) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "handleSelectedFile called with URI: $uri") + CoroutineScope(Dispatchers.IO).launch { + try { + val inputStream = contentResolver.openInputStream(uri) + if (inputStream != null) { + val bytes = inputStream.readBytes() + inputStream.close() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Read ${bytes.size} bytes from file") + + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val fileName = getFileNameFromUri(uri) ?: "file" + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "File name: $fileName, MIME type: $mimeType") + + val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + val creds = sessionManager.getCredentials() + if (creds != null) { + val baseUrl = creds.url.trimEnd('/') + + val requestBody = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("pageId", pageId!!) + .addFormDataPart("file", fileName, bytes.toRequestBody(mimeType.toMediaTypeOrNull())) + .build() + + val request = Request.Builder() + .url("$baseUrl/api/files/upload") + .post(requestBody) + .addHeader("Cookie", "authToken=${creds.authToken}") + .build() + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Uploading to: $baseUrl/api/files/upload with pageId=$pageId") + val response = client.newCall(request).execute() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response code: ${response.code}") + + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response body: $responseBody") + val jsonResponse = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java) + val attachmentId = jsonResponse.get("id")?.asString + val attachmentFileName = jsonResponse.get("fileName")?.asString + + if (attachmentId != null) { + val resultUrl = "$baseUrl/api/files/$attachmentId/$attachmentFileName" + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload successful, URL: $resultUrl") + withContext(Dispatchers.Main) { + // Determine if it's an image or file based on MIME type + val isImage = mimeType.startsWith("image/") + if (isImage) { + binding.webViewEditor.evaluateJavascript( + "window.handleImageUploaded('$resultUrl')" + ) { result -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "handleImageUploaded result: $result") + } + } else { + binding.webViewEditor.evaluateJavascript( + "if(window.handleFileUploaded) window.handleFileUploaded('$resultUrl', '$attachmentFileName', ${bytes.size})" + ) { result -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "handleFileUploaded result: $result") + } + } + } + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "No attachment id in response: $responseBody") + } + } else { + val errorBody = response.body?.string() + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Upload failed: ${response.code}, body: $errorBody") + } + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "No credentials available") + } + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Could not open input stream for URI: $uri") + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error handling selected file: ${e.message}") + e.printStackTrace() + } + } + } + + private fun getFileNameFromUri(uri: Uri): String? { + var fileName: String? = null + val cursor = contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (displayNameIndex >= 0) { + fileName = it.getString(displayNameIndex) + } + } + } + return fileName ?: uri.lastPathSegment?.substringAfterLast('/') + } + + private var swipeDetected = false + + private val swipeDetector by lazy { GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + if (!canEdit) return false + if (e1 == null) return false + if (binding.tabLayout.selectedTabPosition == 0) return false + val diffX = e2.x - e1.x + if (kotlin.math.abs(diffX) > kotlin.math.abs(e2.y - e1.y) * 1.5 && kotlin.math.abs(diffX) > 100 && kotlin.math.abs(velocityX) > 200) { + val current = binding.tabLayout.selectedTabPosition + when { + diffX < 0 && current < binding.tabLayout.tabCount - 1 -> { + binding.tabLayout.getTabAt(current + 1)?.select() + updateTabVisibility(current + 1) + swipeDetected = true + return true + } + diffX > 0 && current > 0 -> { + binding.tabLayout.getTabAt(current - 1)?.select() + updateTabVisibility(current - 1) + swipeDetected = true + return true + } + else -> return false + } + } + return false + } + }) } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + if (ev != null) { + swipeDetected = false + swipeDetector.onTouchEvent(ev) + if (swipeDetected) { + return true + } + } + return super.dispatchTouchEvent(ev) + } + + private var isEditorRendered = false + private var pendingContent: String? = null + private var isTitleVisible = true + private var isAnimating = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPageEditorBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.hide() + + pageId = intent.getStringExtra("pageId") + ?: intent.getStringExtra("page_id") + ?: intent.getStringExtra("extra_page_id") + ?: intent.data?.lastPathSegment + + if (pageId == null) { + finish() + return + } + + sessionManager = SessionManager(this) + val creds = sessionManager.getCredentials() + if (creds == null) { + finish() + return + } + + com.docmost.app.utils.ImageLoader.init(this, sessionManager) + + api = DocmostApi(creds.url) + api.setAuthToken(creds.authToken) + pageCacheManager = PageCacheManager(this) + pageCacheManager.setCurrentUser(creds.email, creds.url) + networkObserver = NetworkConnectivityObserver(this) + spaceCacheManager = SpaceCacheManager(this) + spaceCacheManager.setCurrentUser(creds.email, creds.url) + syncManager = SyncManager.getInstance(this, api, pageCacheManager, spaceCacheManager) + cachedApi = CachedApi(api, pageCacheManager, spaceCacheManager, syncManager, networkObserver) + + observeNetworkStatus() + + isEditorRendered = false + pendingContent = null + + setupWebView() + + val cookieManager = android.webkit.CookieManager.getInstance() + val baseUrl = creds.url.trimEnd('/') + val domain = baseUrl.removePrefix("https://").removePrefix("http://") + cookieManager.setCookie(domain, "authToken=${creds.authToken}") + cookieManager.setCookie(baseUrl, "authToken=${creds.authToken}") + cookieManager.flush() + + setupTabs() + setupInfoToggle() + setupHistoryToggle() + + applyThemeToWebView() + + binding.btnSave.visibility = View.GONE + binding.btnSave.setOnClickListener { + savePage() + } + + binding.fabToggleMode.setOnClickListener { + toggleEditorMode() + } + + binding.tvEmoji.setOnClickListener { + showEmojiPicker() + } + + binding.rvComments.layoutManager = LinearLayoutManager(this) + binding.rvIndex.layoutManager = LinearLayoutManager(this) + binding.btnSendComment.setOnClickListener { + val text = binding.etCommentInput.text.toString().trim() + if (text.isNotEmpty()) { + createComment(text) + } + } + + updateOfflineBanner() + + loadPage() + } + + private fun observeNetworkStatus() { + lifecycleScope.launch { + networkObserver.observeNetworkStatus().collect { status -> + isOfflineMode = (status == NetworkStatus.OFFLINE) + updateOfflineBanner() + } + } + } + + private fun updateOfflineBanner() { + if (isOfflineMode) { + binding.tvOfflineBanner.visibility = View.VISIBLE + binding.tvOfflineBanner.text = "🔴 Modo sin conexión - Los cambios se guardarán localmente" + Toast.makeText(this, "Sin conexión - Los cambios se sincronizarán después", Toast.LENGTH_LONG).show() + } else { + binding.tvOfflineBanner.visibility = View.GONE + + lifecycleScope.launch { + val pendingCount = syncManager.getPendingCount() + if (pendingCount > 0) { + Toast.makeText(this@PageEditorActivity, + "🟡 $pendingCount cambios pendientes de sincronizar", + Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun setupWebView() { + binding.webViewEditor.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + databaseEnabled = false + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = false + displayZoomControls = false + allowUniversalAccessFromFileURLs = false + allowFileAccessFromFileURLs = false + mediaPlaybackRequiresUserGesture = false + setGeolocationEnabled(false) + cacheMode = android.webkit.WebSettings.LOAD_CACHE_ELSE_NETWORK + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + } + + // Configurar cookies para que las imágenes se puedan cargar + val cookieManager = android.webkit.CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(binding.webViewEditor, true) + + binding.webViewEditor.addJavascriptInterface(EditorInterface(), "AndroidInterface") + + binding.webViewEditor.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "onPageFinished called") + val credsForBaseUrl = sessionManager.getCredentials() + if (credsForBaseUrl != null) { + val cleanUrl = credsForBaseUrl.url.trimEnd('/') + binding.webViewEditor.evaluateJavascript( + "window._baseUrl = '$cleanUrl';", null + ) + } + // Esperar un poco para que el JavaScript se inicialice + Handler(Looper.getMainLooper()).postDelayed({ + pendingContent?.let { content -> + if (isEditorRendered) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Loading pending content after delay: ${content.length} chars") + loadEditorContentInternal(content) + pendingContent = null + } else { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Editor still not ready after delay, content will load via onEditorRendered") + } + } + }, 500) + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if (request == null || request.url == null) return null + + val urlString = request.url.toString() + + // Interceptar solicitudes de imágenes del servidor + if (urlString.contains("/api/files/") || urlString.contains("/api/attachments/")) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Intercepting image request: $urlString") + return try { + val creds = sessionManager.getCredentials() + if (creds != null) { + val connection = java.net.URL(urlString).openConnection() as java.net.HttpURLConnection + connection.requestMethod = "GET" + connection.addRequestProperty("Cookie", "authToken=${creds.authToken}") + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + + val inputStream = connection.inputStream + val contentType = connection.contentType ?: "image/jpeg" + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Image intercepted, content-type: $contentType") + + WebResourceResponse(contentType, null, inputStream) + } else { + null + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error intercepting image: ${e.message}") + null + } + } + + return super.shouldInterceptRequest(view, request) + } + } + + binding.webViewEditor.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean { + if (consoleMessage != null) { + if (BuildConfig.DEBUG) android.util.Log.d("WebViewConsole", "${consoleMessage.message()} -- From line ${consoleMessage.lineNumber()} of ${consoleMessage.sourceId()}") + } + return true + } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams? + ): Boolean { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "onShowFileChooser called") + if (fileUploadCallback != null) { + fileUploadCallback?.onReceiveValue(null) + } + fileUploadCallback = filePathCallback + + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + // Aceptar todos los archivos + type = "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + } + try { + filePickerLauncher.launch(Intent.createChooser(intent, "Seleccionar archivo")) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error launching file picker: ${e.message}") + fileUploadCallback = null + return false + } + return true + } + } + + binding.webViewEditor.loadUrl("file:///android_asset/editor/index.html") + } + + private fun applyThemeToWebView() { + val isDark = when (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) { + android.content.res.Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + + Handler(Looper.getMainLooper()).postDelayed({ + val themeJs = if (isDark) "applyDarkTheme()" else "applyLightTheme()" + binding.webViewEditor.evaluateJavascript(themeJs, null) + }, 500) + } + + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + applyThemeToWebView() + } + + private fun hideTitle() { + if (!isTitleVisible || isAnimating) return + + isAnimating = true + binding.titleContainer.animate() + .translationY(-binding.titleContainer.height.toFloat()) + .alpha(0f) + .setDuration(200) + .withEndAction { + binding.titleContainer.visibility = android.view.View.GONE + isAnimating = false + isTitleVisible = false + } + .start() + } + + private fun showTitle() { + if (isTitleVisible || isAnimating) return + + isAnimating = true + binding.titleContainer.visibility = android.view.View.VISIBLE + binding.titleContainer.translationY = -binding.titleContainer.height.toFloat() + binding.titleContainer.alpha = 0f + + binding.titleContainer.animate() + .translationY(0f) + .alpha(1f) + .setDuration(200) + .withEndAction { + isAnimating = false + isTitleVisible = true + } + .start() + } + + private fun toggleEditorMode() { + if (!canEdit) return + isEditorReadOnly = !isEditorReadOnly + + if (isEditorReadOnly) { + saveCurrentContent { + val page = currentPage ?: return@saveCurrentContent + showReadOnlyOverlay(page) + } + } else { + binding.webViewPreview.visibility = View.GONE + binding.editorLayout.visibility = View.VISIBLE + binding.titleContainer.visibility = View.VISIBLE + binding.btnSave.visibility = View.VISIBLE + binding.etTitle.isEnabled = true + binding.tvEmoji.isClickable = true + binding.tvEmoji.isFocusable = true + binding.fabToggleMode.setImageResource(R.drawable.ic_visibility) + + loadEditorContent(currentPageContent) + binding.webViewEditor.requestFocus() + } + } + + inner class EditorInterface { + @JavascriptInterface + fun onThemeChanged(isDark: Boolean) { + runOnUiThread { + val themeJs = if (isDark) "applyDarkTheme()" else "applyLightTheme()" + binding.webViewEditor.evaluateJavascript(themeJs, null) + } + } + + @JavascriptInterface + fun onScrollUp() { + } + + @JavascriptInterface + fun onScrollDown() { + runOnUiThread { + hideTitle() + } + } + + @JavascriptInterface + fun onScrollToTop() { + runOnUiThread { + showTitle() + } + } + + @JavascriptInterface + fun onContentChanged(markdown: String) { + currentPageContent = markdown + } + + @JavascriptInterface + fun onEditorRendered() { + runOnUiThread { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "onEditorRendered called") + isEditorRendered = true + pendingContent?.let { content -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Loading pending content: ${content.length} chars") + loadEditorContentInternal(content) + pendingContent = null + } + } + } + + @JavascriptInterface + fun onAutoSave(markdown: String) { + if (!canEdit) return + + runOnUiThread { + val title = binding.etTitle.text.toString().trim() + + if (markdown == originalContent && title == originalTitle && pageEmoji == originalEmoji) { + return@runOnUiThread + } + + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.updatePage(pageId!!, markdown, title, pageEmoji) + withContext(Dispatchers.Main) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Auto-guardado exitoso") + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error en auto-guardado: ${e.message}") + } + } + } + } + } + + @JavascriptInterface + fun onSaveContent(markdown: String) { + if (!canEdit) return + + val title = binding.etTitle.text.toString().trim() + val hasBodyChanges = markdown.isNotEmpty() && markdown != "{}" && markdown != originalContent + val hasTitleChanges = title != originalTitle + + if (isSaving) { + runOnUiThread { + val currentEmoji = pageEmoji ?: "" + val currentPageId = pageId ?: return@runOnUiThread + val hasEmojiChanges = pageEmoji != originalEmoji + + if (!hasBodyChanges && !hasTitleChanges && !hasEmojiChanges) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Sin cambios, cerrando sin guardar") + isSaving = false + finish() + return@runOnUiThread + } + + if (hasBodyChanges) { + CoroutineScope(Dispatchers.IO).launch { + try { + pageCacheManager.saveDraft(currentPageId, markdown, title, currentEmoji) + + try { + cachedApi.updatePage( + currentPageId, markdown, + if (hasTitleChanges) title else null, + if (hasEmojiChanges) currentEmoji else null + ) + + val cachedPage = pageCacheManager.getCachedPage(currentPageId) + if (cachedPage != null) { + pageCacheManager.cachePage(cachedPage.copy( + title = if (hasTitleChanges) title else cachedPage.title, + icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon, + content = markdown + )) + } + } catch (e: Exception) { + syncManager.enqueueUpdatePage(currentPageId, markdown, title, currentEmoji) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Network save failed, enqueued for sync: ${e.message}") + } + + withContext(Dispatchers.Main) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Guardado local, cerrando") + isSaving = false + finish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error guardando: ${e.message}") + + pageCacheManager.saveDraft(currentPageId, markdown, title, currentEmoji) + syncManager.enqueueUpdatePage(currentPageId, markdown, title, currentEmoji) + + Toast.makeText(this@PageEditorActivity, + "Error: ${e.message}. Cambios guardados localmente.", + Toast.LENGTH_LONG).show() + isSaving = false + finish() + } + } + } + } else if (hasTitleChanges || hasEmojiChanges) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.updatePage( + currentPageId, null, + if (hasTitleChanges) title else null, + if (hasEmojiChanges) currentEmoji else null + ) + } catch (e: Exception) { + syncManager.enqueueUpdatePage(currentPageId, originalContent, title, currentEmoji) + } + withContext(Dispatchers.Main) { + isSaving = false + finish() + } + } + } + } + } else { + if (!hasBodyChanges && !hasTitleChanges && pageEmoji == originalEmoji) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Sin cambios, guardado manual ignorado") + return + } + + runOnUiThread { + binding.btnSave.isEnabled = false + binding.btnSave.text = getString(R.string.save) + + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.updatePage( + pageId!!, + if (hasBodyChanges) markdown else null, + if (hasTitleChanges) title else null, + if (pageEmoji != originalEmoji) pageEmoji else null + ) + withContext(Dispatchers.Main) { + Toast.makeText( + this@PageEditorActivity, + R.string.page_saved, + Toast.LENGTH_SHORT + ).show() + binding.btnSave.isEnabled = true + binding.btnSave.text = getString(R.string.save) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.btnSave.isEnabled = true + binding.btnSave.text = getString(R.string.save) + Toast.makeText( + this@PageEditorActivity, + "Error: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + } + } + } + + @JavascriptInterface + fun setBaseUrl(url: String) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "setBaseUrl: $url") + } + + @JavascriptInterface + fun log(message: String) { + if (BuildConfig.DEBUG) android.util.Log.d("EditorJS", message) + } + + @JavascriptInterface + fun uploadImage(base64: String, fileName: String, mimeType: String): String { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "uploadImage called: fileName=$fileName, mimeType=$mimeType, base64Length=${base64.length}") + return try { + val decodedBytes = Base64.getDecoder().decode(base64) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Decoded bytes length: ${decodedBytes.size}") + + val requestBody = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("type", "page-image") + .addFormDataPart("file", fileName, decodedBytes.toRequestBody(mimeType.toMediaTypeOrNull())) + .build() + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val creds = sessionManager.getCredentials() ?: return "" + val baseUrl = creds.url.trimEnd('/') + + val request = Request.Builder() + .url("$baseUrl/api/attachments/upload-image") + .post(requestBody) + .addHeader("Cookie", "authToken=${creds.authToken}") + .build() + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Uploading to: $baseUrl/api/attachments/upload-image") + val response = client.newCall(request).execute() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response code: ${response.code}") + + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response body: $responseBody") + val jsonResponse = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java) + val attachmentFileName = jsonResponse.get("fileName")?.asString + + if (attachmentFileName != null) { + val resultUrl = "$baseUrl/api/attachments/img/page-image/$attachmentFileName" + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload successful, URL: $resultUrl") + resultUrl + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "No fileName in response: $responseBody") + "" + } + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Upload failed: ${response.code}") + "" + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Upload error: ${e.message}") + e.printStackTrace() + "" + } + } + + @JavascriptInterface + fun uploadFile(base64: String, fileName: String, mimeType: String): String { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "uploadFile called: fileName=$fileName, mimeType=$mimeType, base64Length=${base64.length}") + return try { + val decodedBytes = Base64.getDecoder().decode(base64) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Decoded bytes length: ${decodedBytes.size}") + + val maxSize = 30 * 1024 * 1024 + if (decodedBytes.size > maxSize) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "File too large: ${decodedBytes.size} bytes") + return "" + } + + val requestBody = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("pageId", pageId!!) + .addFormDataPart("file", fileName, decodedBytes.toRequestBody(mimeType.toMediaTypeOrNull())) + .build() + + val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + val creds = sessionManager.getCredentials() ?: return "" + val baseUrl = creds.url.trimEnd('/') + + val request = Request.Builder() + .url("$baseUrl/api/files/upload") + .post(requestBody) + .addHeader("Cookie", "authToken=${creds.authToken}") + .build() + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Uploading to: $baseUrl/api/files/upload") + val response = client.newCall(request).execute() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response code: ${response.code}") + + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload response body: $responseBody") + val jsonResponse = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java) + val attachmentId = jsonResponse.get("id")?.asString + val attachmentFileName = jsonResponse.get("fileName")?.asString + + if (attachmentId != null && attachmentFileName != null) { + val resultUrl = "$baseUrl/api/files/$attachmentId/$attachmentFileName" + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Upload successful, URL: $resultUrl") + resultUrl + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "No fileName in response: $responseBody") + "" + } + } else { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Upload failed: ${response.code}") + "" + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Upload error: ${e.message}") + e.printStackTrace() + "" + } + } + + @JavascriptInterface + fun selectFileForAttachment(): String { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "selectFileForAttachment called") + // This will be handled by opening a file picker directly + // Return empty string, actual file selection happens via callback + return "" + } + + @JavascriptInterface + fun downloadFile(url: String, fileName: String) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "downloadFile called: $url, name: $fileName") + CoroutineScope(Dispatchers.IO).launch { + try { + val creds = sessionManager.getCredentials() + if (creds != null) { + val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection + connection.requestMethod = "GET" + connection.addRequestProperty("Cookie", "authToken=${creds.authToken}") + connection.connectTimeout = 10000 + connection.readTimeout = 30000 + connection.connect() + + val inputStream = connection.inputStream + val bytes = inputStream.readBytes() + inputStream.close() + + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Downloaded ${bytes.size} bytes") + + // Show dialog to save file + withContext(Dispatchers.Main) { + showDownloadDialog(bytes, fileName) + } + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error downloading file: ${e.message}") + withContext(Dispatchers.Main) { + Toast.makeText(this@PageEditorActivity, "Error al descargar: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun showDownloadDialog(bytes: ByteArray, fileName: String) { + MaterialAlertDialogBuilder(this@PageEditorActivity) + .setTitle("Descargar archivo") + .setMessage("¿Deseas guardar el archivo $fileName?") + .setPositiveButton("Guardar") { _, _ -> + saveFile(bytes, fileName) + } + .setNegativeButton("Cancelar", null) + .show() + } + + private fun saveFile(bytes: ByteArray, fileName: String) { + try { + val contentValues = android.content.ContentValues().apply { + put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(android.provider.MediaStore.MediaColumns.MIME_TYPE, contentResolver.getType(android.net.Uri.parse("file:///$fileName")) ?: "*/*") + put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download") + } + + val uri = contentResolver.insert(android.provider.MediaStore.Files.getContentUri("external"), contentValues) + if (uri != null) { + contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(bytes) + } + Toast.makeText(this@PageEditorActivity, "Archivo guardado en Descargas", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@PageEditorActivity, "Error al guardar el archivo", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error saving file: ${e.message}") + Toast.makeText(this@PageEditorActivity, "Error al guardar: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + private fun loadEditorContent(markdown: String) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars") + if (isEditorRendered) { + loadEditorContentInternal(markdown) + } else { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Editor not ready, storing pending content") + pendingContent = markdown + } + } + + private fun loadEditorContentInternal(markdown: String) { + val escapedMarkdown = markdown + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + val js = "window.setContent(\"$escapedMarkdown\")" + binding.webViewEditor.evaluateJavascript(js) { result -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "setContent result: $result") + } + } + + private fun getEditorContent(callback: (String) -> Unit) { + binding.webViewEditor.evaluateJavascript("window.getContent().then(function(md) { return md; })") { value -> + val content = value?.replace("^\"|\"$".toRegex(), "") + ?.replace("\\n", "\n") + ?.replace("\\\"", "\"") + ?.replace("\\\\", "\\") ?: "" + callback(content) + } + } + + private var editingCommentId: String? = null + + private fun setupTabs() { + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("Página")) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("Comentarios")) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("Índice")) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("Opciones")) + + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + updateTabVisibility(tab.position) + } + override fun onTabUnselected(tab: TabLayout.Tab) {} + override fun onTabReselected(tab: TabLayout.Tab) {} + }) + } + + private fun updateTabVisibility(position: Int) { + binding.pageContent.visibility = View.GONE + binding.commentsContent.visibility = View.GONE + binding.indexContent.visibility = View.GONE + binding.optionsContent.visibility = View.GONE + when (position) { + 0 -> { + binding.pageContent.visibility = View.VISIBLE + binding.fabToggleMode.visibility = if (canEdit) View.VISIBLE else View.GONE + } + 1 -> { + binding.commentsContent.visibility = View.VISIBLE + binding.fabToggleMode.visibility = View.GONE + loadComments() + } + 2 -> { + binding.indexContent.visibility = View.VISIBLE + binding.fabToggleMode.visibility = View.GONE + loadIndex() + } + 3 -> { + binding.optionsContent.visibility = View.VISIBLE + binding.fabToggleMode.visibility = View.GONE + } + } + } + + private fun setupInfoToggle(parent: View? = null) { + val btnToggleInfo = parent?.findViewById(R.id.btnToggleInfo) ?: binding.btnToggleInfo + val infoCard = parent?.findViewById(R.id.infoCard) ?: binding.infoCard + val tvCreator = parent?.findViewById(R.id.tvCreator) ?: binding.tvCreator + + var isExpanded = false + btnToggleInfo.setOnClickListener { + isExpanded = !isExpanded + if (isExpanded) { + infoCard.visibility = View.VISIBLE + btnToggleInfo.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_less, 0) + if (tvCreator.text.isNullOrBlank()) { + loadPageInfo(parent) + } + } else { + infoCard.visibility = View.GONE + btnToggleInfo.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + } + } + } + + private fun loadPageInfo(parent: View? = null) { + CoroutineScope(Dispatchers.IO).launch { + try { + val page = cachedApi.getPageInfo(pageId!!) + + withContext(Dispatchers.Main) { + val ivCreator = parent?.findViewById(R.id.ivCreator) ?: binding.ivCreator + val tvCreator = parent?.findViewById(R.id.tvCreator) ?: binding.tvCreator + val ivUpdater = parent?.findViewById(R.id.ivUpdater) ?: binding.ivUpdater + val tvUpdater = parent?.findViewById(R.id.tvUpdater) ?: binding.tvUpdater + val tvCreatedAt = parent?.findViewById(R.id.tvCreatedAt) ?: binding.tvCreatedAt + val tvUpdatedAt = parent?.findViewById(R.id.tvUpdatedAt) ?: binding.tvUpdatedAt + + if (page.creator?.avatarUrl.isNullOrBlank()) { + ivCreator.setImageResource(R.drawable.ic_page) + } else { + loadAvatar(ivCreator, page.creator?.avatarUrl) + } + tvCreator.text = page.creator?.name ?: "Desconocido" + + if (page.lastUpdatedBy?.avatarUrl.isNullOrBlank()) { + ivUpdater.setImageResource(R.drawable.ic_page) + } else { + loadAvatar(ivUpdater, page.lastUpdatedBy?.avatarUrl) + } + tvUpdater.text = page.lastUpdatedBy?.name ?: "Desconocido" + + val wordCount = countWords(currentPageContent) + val charCount = currentPageContent.length + tvCreatedAt.text = "📅 Creado: ${formatDateTime(page.createdAt)}" + tvUpdatedAt.text = "🔄 Actualizado: ${formatDateTime(page.updatedAt)}" + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error loading page info: ${e.message}", e) + } + } + } + + private fun countWords(text: String): Int { + return text.split("\\s+".toRegex()).filter { it.isNotBlank() }.size + } + + private fun setupHistoryToggle(parent: View? = null) { + val btnToggleHistory = parent?.findViewById(R.id.btnToggleHistory) ?: binding.btnToggleHistory + val historyCard = parent?.findViewById(R.id.historyCard) ?: binding.historyCard + val rvHistory = parent?.findViewById(R.id.rvHistory) ?: binding.rvHistory + val emptyHistory = parent?.findViewById(R.id.emptyHistory) ?: binding.emptyHistory + val progressOptions = parent?.findViewById(R.id.progressOptions) ?: binding.progressOptions + val btnExportPdf = parent?.findViewById(R.id.btnExportPdf) ?: binding.btnExportPdf + + var isExpanded = false + btnToggleHistory.setOnClickListener { + isExpanded = !isExpanded + if (isExpanded) { + historyCard.visibility = View.VISIBLE + btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_less, 0) + if (rvHistory.adapter == null || rvHistory.adapter!!.itemCount == 0) { + loadPageHistory(parent) + } + } else { + historyCard.visibility = View.GONE + btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + } + } + + btnExportPdf.setOnClickListener { + exportPageToPdf() + } + } + + private fun loadPageHistory(parent: View? = null) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadPageHistory called") + val progress = parent?.findViewById(R.id.progressOptions) ?: binding.progressOptions + val emptyView = parent?.findViewById(R.id.emptyHistory) ?: binding.emptyHistory + val recyclerView = parent?.findViewById(R.id.rvHistory) ?: binding.rvHistory + + progress.visibility = View.VISIBLE + emptyView.visibility = View.GONE + recyclerView.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val historyItems = cachedApi.getPageHistory(pageId!!) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "History loaded: ${historyItems.size} items") + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + if (historyItems.isEmpty()) { + emptyView.visibility = View.VISIBLE + } else { + recyclerView.visibility = View.VISIBLE + renderHistory(historyItems, parent) + } + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + emptyView.text = "No disponible sin conexión" + emptyView.visibility = View.VISIBLE + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error loading history: ${e.message}", e) + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + emptyView.text = "Error: ${e.message}" + emptyView.visibility = View.VISIBLE + } + } + } + } + + private fun renderHistory(history: List, parent: View? = null) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "renderHistory called with ${history.size} items") + val recyclerView = parent?.findViewById(R.id.rvHistory) ?: binding.rvHistory + + if (recyclerView.layoutManager == null) { + recyclerView.layoutManager = LinearLayoutManager(recyclerView.context) + } + + recyclerView.adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = history.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = LayoutInflater.from(this@PageEditorActivity).inflate(R.layout.item_history, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val historyItem = history[position] + val ivAvatar = holder.itemView.findViewById(R.id.ivAvatar) + val tvAuthor = holder.itemView.findViewById(R.id.tvAuthor) + val tvDateTime = holder.itemView.findViewById(R.id.tvDateTime) + + loadAvatar(ivAvatar, historyItem.lastUpdatedBy?.avatarUrl) + tvAuthor.text = historyItem.lastUpdatedBy?.name ?: "Desconocido" + tvDateTime.text = formatDateTime(historyItem.createdAt) + + holder.itemView.setOnClickListener { + showHistoryDiff(historyItem) + } + } + } + } + + private fun loadIndex() { + val pattern = Regex("^(#{1,6})\\s+(.+)$", RegexOption.MULTILINE) + val items = pattern.findAll(currentPageContent).mapIndexed { index, match -> + val level = match.groupValues[1].length + val text = match.groupValues[2].trim() + IndexItem(level = level, text = text, position = match.range.first) + }.toList() + + if (items.isEmpty()) { + binding.rvIndex.visibility = View.GONE + binding.emptyIndex.visibility = View.VISIBLE + return + } + binding.emptyIndex.visibility = View.GONE + binding.rvIndex.visibility = View.VISIBLE + binding.emptyIndex.text = getString(R.string.no_headings) + binding.rvIndex.adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = items.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = LayoutInflater.from(this@PageEditorActivity).inflate(R.layout.item_index, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + val tvIcon = holder.itemView.findViewById(R.id.tvIndexIcon) + val tvText = holder.itemView.findViewById(R.id.tvIndexText) + tvIcon.text = "H${item.level}" + tvIcon.visibility = if (item.level <= 3) View.VISIBLE else View.GONE + tvText.text = item.text + tvText.setPadding(item.level * 16, 0, 0, 0) + holder.itemView.setOnClickListener { goToHeading(item) } + } + } + } + + private fun goToHeading(item: IndexItem) { + binding.tabLayout.getTabAt(0)?.select() + + val escapedText = item.text + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + + Handler(Looper.getMainLooper()).postDelayed({ + val js = """ + (function() { + console.log('goToHeading: searching for "$escapedText"'); + const allHeaders = document.querySelectorAll('h1, h2, h3'); + console.log('goToHeading: found ' + allHeaders.length + ' headers'); + + for (let i = 0; i < allHeaders.length; i++) { + const header = allHeaders[i]; + const headerText = header.textContent.trim(); + console.log('goToHeading: checking header ' + i + ': "' + headerText + '"'); + + if (headerText === "$escapedText") { + console.log('goToHeading: found match, scrolling'); + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + window.AndroidInterface.log('goToHeading: scrolled to header'); + return; + } + } + console.log('goToHeading: no match found'); + window.AndroidInterface.log('goToHeading: no match found'); + })() + """.trimIndent() + + binding.webViewEditor.evaluateJavascript(js) { result -> + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "goToHeading JS result: $result") + } + }, 500) + } + + private fun loadPageHistory() { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadPageHistory called") + binding.progressOptions.visibility = View.VISIBLE + binding.emptyHistory.visibility = View.GONE + binding.rvHistory.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val history = cachedApi.getPageHistory(pageId!!) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "History loaded: ${history.size} items") + + withContext(Dispatchers.Main) { + binding.progressOptions.visibility = View.GONE + + if (history.isEmpty()) { + binding.emptyHistory.visibility = View.VISIBLE + binding.rvHistory.visibility = View.GONE + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "History is empty") + } else { + binding.emptyHistory.visibility = View.GONE + binding.rvHistory.visibility = View.VISIBLE + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Rendering history with ${history.size} items") + renderHistory(history) + } + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.progressOptions.visibility = View.GONE + binding.emptyHistory.visibility = View.VISIBLE + binding.emptyHistory.text = "No disponible sin conexión" + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error loading history: ${e.message}", e) + withContext(Dispatchers.Main) { + binding.progressOptions.visibility = View.GONE + binding.emptyHistory.visibility = View.VISIBLE + binding.emptyHistory.text = "Error: ${e.message}" + } + } + } + } + + private fun renderHistory(history: List) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "renderHistory called with ${history.size} items") + binding.rvHistory.layoutManager = LinearLayoutManager(this) + binding.rvHistory.adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = history.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = LayoutInflater.from(this@PageEditorActivity).inflate(R.layout.item_history, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = history[position] + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Binding history item $position: author=${item.lastUpdatedBy?.name}") + val ivAvatar = holder.itemView.findViewById(R.id.ivAvatar) + val tvAuthor = holder.itemView.findViewById(R.id.tvAuthor) + val tvDateTime = holder.itemView.findViewById(R.id.tvDateTime) + val historyItem = holder.itemView.findViewById(R.id.historyItem) + val btnViewVersion = holder.itemView.findViewById(R.id.btnViewVersion) + + val lastUpdatedBy = item.lastUpdatedBy + if (lastUpdatedBy?.avatarUrl.isNullOrBlank()) { + ivAvatar.setImageResource(R.drawable.ic_page) + } else { + loadAvatar(ivAvatar, lastUpdatedBy?.avatarUrl) + } + + tvAuthor.text = lastUpdatedBy?.name ?: "Usuario desconocido" + tvDateTime.text = formatDateTime(item.createdAt) + + historyItem.setOnClickListener { + viewVersion(item) + } + + btnViewVersion.setOnClickListener { + viewVersion(item) + } + } + } + } + + private fun formatDateTime(dateString: String): String { + return try { + val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US) + format.timeZone = java.util.TimeZone.getTimeZone("UTC") + val date = format.parse(dateString) ?: return dateString + + val outputFormat = java.text.SimpleDateFormat("dd MMM yyyy, HH:mm", java.util.Locale.getDefault()) + outputFormat.format(date) + } catch (e: Exception) { + dateString + } + } + + private fun loadPageFromHistory(historyItem: PageHistory) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Loading page from history: ${historyItem.id}") + binding.progressBar.visibility = View.VISIBLE + + CoroutineScope(Dispatchers.IO).launch { + try { + val history = cachedApi.getPageHistoryInfo(pageId!!, historyItem.id) + val content = if (history.content != null) { + val json = gson.toJson(history.content) + contentToMarkdown(history.content) + } else { + "" + } + + withContext(Dispatchers.Main) { + currentPageContent = content + loadEditorContent(content) + binding.progressBar.visibility = View.GONE + binding.historyCard.visibility = View.GONE + binding.btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + Toast.makeText(this@PageEditorActivity, "Versión cargada", Toast.LENGTH_SHORT).show() + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText(this@PageEditorActivity, "No disponible sin conexión", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error loading history: ${e.message}", e) + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText(this@PageEditorActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun viewVersion(historyItem: PageHistory) { + val intent = android.content.Intent(this, VersionViewerActivity::class.java) + intent.putExtra("pageId", pageId) + intent.putExtra("historyId", historyItem.id) + intent.putExtra("author", historyItem.lastUpdatedBy?.name ?: "Desconocido") + intent.putExtra("dateTime", formatDateTime(historyItem.createdAt)) + versionViewerLauncher.launch(intent) + } + + private fun getTimeAgo(dateString: String): String { + return try { + val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US) + format.timeZone = java.util.TimeZone.getTimeZone("UTC") + val date = format.parse(dateString) ?: return dateString + + val now = java.util.Date() + val diffMillis = now.time - date.time + val diffSeconds = diffMillis / 1000 + val diffMinutes = diffSeconds / 60 + val diffHours = diffMinutes / 60 + val diffDays = diffHours / 24 + + when { + diffMinutes < 1 -> "Ahora" + diffMinutes < 60 -> "hace ${diffMinutes}m" + diffHours < 24 -> "hace ${diffHours}h" + diffDays < 30 -> "hace ${diffDays}d" + diffDays < 365 -> "hace ${diffDays / 30} meses" + else -> "hace ${diffDays / 365} años" + } + } catch (e: Exception) { + dateString + } + } + + private fun showHistoryDiff(historyItem: PageHistory) { + val intent = Intent(this, VersionViewerActivity::class.java).apply { + putExtra("pageId", pageId) + putExtra("historyId", historyItem.id) + putExtra("author", historyItem.lastUpdatedBy?.name ?: "Desconocido") + putExtra("dateTime", formatDateTime(historyItem.createdAt)) + } + startActivity(intent) + } + + private fun exportPageToPdf() { + if (pageId == null) { + Toast.makeText(this, "No hay página para exportar", Toast.LENGTH_SHORT).show() + return + } + + MaterialAlertDialogBuilder(this) + .setTitle("Exportar a PDF") + .setMessage("Se obtendrá el contenido HTML del servidor de Docmost y se generará un PDF.\n\n¿Continuar?") + .setPositiveButton("Exportar") { _, _ -> + generatePdfFromServer() + } + .setNegativeButton("Cancelar", null) + .show() + } + + private fun generatePdfFromServer() { + val creds = sessionManager.getCredentials() ?: run { + Toast.makeText(this, "Error: no hay sesión activa", Toast.LENGTH_SHORT).show() + return + } + + binding.progressOptions.visibility = View.VISIBLE + + lifecycleScope.launch(Dispatchers.IO) { + try { + val htmlContent = cachedApi.getPageHtml(pageId!!) + + withContext(Dispatchers.Main) { + binding.progressOptions.visibility = View.GONE + loadHtmlIntoWebViewAndPrint(htmlContent, creds.url.trimEnd('/')) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressOptions.visibility = View.GONE + Toast.makeText(this@PageEditorActivity, "Error al obtener HTML: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private var pdfWebView: WebView? = null + + private fun loadHtmlIntoWebViewAndPrint(htmlContent: String, baseUrl: String) { + val uiMode = resources.configuration.uiMode + val isDark = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + + val pageTitle = originalTitle.ifBlank { "Página" } + + val darkModeCss = if (isDark) """ + body { + background-color: #1a1a1a !important; + color: #e0e0e0 !important; + } + .page-title { + border-bottom-color: #404040 !important; + } + pre, code { + background: #2d2d2d !important; + color: #e0e0e0 !important; + } + blockquote { + border-left-color: #404040 !important; + color: #9ca3af !important; + } + th { + background: #2a2a2a !important; + } + td, th { + border-color: #404040 !important; + } + a { + color: #64b5f6 !important; + } + """ else "" + + val fullHtml = """ + + + + + + + +
+

$pageTitle

+ $htmlContent +
+ + + """.trimIndent() + + pdfWebView = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.builtInZoomControls = true + settings.displayZoomControls = false + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + printWebPage(view!!) + } + } + + loadDataWithBaseURL("$baseUrl/", fullHtml, "text/html", "UTF-8", null) + } + } + + private fun printWebPage(webView: WebView) { + val printManager = getSystemService(Context.PRINT_SERVICE) as PrintManager + val fileName = originalTitle.ifBlank { "page_export" }.replace(" ", "_") + val printAdapter = webView.createPrintDocumentAdapter(fileName) + + printManager.print( + fileName, + printAdapter, + PrintAttributes.Builder().build() + ) + + Toast.makeText(this, "En el diálogo de impresión, selecciona 'Guardar como PDF'", Toast.LENGTH_LONG).show() + + pdfWebView = null + } + + private fun extractCommentText(content: com.google.gson.JsonObject?): String { + if (content == null) return "" + val sb = StringBuilder() + fun walk(element: com.google.gson.JsonElement) { + if (element.isJsonObject) { + val obj = element.asJsonObject + if (obj.has("text") && obj.get("text").isJsonPrimitive) { + if (sb.isNotEmpty()) sb.append(" ") + sb.append(obj.get("text").asString) + } + if (obj.has("content") && obj.get("content").isJsonArray) { + obj.getAsJsonArray("content").forEach { walk(it) } + } + } + } + walk(content) + return sb.toString() + } + + private fun renderComments(comments: List) { + binding.rvComments.adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = comments.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = LayoutInflater.from(this@PageEditorActivity).inflate(R.layout.item_comment, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val comment = comments[position] + val ivAvatar = holder.itemView.findViewById(R.id.ivCommentAvatar) + val tvAuthor = holder.itemView.findViewById(R.id.tvCommentAuthor) + val tvTime = holder.itemView.findViewById(R.id.tvCommentTime) + val tvContent = holder.itemView.findViewById(R.id.tvCommentContent) + val btnEdit = holder.itemView.findViewById(R.id.btnEditComment) + val btnDelete = holder.itemView.findViewById(R.id.btnDeleteComment) + + loadAvatar(ivAvatar, comment.creator?.avatarUrl) + + tvAuthor.text = comment.creator?.name ?: "Usuario" + tvContent.text = extractCommentText(comment.content) + + btnEdit.setOnClickListener { + editingCommentId = comment.id + binding.etCommentInput.setText(extractCommentText(comment.content)) + } + + btnDelete.setOnClickListener { + showDeleteCommentDialog(comment.id) + } + } + } + } + + private fun loadAvatar(imageView: ImageView, avatarUrl: String?) { + if (avatarUrl.isNullOrBlank()) { + imageView.setImageResource(R.drawable.ic_page) + return + } + val creds = sessionManager.getCredentials() ?: run { + imageView.setImageResource(R.drawable.ic_page) + return + } + val baseUrl = creds.url.trimEnd('/') + val url = "$baseUrl/api/attachments/img/${AttachmentType.AVATAR}/$avatarUrl" + com.docmost.app.utils.ImageLoader.loadSync(url, imageView, R.drawable.ic_page) + } + + private fun loadComments() { + binding.progressComments.visibility = View.VISIBLE + binding.emptyComments.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val comments = cachedApi.getComments(pageId!!) + withContext(Dispatchers.Main) { + binding.progressComments.visibility = View.GONE + if (comments.isEmpty()) { + binding.emptyComments.visibility = View.VISIBLE + binding.emptyComments.text = getString(R.string.no_comments) + } + renderComments(comments) + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.progressComments.visibility = View.GONE + binding.emptyComments.visibility = View.VISIBLE + binding.emptyComments.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressComments.visibility = View.GONE + binding.emptyComments.visibility = View.VISIBLE + binding.emptyComments.text = "Error: ${e.message}" + } + } + } + } + + private fun createComment(text: String) { + val id = editingCommentId + if (id != null) { + updateComment(id, text) + return + } + + binding.btnSendComment.isEnabled = false + CoroutineScope(Dispatchers.IO).launch { + try { + val tiptapContent = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":${gson.toJson(text)}}]}]}" + cachedApi.createComment(pageId!!, tiptapContent) + withContext(Dispatchers.Main) { + binding.etCommentInput.text?.clear() + binding.btnSendComment.isEnabled = true + editingCommentId = null + loadComments() + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.btnSendComment.isEnabled = true + Toast.makeText(this@PageEditorActivity, "No disponible sin conexión", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.btnSendComment.isEnabled = true + Toast.makeText(this@PageEditorActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun updateComment(commentId: String, text: String) { + binding.btnSendComment.isEnabled = false + CoroutineScope(Dispatchers.IO).launch { + try { + val tiptapContent = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":${gson.toJson(text)}}]}]}" + cachedApi.updateComment(commentId, tiptapContent) + withContext(Dispatchers.Main) { + binding.etCommentInput.text?.clear() + binding.btnSendComment.isEnabled = true + editingCommentId = null + loadComments() + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.btnSendComment.isEnabled = true + Toast.makeText(this@PageEditorActivity, "No disponible sin conexión", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.btnSendComment.isEnabled = true + Toast.makeText(this@PageEditorActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun showDeleteCommentDialog(commentId: String) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete) + .setMessage("¿Eliminar este comentario?") + .setPositiveButton(R.string.delete) { _, _ -> + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.deleteComment(commentId) + withContext(Dispatchers.Main) { + loadComments() + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + Toast.makeText(this@PageEditorActivity, "No disponible sin conexión", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@PageEditorActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun showEmojiPicker() { + val allEmojis = arrayOf( + "📝", "📄", "📋", "📌", "📎", "🔖", + "✏️", "🖊️", "📚", "📖", "🔍", "💡", + "🚀", "⭐", "🔥", "💎", "🎯", "📊", + "🗂️", "📁", "🏠", "🔧", "🎨", "💻", + "🌟", "🎉", "✅", "❓", "💬", "📢", + "🎵", "🎬", "🏆", "🧩", "🏗️", "⚡", + "🎈", "🎁", "📦", "🔗", "🧠", "⚙️", + "🚩", "🧰", "📈", "🗺️", "🧭", "🌱", + "☀️", "🌙", "🎧", "🎮", "📱", "🖥️", + "🔒", "🔓", "⏰", "💾", "🛠️", "🎯" + ) + + val view = LayoutInflater.from(this) + .inflate(R.layout.dialog_emoji_picker, null) + val gridView = view.findViewById(R.id.emojiGrid) + val btnRemove = view.findViewById(R.id.btnRemoveEmoji) + + val maxHeight = (resources.displayMetrics.heightPixels * 0.5).toInt() + gridView.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, maxHeight + ) + + val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, allEmojis) + gridView.adapter = adapter + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + + gridView.setOnItemClickListener { _, _, position, _ -> + pageEmoji = allEmojis[position] + binding.tvEmoji.text = pageEmoji + dialog.dismiss() + } + + btnRemove.setOnClickListener { + pageEmoji = null + binding.tvEmoji.text = getString(R.string.add_emoji) + dialog.dismiss() + } + + dialog.show() + } + + private fun tiptapToMarkdown(tiptapJson: String): String { + return try { + val mapType = object : TypeToken>() {}.type + val doc = gson.fromJson>(tiptapJson, mapType) + extractText(doc) + } catch (e: Exception) { + tiptapJson + } + } + + private fun extractText(node: Map): String { + val type = node["type"] as? String + + if (type == "text") { + var text = node["text"] as? String ?: "" + val marks = node["marks"] as? List> + marks?.forEach { mark -> + when (mark["type"] as? String) { + "bold" -> text = "**$text**" + "italic" -> text = "*$text*" + "code" -> text = "`$text`" + "strike" -> text = "~~$text~~" + "link" -> { + val href = (mark["attrs"] as? Map<*, *>)?.get("href") as? String ?: "" + text = "[$text]($href)" + } + } + } + return text + } + + if (type == "hardBreak") { + return " \n" + } + + val sb = StringBuilder() + val content = node["content"] + + if (type == "orderedList") { + if (content is List<*>) { + var index = 1 + for (child in content) { + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + val childMap = child as Map + val listItemContent = childMap["content"] as? List<*> + var itemText = "" + if (listItemContent != null) { + for (paragraph in listItemContent) { + if (paragraph is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + itemText = extractText(paragraph as Map).trimEnd() + } + } + } + sb.append("${index}. ${itemText.trimEnd()}\n") + index++ + } + } + } + return sb.toString() + "\n" + } + + if (type == "table") { + return extractTable(node) + } + + if (content is List<*>) { + for (child in content) { + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(extractText(child as Map)) + } + } + } + + val attrs = node["attrs"] as? Map<*, *> + + return when (type) { + "paragraph" -> sb.toString().trimEnd() + "\n\n" + "heading" -> { + val level = (attrs?.get("level") as? Double)?.toInt() ?: 1 + "#".repeat(level.coerceIn(1, 6)) + " " + sb.toString().trimEnd() + "\n\n" + } + "image" -> { + val src = attrs?.get("src") as? String ?: "" + val alt = attrs?.get("alt") as? String ?: "" + "![$alt]($src)\n\n" + } + "attachment" -> { + val url = attrs?.get("url") as? String ?: "" + val name = attrs?.get("name") as? String ?: "Archivo" + "[$name]($url)\n\n" + } + "taskList" -> sb.toString() + "\n" + "taskItem" -> { + val checked = attrs?.get("checked") as? Boolean ?: false + val prefix = if (checked) "- [x] " else "- [ ] " + prefix + sb.toString().trimEnd() + "\n" + } + "bulletList" -> sb.toString() + "\n" + "listItem" -> "- " + sb.toString().trimEnd() + "\n" + "horizontalRule" -> "---\n\n" + "codeBlock" -> { + val lang = attrs?.get("language") as? String ?: "" + "```$lang\n${sb.toString().trimEnd()}\n```\n\n" + } + "blockquote" -> "> " + sb.toString().trimEnd().replace("\n", "\n> ") + "\n\n" + else -> sb.toString() + } + } + + private fun extractTable(node: Map): String { + val sb = StringBuilder() + val rows = node["content"] as? List<*> ?: return "" + + var isFirstRow = true + for (row in rows) { + if (row !is Map<*, *>) continue + @Suppress("UNCHECKED_CAST") + val rowMap = row as Map + val cells = rowMap["content"] as? List<*> ?: continue + + val cellTexts = mutableListOf() + var hasHeader = false + + for (cell in cells) { + if (cell !is Map<*, *>) continue + @Suppress("UNCHECKED_CAST") + val cellMap = cell as Map + val cellType = cellMap["type"] as? String + + if (cellType == "tableHeader") { + hasHeader = true + } + + val cellContent = cellMap["content"] as? List<*> + var cellText = "" + if (cellContent != null) { + for (paragraph in cellContent) { + if (paragraph is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + cellText = extractText(paragraph as Map).trimEnd() + } + } + } + cellTexts.add(cellText) + } + + sb.append("| ") + sb.append(cellTexts.joinToString(" | ")) + sb.append(" |\n") + + if (isFirstRow && hasHeader) { + sb.append("| ") + sb.append(cellTexts.joinToString(" | ") { "---" }) + sb.append(" |\n") + isFirstRow = false + } else if (isFirstRow) { + isFirstRow = false + } + } + + return sb.toString() + "\n" + } + + private fun loadPage() { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadPage() called for pageId: $pageId") + binding.progressBar.visibility = View.VISIBLE + binding.editorLayout.visibility = View.GONE + binding.tvOfflineBanner.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + val cachedPage = pageCacheManager.getCachedPage(pageId!!) + + if (cachedPage != null) { + withContext(Dispatchers.Main) { + renderPageContent(cachedPage, isFromCache = true) + } + } + + try { + val page = cachedApi.getPageInfo(pageId!!) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Page loaded from API: id=${page.id}, title='${page.title}', canEdit=${page.permissions?.canEdit}") + pageCacheManager.cachePage(page) + + withContext(Dispatchers.Main) { + if (cachedPage == null || page.updatedAt != cachedPage.updatedAt) { + renderPageContent(page, isFromCache = false) + } + } + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error loading page from API: ${e.message}", e) + if (cachedPage == null) { + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText( + this@PageEditorActivity, + "Error: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } + } + } + } + + private fun renderPageContent(page: Page, isFromCache: Boolean) { + originalTitle = page.title + pageEmoji = page.icon + originalEmoji = page.icon + canEdit = page.permissions?.canEdit ?: true + currentPage = page + + if (!canEdit) { + loadReadOnlyPreview(page) + } else { + val markdown = contentToMarkdown(page.content) + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Markdown content length: ${markdown.length}") + currentPageContent = markdown + originalContent = markdown + binding.etTitle.setText(page.title) + binding.tvEmoji.text = page.icon ?: getString(R.string.add_emoji) + + binding.progressBar.visibility = View.GONE + binding.editorContainer.visibility = View.VISIBLE + binding.tabLayout.visibility = View.VISIBLE + binding.btnSave.visibility = View.GONE + binding.fabToggleMode.visibility = View.VISIBLE + + if (isFromCache && cachedApi.isOffline) { + binding.tvOfflineBanner.visibility = View.VISIBLE + } else { + binding.tvOfflineBanner.visibility = View.GONE + } + + showReadOnlyOverlay(page) + loadEditorContent(markdown) + } + } + + private fun showReadOnlyOverlay(page: Page) { + isEditorReadOnly = true + binding.editorLayout.visibility = View.GONE + binding.titleContainer.visibility = View.GONE + binding.webViewPreview.visibility = View.VISIBLE + binding.fabToggleMode.setImageResource(R.drawable.ic_pencil) + + binding.webViewPreview.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + builtInZoomControls = false + displayZoomControls = false + loadWithOverviewMode = false + useWideViewPort = false + } + binding.webViewPreview.webViewClient = object : WebViewClient() {} + + val creds = sessionManager.getCredentials() + val baseUrl = creds?.url?.trimEnd('/') ?: "" + + lifecycleScope.launch(Dispatchers.IO) { + try { + val htmlContent = cachedApi.getPageHtml(page.id) + withContext(Dispatchers.Main) { + val fullHtml = buildReadOnlyHtml(htmlContent) + if (baseUrl.isNotEmpty()) { + binding.webViewPreview.loadDataWithBaseURL("$baseUrl/", fullHtml, "text/html", "UTF-8", null) + } else { + binding.webViewPreview.loadData(fullHtml, "text/html", "UTF-8") + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + val fallbackContent = if (page.content != null) { + val json = gson.toJson(page.content) + ContentConverter.tipTapToHtml(json) + } else { + "

Sin contenido

" + } + val fullHtml = buildReadOnlyHtml(fallbackContent) + if (baseUrl.isNotEmpty()) { + binding.webViewPreview.loadDataWithBaseURL("$baseUrl/", fullHtml, "text/html", "UTF-8", null) + } else { + binding.webViewPreview.loadData(fullHtml, "text/html", "UTF-8") + } + } + } + } + } + + private fun buildReadOnlyHtml(htmlContent: String): String { + val uiMode = resources.configuration.uiMode + val isDark = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + + val darkModeCss = if (isDark) """ + body { + background-color: #1a1a1a !important; + color: #e0e0e0 !important; + } + pre, code { + background: #2d2d2d !important; + color: #e0e0e0 !important; + } + blockquote { + border-left-color: #404040 !important; + color: #9ca3af !important; + } + th { + background: #2a2a2a !important; + } + td, th { + border-color: #404040 !important; + } + a { + color: #64b5f6 !important; + } + """ else "" + + return """ + + + + + + + + +
+ $htmlContent +
+ + + """.trimIndent() + } + + private fun setupReadOnlyViewPager(fullHtml: String, baseUrl: String, indexItems: List) { + class ReadOnlyViewPagerAdapter : RecyclerView.Adapter() { + override fun getItemCount() = 3 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(this@PageEditorActivity).inflate( + when (viewType) { + 0 -> R.layout.fragment_readonly_content + 1 -> R.layout.fragment_readonly_index + else -> R.layout.fragment_readonly_options + }, + parent, false + ) + return object : RecyclerView.ViewHolder(view) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (position == 0) { + val webView = holder.itemView.findViewById(R.id.webViewContent) + webView.apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + builtInZoomControls = true + displayZoomControls = false + loadWithOverviewMode = true + useWideViewPort = true + } + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + } + } + if (baseUrl.isNotEmpty()) { + loadDataWithBaseURL("$baseUrl/", fullHtml, "text/html", "UTF-8", null) + } else { + loadData(fullHtml, "text/html", "UTF-8") + } + } + } else if (position == 1) { + val recyclerView = holder.itemView.findViewById(R.id.rvIndex) + val emptyView = holder.itemView.findViewById(R.id.emptyIndex) + if (indexItems.isEmpty()) { + recyclerView.visibility = View.GONE + emptyView.visibility = View.VISIBLE + emptyView.text = "No hay encabezados en esta página" + } else { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + if (recyclerView.layoutManager == null) { + recyclerView.layoutManager = LinearLayoutManager(recyclerView.context) + } + recyclerView.adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = indexItems.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = LayoutInflater.from(this@PageEditorActivity).inflate(R.layout.item_index, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = indexItems[position] + val tvIcon = holder.itemView.findViewById(R.id.tvIndexIcon) + val tvText = holder.itemView.findViewById(R.id.tvIndexText) + tvIcon.text = "H${item.level}" + tvIcon.visibility = if (item.level <= 3) View.VISIBLE else View.GONE + tvText.text = item.text + tvText.setPadding(item.level * 16, 0, 0, 0) + holder.itemView.setOnClickListener { + goToHeadingInWebView(item) + } + } + } + } + } else { + val btnToggleInfo = holder.itemView.findViewById(R.id.btnToggleInfo) + val btnToggleHistory = holder.itemView.findViewById(R.id.btnToggleHistory) + val btnExportPdf = holder.itemView.findViewById(R.id.btnExportPdf) + btnExportPdf?.setOnClickListener { exportPageToPdf() } + btnToggleInfo?.setOnClickListener { + val infoCard = holder.itemView.findViewById(R.id.infoCard) + if (infoCard?.visibility == View.GONE) { + infoCard.visibility = View.VISIBLE + btnToggleInfo.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_less, 0) + loadPageInfo(holder.itemView) + } else { + infoCard.visibility = View.GONE + btnToggleInfo.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + } + } + btnToggleHistory?.setOnClickListener { + val historyCard = holder.itemView.findViewById(R.id.historyCard) + if (historyCard?.visibility == View.GONE) { + historyCard.visibility = View.VISIBLE + btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_less, 0) + loadPageHistory(holder.itemView) + } else { + historyCard.visibility = View.GONE + btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + } + } + } + } + override fun getItemViewType(position: Int): Int = position + } + + binding.readOnlyViewPager.adapter = ReadOnlyViewPagerAdapter() + binding.readOnlyViewPager.isUserInputEnabled = false + + binding.readOnlyViewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + var lastPosition = -1 + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (position == 2 && lastPosition != 2) { + binding.readOnlyViewPager.postDelayed({ + for (i in 0 until binding.readOnlyViewPager.childCount) { + val child = binding.readOnlyViewPager.getChildAt(i) + val btnToggleHistory = child.findViewById(R.id.btnToggleHistory) + val historyCard = child.findViewById(R.id.historyCard) + if (btnToggleHistory != null && historyCard != null && historyCard.visibility == View.GONE) { + historyCard.visibility = View.VISIBLE + btnToggleHistory.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_expand_less, 0) + loadPageHistory(child) + break + } + } + }, 200) + } + lastPosition = position + } + }) + + TabLayoutMediator(binding.readOnlyTabLayout, binding.readOnlyViewPager) { tab, position -> + tab.text = when (position) { + 0 -> "Contenido" + 1 -> "Índice" + else -> "Opciones" + } + }.attach() + } + + private fun loadReadOnlyPreview(page: Page) { + binding.progressBar.visibility = View.GONE + binding.editorContainer.visibility = View.GONE + binding.webViewPreview.visibility = View.GONE + binding.tabLayout.visibility = View.GONE + binding.btnSave.visibility = View.GONE + binding.readOnlyTabLayout.visibility = View.VISIBLE + binding.readOnlyViewPager.visibility = View.VISIBLE + + binding.etTitle.setText(page.title) + binding.tvEmoji.text = page.icon ?: getString(R.string.add_emoji) + + val creds = sessionManager.getCredentials() + val baseUrl = creds?.url?.trimEnd('/') ?: "" + + lifecycleScope.launch(Dispatchers.IO) { + try { + val htmlContent = cachedApi.getPageHtml(page.id) + withContext(Dispatchers.Main) { + val indexItems = extractIndexFromHtml(htmlContent) + val fullHtml = buildReadOnlyHtml(htmlContent) + setupReadOnlyViewPager(fullHtml, baseUrl, indexItems) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + val fallbackContent = if (page.content != null) { + val json = gson.toJson(page.content) + ContentConverter.tipTapToHtml(json) + } else { + "

Sin contenido

" + } + val indexItems = extractIndexFromHtml(fallbackContent) + val fullHtml = buildReadOnlyHtml(fallbackContent) + setupReadOnlyViewPager(fullHtml, baseUrl, indexItems) + } + } + } + + Toast.makeText(this, "Modo solo lectura", Toast.LENGTH_SHORT).show() + } + + private data class ReadOnlyIndexItem(val level: Int, val text: String) + + private fun extractIndexFromHtml(html: String): List { + val items = mutableListOf() + val pattern = Regex("]*>([^<]+)") + for (match in pattern.findAll(html)) { + val level = match.groupValues[1].toInt() + val text = match.groupValues[2].trim() + items.add(ReadOnlyIndexItem(level, text)) + } + return items + } + + private fun goToHeadingInWebView(item: ReadOnlyIndexItem) { + binding.readOnlyViewPager.currentItem = 0 + + val escapedText = item.text + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + + Handler(Looper.getMainLooper()).postDelayed({ + for (i in 0 until binding.readOnlyViewPager.childCount) { + val child = binding.readOnlyViewPager.getChildAt(i) + val webView = child?.findViewById(R.id.webViewContent) + if (webView != null) { + val js = """ + (function() { + console.log('goToHeading: searching for "$escapedText"'); + const allHeaders = document.querySelectorAll('h1, h2, h3'); + console.log('goToHeading: found ' + allHeaders.length + ' headers'); + + for (let i = 0; i < allHeaders.length; i++) { + const header = allHeaders[i]; + const headerText = header.textContent.trim(); + console.log('goToHeading: checking header ' + i + ': "' + headerText + '"'); + + if (headerText === "$escapedText") { + console.log('goToHeading: found match, scrolling'); + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + } + console.log('goToHeading: no match found'); + })() + """.trimIndent() + + webView.evaluateJavascript(js, null) + break + } + } + }, 500) + } + + private fun contentToMarkdown(content: Any?): String { + if (content == null) return "" + if (content is String) return content + return try { + val json = gson.toJson(content) + tiptapToMarkdown(json) + } catch (e: Exception) { + content.toString() + } + } + + private fun markdownToTipTap(markdown: String): String { + // This function converts Markdown to TipTap JSON format that Docmost expects + // It handles attachments specially to preserve the attachment type + val lines = markdown.split("\n") + val content = mutableListOf>() + var i = 0 + + while (i < lines.size) { + val line = lines[i].trim() + + if (line.isEmpty()) { + i++ + continue + } + + // Check for attachment links: [name](url) with /api/files/ + if (line.startsWith("[") && line.contains("](") && line.endsWith(")")) { + val match = Regex("\\[([^\\]]+)\\]\\(([^)]+)\\)").find(line) + if (match != null) { + val name = match.groupValues[1] + val url = match.groupValues[2] + if (url.contains("/api/files/")) { + // Extract attachment ID from URL + val attachmentId = url.substringAfter("/api/files/").substringBefore("/") + content.add(mapOf( + "type" to "attachment", + "attrs" to mapOf( + "url" to url, + "name" to name, + "attachmentId" to attachmentId, + "mime" to guessMimeType(name), + "size" to 0 + ) + )) + i++ + continue + } + } + } + + // For all other content, convert to paragraph (simplified) + // Docmost will handle the complex conversions + content.add(mapOf( + "type" to "paragraph", + "attrs" to mapOf("id" to generateId(), "indent" to 0), + "content" to listOf( + mapOf("type" to "text", "text" to line) + ) + )) + i++ + } + + val tipTapDoc = mapOf( + "type" to "doc", + "content" to content + ) + + return gson.toJson(tipTapDoc) + } + + private fun guessMimeType(fileName: String): String { + val ext = fileName.substringAfterLast(".").lowercase() + return when (ext) { + "pdf" -> "application/pdf" + "json" -> "application/json" + "txt" -> "text/plain" + "md" -> "text/markdown" + "doc", "docx" -> "application/msword" + "xls", "xlsx" -> "application/vnd.ms-excel" + "ppt", "pptx" -> "application/vnd.ms-powerpoint" + "zip" -> "application/zip" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "svg" -> "image/svg+xml" + else -> "application/octet-stream" + } + } + + private fun generateId(): String { + return (1..12).map { ('a'..'z').random() }.joinToString("") + } + + private fun hasAttachments(markdown: String): Boolean { + return markdown.contains(Regex("\\[([^\\]]+)\\]\\([^)]*/api/files/[^)]+\\)")) + } + + private var isSaving: Boolean = false + + private fun saveCurrentContent(onComplete: (() -> Unit)? = null) { + if (!canEdit) { onComplete?.invoke(); return } + val title = binding.etTitle.text.toString().trim() + val hasChanges = currentPageContent != originalContent || title != originalTitle || pageEmoji != originalEmoji + if (!hasChanges) { onComplete?.invoke(); return } + isSaving = true + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.updatePage(pageId!!, currentPageContent, title, pageEmoji) + withContext(Dispatchers.Main) { + originalTitle = title + originalContent = currentPageContent + originalEmoji = pageEmoji + isSaving = false + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Content saved successfully") + onComplete?.invoke() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + isSaving = false + if (BuildConfig.DEBUG) android.util.Log.e("PageEditor", "Error saving content: ${e.message}") + onComplete?.invoke() + } + } + } + } + + private fun savePage() { + if (!canEdit) { + Toast.makeText(this, "Esta página es de solo lectura", Toast.LENGTH_SHORT).show() + return + } + if (isSaving) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Save already in progress, skipping") + return + } + binding.btnSave.isEnabled = false + binding.btnSave.text = getString(R.string.saving) + + binding.webViewEditor.evaluateJavascript("window.saveAndSendContent()", null) + } + + private fun savePageAndFinish() { + if (!canEdit) { + finish() + return + } + if (isSaving) { + if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Save already in progress, skipping") + return + } + isSaving = true + + binding.webViewEditor.evaluateJavascript("window.saveAndSendContent()", null) + } + + override fun onBackPressed() { + if (!isEditorReadOnly && canEdit) { + toggleEditorMode() + return + } + if (!canEdit) { + finish() + return + } + savePageAndFinish() + } + + override fun onDestroy() { + binding.webViewEditor.destroy() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt b/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt new file mode 100644 index 0000000..fa82246 --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt @@ -0,0 +1,2362 @@ +package com.docmost.app.ui + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.util.TypedValue +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.GestureDetector +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import kotlin.math.abs +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.docmost.app.R +import com.docmost.app.api.AttachmentType +import com.docmost.app.api.DeletedPage +import com.docmost.app.api.DeletedPagesRequest +import com.docmost.app.api.DocmostApi +import com.docmost.app.api.CachedApi +import com.docmost.app.api.OfflineNotAvailableException +import com.docmost.app.data.SpaceCacheManager +import com.docmost.app.api.Page +import com.docmost.app.api.SearchResult +import com.docmost.app.api.Space +import com.docmost.app.api.User +import com.docmost.app.data.Credentials +import com.docmost.app.data.PageCacheManager +import com.docmost.app.data.SessionManager +import com.docmost.app.data.SyncManager +import com.docmost.app.data.SyncStatus +import com.docmost.app.utils.NetworkConnectivityObserver +import com.docmost.app.utils.NetworkStatus +import com.docmost.app.databinding.ActivitySpacesBinding +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.lifecycle.lifecycleScope +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.Executors +import com.docmost.app.BuildConfig +import java.util.concurrent.TimeUnit + + +class SpacesActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySpacesBinding + private lateinit var sessionManager: SessionManager + private lateinit var api: DocmostApi + private lateinit var cachedApi: CachedApi + private lateinit var spaceCacheManager: SpaceCacheManager + private lateinit var adapter: SpacesAdapter + private lateinit var baseUrl: String + private lateinit var authToken: String + private lateinit var networkObserver: NetworkConnectivityObserver + private lateinit var syncManager: SyncManager + + private val spaces = mutableListOf() + private val pagesMap = mutableMapOf>() + private val expandedSpaces = mutableSetOf() + private var spaceLoadJobs = mutableMapOf() + private var iconEditSpace: Space? = null + private var isOfflineMode: Boolean = false + + private var currentUser: User? = null + private var pendingSpaceIconUri: Uri? = null + private var createDialogIconView: android.widget.ImageView? = null + private lateinit var pageCacheManager: PageCacheManager + private val childPagesMap = mutableMapOf>() + private val expandedPages = mutableSetOf() + + private val prefs by lazy { getSharedPreferences("docmost_prefs", MODE_PRIVATE) } + private var biometricEnabled: Boolean + get() = prefs.getBoolean("biometric_enabled", false) + set(value) = prefs.edit().putBoolean("biometric_enabled", value).apply() + private var isAuthenticated = false + + private val biometricExecutor = Executors.newSingleThreadExecutor() + private var swipeDetected = false + + private val swipeDetector by lazy { GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + if (e1 == null) return false + val diffX = e2.x - e1.x + if (abs(diffX) > abs(e2.y - e1.y) && abs(diffX) > 150 && abs(velocityX) > 300) { + val current = binding.tabLayout.selectedTabPosition + return when { + diffX < 0 && current < binding.tabLayout.tabCount - 1 -> { + binding.tabLayout.getTabAt(current + 1)?.select() + swipeDetected = true + true + } + diffX > 0 && current > 0 -> { + binding.tabLayout.getTabAt(current - 1)?.select() + swipeDetected = true + true + } + else -> false + } + } + return false + } + }) } + + private val recentAdapter = object : RecyclerView.Adapter() { + private val items = mutableListOf() + + fun submitList(list: List) { + items.clear() + items.addAll(list) + notifyDataSetChanged() + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + } + + override fun getItemCount(): Int = items.size + + override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): RecentViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_recent_page, parent, false) + return RecentViewHolder(view) + } + + override fun onBindViewHolder(holder: RecentViewHolder, position: Int) { + holder.bind(items[position]) + } + } + + inner class RecentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvTitle: TextView = itemView.findViewById(R.id.tvPageTitle) + private val tvIcon: TextView = itemView.findViewById(R.id.tvPageIcon) + private val ivIcon: ImageView = itemView.findViewById(R.id.ivPageIcon) + private val tvDate: TextView = itemView.findViewById(R.id.tvPageDate) + + fun bind(page: Page) { + tvTitle.text = if (page.title.isNullOrBlank()) "Untitled" else page.title + tvDate.text = formatPageDate(page.updatedAt) + if (page.icon.isNullOrBlank()) { + tvIcon.visibility = View.GONE + ivIcon.visibility = View.VISIBLE + } else { + tvIcon.visibility = View.VISIBLE + tvIcon.text = page.icon + ivIcon.visibility = View.GONE + } + itemView.setOnClickListener { this@SpacesActivity.openPageEditor(page) } + } + } + + private val imagePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + val space = iconEditSpace + if (space != null) { + uploadIconForSpace(space, uri) + } else { + uploadAvatarForProfile(uri) + } + } + } + + private val avatarPickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + uploadAvatarForProfile(uri) + } + } + + private val spaceIconPickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + pendingSpaceIconUri = uri + createDialogIconView?.let { iv -> + val inputStream = contentResolver.openInputStream(uri) + if (inputStream != null) { + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream.close() + iv.setImageBitmap(bitmap) + } + } + } + } + + private val backgroundPickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + setBackgroundImage(uri) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySpacesBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.hide() + + sessionManager = SessionManager(this) + val creds = sessionManager.getCredentials() + if (creds == null) { + goToLogin() + return + } + + com.docmost.app.utils.ImageLoader.init(this, sessionManager) + + baseUrl = creds.url + authToken = creds.authToken + api = DocmostApi(baseUrl) + api.setAuthToken(authToken) + pageCacheManager = PageCacheManager(this) + pageCacheManager.setCurrentUser(creds.email, creds.url) + networkObserver = NetworkConnectivityObserver(this) + spaceCacheManager = SpaceCacheManager(this) + spaceCacheManager.setCurrentUser(creds.email, creds.url) + syncManager = SyncManager.getInstance(this, api, pageCacheManager, spaceCacheManager) + cachedApi = CachedApi(api, pageCacheManager, spaceCacheManager, syncManager, networkObserver) + + observeNetworkStatus() + observePendingOperations() + + setupRecyclerView() + setupTabs() + + binding.btnChangeAvatar.setOnClickListener { + avatarPickerLauncher.launch("image/*") + } + + binding.btnRemoveAvatar.setOnClickListener { + removeAvatarForProfile() + } + + binding.btnSaveProfile.setOnClickListener { + saveUserProfile() + } + + binding.btnLogout.setOnClickListener { + showLogoutDialog() + } + + binding.btnChangeBackground.setOnClickListener { + backgroundPickerLauncher.launch("image/*") + } + + binding.btnRemoveBackground.setOnClickListener { + removeBackgroundImage() + } + + binding.fabSearch.setOnClickListener { + showSearchDialog() + } + + binding.fabAddSpace.setOnClickListener { + showCreateSpaceDialog() + } + + binding.btnCachedPages.setOnClickListener { + showCachedPagesDialog() + } + + binding.btnDeletedPages.setOnClickListener { + showDeletedPagesDialog() + } + + binding.btnAddAccount.setOnClickListener { + showAddAccountDialog() + } + + loadSavedAccounts() + + binding.switchBiometric.isChecked = biometricEnabled + binding.switchBiometric.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + binding.switchBiometric.isChecked = false + authenticateWithBiometric { success -> + if (success) { + biometricEnabled = true + binding.switchBiometric.isChecked = true + } + } + } else { + biometricEnabled = false + } + } + + binding.switchForcedOffline.isChecked = prefs.getBoolean("forced_offline", false) + cachedApi.forcedOffline = binding.switchForcedOffline.isChecked + binding.switchForcedOffline.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean("forced_offline", isChecked).apply() + cachedApi.forcedOffline = isChecked + updateNetworkUI() + } + + binding.btnNewShare.setOnClickListener { + showCreateShareDialog() + } + + binding.swipeSpaces.setOnRefreshListener { + lifecycleScope.launch { + if (syncManager.getPendingCount() > 0) { + syncManager.processQueue() + } + loadSpaces() + } + } + binding.swipeRecent.setOnRefreshListener { + lifecycleScope.launch { + if (syncManager.getPendingCount() > 0) { + syncManager.processQueue() + } + loadRecentPages(showCache = false) + } + } + binding.swipeShares.setOnRefreshListener { + lifecycleScope.launch { + if (syncManager.getPendingCount() > 0) { + syncManager.processQueue() + } + loadShares() + } + } + + loadBackgroundImage() + loadSpaces() + } + + override fun onStart() { + super.onStart() + if (biometricEnabled && !isAuthenticated) { + authenticateWithBiometric() + } + } + + override fun onStop() { + super.onStop() + isAuthenticated = false + } + + private fun setupTabs() { + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.spaces_tab).setIcon(R.drawable.ic_spaces)) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.recent_tab).setIcon(R.drawable.ic_recent)) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.shares_tab).setIcon(R.drawable.ic_link)) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.settings_tab).setIcon(R.drawable.ic_settings)) + + val tabColorAttrs = intArrayOf( + com.google.android.material.R.attr.colorPrimary, + com.google.android.material.R.attr.colorSecondary, + com.google.android.material.R.attr.colorTertiary, + com.google.android.material.R.attr.colorError + ) + + fun applyTabColor(tab: TabLayout.Tab, colorAttrResId: Int, selected: Boolean) { + val tv = TypedValue() + theme.resolveAttribute(colorAttrResId, tv, true) + val color = tv.data + val alphaColor = Color.argb(25, Color.red(color), Color.green(color), Color.blue(color)) + val dimColor = Color.argb(80, Color.red(color), Color.green(color), Color.blue(color)) + if (selected) { + tab.icon?.setTint(color) + val pill = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 24f * resources.displayMetrics.density + setColor(alphaColor) + } + tab.view.background = pill + tab.view.findViewById(com.google.android.material.R.id.text)?.setTextColor(color) + } else { + tab.icon?.setTint(dimColor) + tab.view.background = null + tab.view.findViewById(com.google.android.material.R.id.text)?.setTextColor(dimColor) + } + } + + for (i in 0 until binding.tabLayout.tabCount) { + binding.tabLayout.getTabAt(i)?.let { applyTabColor(it, tabColorAttrs[i], i == 0) } + } + + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + applyTabColor(tab, tabColorAttrs[tab.position], true) + binding.spacesContent.visibility = View.GONE + binding.recentContent.visibility = View.GONE + binding.sharesContent.visibility = View.GONE + binding.settingsContent.visibility = View.GONE + when (tab.position) { + 0 -> { + binding.spacesContent.visibility = View.VISIBLE + binding.fabAddSpace.visibility = View.VISIBLE + binding.fabSearch.visibility = View.VISIBLE + } + 1 -> { + binding.recentContent.visibility = View.VISIBLE + binding.fabAddSpace.visibility = View.GONE + binding.fabSearch.visibility = View.VISIBLE + loadRecentPages() + } + 2 -> { + binding.sharesContent.visibility = View.VISIBLE + binding.fabAddSpace.visibility = View.GONE + binding.fabSearch.visibility = View.GONE + loadShares() + } + 3 -> { + binding.settingsContent.visibility = View.VISIBLE + binding.fabAddSpace.visibility = View.GONE + binding.fabSearch.visibility = View.GONE + loadUserProfile() + } + } + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + applyTabColor(tab, tabColorAttrs[tab.position], false) + } + override fun onTabReselected(tab: TabLayout.Tab) {} + }) + + binding.rvRecentPages.layoutManager = LinearLayoutManager(this) + binding.rvRecentPages.adapter = recentAdapter + + binding.rvShares.layoutManager = LinearLayoutManager(this) + binding.rvShares.adapter = shareAdapter + } + + private fun loadUserProfile() { + binding.btnSaveProfile.isEnabled = false + CoroutineScope(Dispatchers.IO).launch { + try { + val user = cachedApi.getCurrentUser() + currentUser = user + withContext(Dispatchers.Main) { + adapter.currentUserId = user.id + if (spaces.isNotEmpty()) refreshAdapter() + binding.etUserName.setText(user.name ?: "") + binding.tvEmail.text = user.email + loadAvatarPreview(user.avatarUrl) + binding.btnRemoveAvatar.visibility = + if (user.avatarUrl.isNullOrBlank()) View.GONE else View.VISIBLE + binding.btnSaveProfile.isEnabled = true + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun loadAvatarPreview(avatarUrl: String?) { + if (avatarUrl.isNullOrBlank()) { + binding.ivAvatar.setImageResource(R.drawable.ic_page) + return + } + + val url = buildString { + append(baseUrl.trimEnd('/')) + append("/api/attachments/img/${AttachmentType.AVATAR}/$avatarUrl") + } + + com.docmost.app.utils.ImageLoader.loadSync(url, binding.ivAvatar, R.drawable.ic_page) + } + + private fun showSearchDialog() { + val view = layoutInflater.inflate(R.layout.dialog_search, null) + val etSearch = view.findViewById(R.id.etSearch) + val rvResults = view.findViewById(R.id.rvSearchResults) + val tvEmpty = view.findViewById(R.id.tvSearchEmpty) + rvResults.layoutManager = LinearLayoutManager(this) + + var searchJob: Job? = null + val results = mutableListOf() + val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = results.size + override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false) + return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val r = results[position] + val text1 = holder.itemView.findViewById(android.R.id.text1) + val text2 = holder.itemView.findViewById(android.R.id.text2) + text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}" + text2.text = if (r.spaceName != null) "${r.spaceName} · ${r.highlight ?: ""}" else (r.highlight ?: "") + text2.maxLines = 2 + holder.itemView.setOnClickListener { + openPageEditor(Page(id = r.id, title = r.title, icon = r.icon)) + } + } + } + rvResults.adapter = adapter + + val maxHeight = (resources.displayMetrics.heightPixels * 0.6).toInt() + rvResults.layoutParams.height = maxHeight + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + dialog.show() + + etSearch.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + searchJob?.cancel() + val query = s?.toString()?.trim() ?: return + if (query.length < 2) { + results.clear() + adapter.notifyDataSetChanged() + tvEmpty.visibility = View.GONE + return + } + searchJob = CoroutineScope(Dispatchers.IO).launch { + delay(300) + try { + val items = cachedApi.searchPages(query) + withContext(Dispatchers.Main) { + results.clear() + results.addAll(items) + adapter.notifyDataSetChanged() + tvEmpty.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE + tvEmpty.text = if (items.isEmpty()) "No se encontraron resultados" else "" + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "Error: ${e.message}" + } + } + } + } + }) + } + + private fun showLogoutDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.logout) + .setMessage(R.string.logout_confirm) + .setPositiveButton(R.string.logout) { _, _ -> + val currentCreds = sessionManager.getCredentials() + if (currentCreds != null) { + sessionManager.removeAccount(currentCreds.email, currentCreds.url) + } + pageCacheManager.clearUserCache() + sessionManager.clear() + goToLogin() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun showCreateSpaceDialog() { + val view = layoutInflater.inflate(R.layout.dialog_create_space, null) + val etName = view.findViewById(R.id.etSpaceName) + val etDescription = view.findViewById(R.id.etSpaceDescription) + val btnChooseIcon = view.findViewById(R.id.btnChooseIcon) + val ivIcon = view.findViewById(R.id.ivSpaceIcon) + + pendingSpaceIconUri = null + createDialogIconView = ivIcon + ivIcon.setImageResource(R.drawable.ic_page) + + btnChooseIcon.setOnClickListener { + spaceIconPickerLauncher.launch("image/*") + } + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.create_space) + .setView(view) + .setPositiveButton(R.string.create_space) { _, _ -> + val name = etName.text.toString().trim() + if (name.length < 2) { + Toast.makeText(this, "El nombre debe tener al menos 2 caracteres", Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + val slug = name.lowercase().replace(Regex("[^a-z0-9]"), "") + if (slug.length < 2) { + Toast.makeText(this, "El nombre debe generar un slug válido (al menos 2 caracteres alfanuméricos)", Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + createSpace(name, slug, etDescription.text.toString().trim().ifEmpty { null }) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun createSpace(name: String, slug: String, description: String?) { + CoroutineScope(Dispatchers.IO).launch { + try { + val space = cachedApi.createSpace(name, slug, description) + val iconUri = pendingSpaceIconUri + if (iconUri != null && !cachedApi.isOffline) { + try { + cachedApi.uploadSpaceIcon(space.id, iconUri, this@SpacesActivity) + } catch (e: Exception) { + if (BuildConfig.DEBUG) Log.e("SpacesActivity", "Icon upload failed: ${e.message}") + } + } + withContext(Dispatchers.Main) { + val msg = if (cachedApi.isOffline) "Space creado (se sincronizará cuando haya conexión)" else getString(R.string.space_created) + Toast.makeText(this@SpacesActivity, msg, Toast.LENGTH_SHORT).show() + loadSpaces() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun showCreatePageDialog(space: Space) { + val view = layoutInflater.inflate(R.layout.dialog_create_page, null) + val etTitle = view.findViewById(R.id.etPageTitle) + val tilTitle = view.findViewById(R.id.tilPageTitle) + + etTitle?.requestFocus() + + MaterialAlertDialogBuilder(this) + .setTitle("Nueva página en ${space.name}") + .setView(view) + .setPositiveButton(R.string.create) { _, _ -> + val title = etTitle?.text?.toString()?.trim() ?: "" + if (title.isNotBlank()) { + createPage(title, space) + } else { + tilTitle?.error = "El título es requerido" + } + } + .setNegativeButton(R.string.cancel, null) + .setOnDismissListener { + tilTitle?.error = null + } + .show() + } + + private fun createPage(title: String, space: Space) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.createPage(title, space.id) + withContext(Dispatchers.Main) { + val msg = if (cachedApi.isOffline) "Página creada (se sincronizará cuando haya conexión)" else "Página creada" + Toast.makeText(this@SpacesActivity, msg, Toast.LENGTH_SHORT).show() + loadPages(space) + expandedSpaces.add(space.id) + refreshAdapter() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun showDeletePageDialog(page: Page) { + val title = if (page.title.isNullOrBlank()) "Untitled" else page.title + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(R.string.delete_confirm) + .setPositiveButton(R.string.delete) { _, _ -> + deletePage(page) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deletePage(page: Page) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.deletePage(page.id, page.spaceId) + withContext(Dispatchers.Main) { + val msg = if (cachedApi.isOffline) "Página eliminada (se sincronizará)" else "Página eliminada" + Toast.makeText(this@SpacesActivity, msg, Toast.LENGTH_SHORT).show() + val space = spaces.find { it.id == page.spaceId } + if (space != null) { + loadPages(space) + refreshAdapter() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + if (ev != null) { + swipeDetected = false + swipeDetector.onTouchEvent(ev) + if (swipeDetected) { + return true + } + } + return super.dispatchTouchEvent(ev) + } + + private fun goToLogin() { + val intent = Intent(this, LoginActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + } + + private fun refreshAdapter() { + adapter.submitList(spaces, pagesMap, expandedSpaces, childPagesMap, expandedPages) + } + + private fun observeNetworkStatus() { + lifecycleScope.launch { + networkObserver.observeNetworkStatus().collect { status -> + isOfflineMode = (status == NetworkStatus.OFFLINE) + updateNetworkUI() + } + } + } + + private fun observePendingOperations() { + lifecycleScope.launch { + syncManager.observePendingCount().collect { count -> + if (count > 0) { + binding.tvOfflineBanner.visibility = View.VISIBLE + binding.tvOfflineBanner.text = "🟡 $count cambios pendientes de sincronizar" + } else if (!isOfflineMode) { + binding.tvOfflineBanner.visibility = View.GONE + if (count == 0 && isFinishing == false) { + loadSpaces() + } + } + } + } + } + + private fun updateNetworkUI() { + if (isOfflineMode) { + binding.tvOfflineBanner.visibility = View.VISIBLE + binding.tvOfflineBanner.text = "🔴 Modo sin conexión" + Toast.makeText(this, "Sin conexión - Algunas funciones están limitadas", Toast.LENGTH_LONG).show() + } else { + binding.tvOfflineBanner.visibility = View.GONE + + lifecycleScope.launch { + val pendingCount = syncManager.getPendingCount() + if (pendingCount > 0) { + binding.tvOfflineBanner.visibility = View.VISIBLE + binding.tvOfflineBanner.text = "🟡 $pendingCount cambios pendientes de sincronizar" + + syncManager.processQueue() + loadSpaces() + } + } + } + } + + private fun setupRecyclerView() { + adapter = SpacesAdapter( + onSpaceToggle = { space -> + if (expandedSpaces.contains(space.id)) { + val pos = spaces.indexOfFirst { it.id == space.id } + val holder = if (pos >= 0) + binding.recyclerView.findViewHolderForAdapterPosition(pos) as? SpacesAdapter.SpaceViewHolder + else null + if (holder != null) { + holder.animateCollapse { + expandedSpaces.remove(space.id) + refreshAdapter() + } + } else { + expandedSpaces.remove(space.id) + refreshAdapter() + } + } else if (pagesMap.containsKey(space.id)) { + expandedSpaces.add(space.id) + refreshAdapter() + } else { + loadPages(space) + pagesMap[space.id] = emptyList() + expandedSpaces.add(space.id) + refreshAdapter() + } + }, + onPageClick = { page -> + openPageEditor(page) + }, + onSpaceEdit = { space -> + showSpaceEditDialog(space) + }, + onAddPage = { space -> + showCreatePageDialog(space) + }, + onPageDelete = { page -> + showDeletePageDialog(page) + }, + onPageLongClick = { page -> + showPageContextMenu(page) + }, + onPageToggle = { page -> + togglePageChildren(page) + }, + baseUrl = baseUrl + ) + + binding.recyclerView.layoutManager = LinearLayoutManager(this) + binding.recyclerView.adapter = adapter + } + + private fun loadSpaces() { + binding.progressBar.visibility = View.VISIBLE + binding.emptyView.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val result = cachedApi.getSpaces() + + val pagesResults = result.map { space -> + async { + try { + space.id to cachedApi.getSidebarPages(space.id) + } catch (_: Exception) { + space.id to emptyList() + } + } + } + val loadedPages = pagesResults.awaitAll().toMap() + + withContext(Dispatchers.Main) { + binding.swipeSpaces.isRefreshing = false + spaces.clear() + spaces.addAll(result) + + pagesMap.clear() + pagesMap.putAll(loadedPages) + childPagesMap.clear() + expandedSpaces.clear() + expandedPages.clear() + + adapter.submitList(spaces, pagesMap, expandedSpaces, childPagesMap, expandedPages) + + binding.progressBar.visibility = View.GONE + if (spaces.isEmpty()) { + binding.emptyView.visibility = View.VISIBLE + binding.emptyView.text = getString(R.string.no_spaces) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.swipeSpaces.isRefreshing = false + binding.progressBar.visibility = View.GONE + binding.emptyView.visibility = View.VISIBLE + val msg = e.message ?: "" + + if (msg.contains("401") || msg.contains("Unauthorized")) { + binding.emptyView.text = "Sesión expirada. Redirigiendo..." + Toast.makeText(this@SpacesActivity, "Sesión expirada", Toast.LENGTH_SHORT).show() + pageCacheManager.clearUserCache() + sessionManager.clear() + goToLogin() + } else { + binding.emptyView.text = "${getString(R.string.error_loading)}\n$msg" + Toast.makeText(this@SpacesActivity, msg, Toast.LENGTH_LONG).show() + } + } + } + } + } + + private val gson = Gson() + + private fun saveRecentCache(pages: List) { + try { + val json = gson.toJson(pages) + prefs.edit().putString("recent_cache", json).apply() + } catch (_: Exception) { } + } + + private fun loadRecentCache(): List? { + try { + val json = prefs.getString("recent_cache", null) ?: return null + val type = object : TypeToken>() {}.type + return gson.fromJson(json, type) + } catch (_: Exception) { return null } + } + + private fun loadRecentPages(showCache: Boolean = true) { + val cached = if (showCache) loadRecentCache() else null + + if (cached != null) { + recentAdapter.submitList(cached) + binding.emptyRecent.visibility = View.GONE + } else { + binding.progressRecent.visibility = View.VISIBLE + } + binding.emptyRecent.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val pages = cachedApi.getRecentPages() + withContext(Dispatchers.Main) { + binding.swipeRecent.isRefreshing = false + binding.progressRecent.visibility = View.GONE + if (pages.isEmpty()) { + binding.emptyRecent.visibility = View.VISIBLE + binding.emptyRecent.text = getString(R.string.no_recent_pages) + } + saveRecentCache(pages) + recentAdapter.submitList(pages) + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.swipeRecent.isRefreshing = false + binding.progressRecent.visibility = View.GONE + if (cached == null) { + binding.emptyRecent.visibility = View.VISIBLE + binding.emptyRecent.text = "No disponible sin conexión" + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.swipeRecent.isRefreshing = false + binding.progressRecent.visibility = View.GONE + if (cached == null) { + binding.emptyRecent.visibility = View.VISIBLE + binding.emptyRecent.text = "Error: ${e.message}" + } + } + } + } + } + + private fun loadPages(space: Space) { + spaceLoadJobs[space.id]?.cancel() + spaceLoadJobs[space.id] = CoroutineScope(Dispatchers.IO).launch { + try { + val pages = cachedApi.getSidebarPages(space.id) + withContext(Dispatchers.Main) { + pagesMap[space.id] = pages + childPagesMap.clear() + expandedPages.clear() + refreshAdapter() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + pagesMap[space.id] = emptyList() + childPagesMap.clear() + expandedPages.clear() + refreshAdapter() + Toast.makeText( + this@SpacesActivity, + "Error: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private fun showSpaceEditDialog(space: Space) { + val input = layoutInflater.inflate(R.layout.dialog_edit_space, null) + val etName = input.findViewById(R.id.etSpaceName) + val etDesc = input.findViewById(R.id.etSpaceDescription) + val ivPreview = input.findViewById(R.id.ivIconPreview) + val btnChange = input.findViewById(R.id.btnChangeIcon) + val btnRemove = input.findViewById(R.id.btnRemoveIcon) + val btnDelete = input.findViewById(R.id.btnDeleteSpace) + + etName.setText(space.name) + etDesc.setText(space.description ?: "") + loadIconPreview(space, ivPreview) + btnRemove.visibility = if (space.logo.isNullOrBlank()) View.GONE else View.VISIBLE + + btnChange.setOnClickListener { + iconEditSpace = space + imagePickerLauncher.launch("image/*") + } + + btnRemove.setOnClickListener { + removeSpaceIcon(space) + } + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle("Editar espacio") + .setView(input) + .setPositiveButton("Guardar") { dialog, _ -> + val newName = etName.text?.toString()?.trim() + val newDesc = etDesc.text?.toString()?.trim() + if (newName.isNullOrBlank()) { + Toast.makeText(this, "El nombre no puede estar vacío", Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + dialog.dismiss() + updateSpace(space, newName, newDesc.takeIf { it.isNullOrBlank().not() }) + } + .setNegativeButton("Cancelar", null) + .show() + + btnDelete.setOnClickListener { + dialog.dismiss() + showDeleteSpaceDialog(space) + } + } + + private fun showDeleteSpaceDialog(space: Space) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_space) + .setMessage(R.string.delete_space_confirm) + .setPositiveButton(R.string.delete) { _, _ -> + deleteSpace(space) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deleteSpace(space: Space) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.deleteSpace(space.id) + withContext(Dispatchers.Main) { + val msg = if (cachedApi.isOffline) "Espacio eliminado (se sincronizará)" else "Espacio eliminado" + Toast.makeText(this@SpacesActivity, msg, Toast.LENGTH_SHORT).show() + loadSpaces() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun loadIconPreview(space: Space, imageView: ImageView) { + if (space.logo.isNullOrBlank()) { + imageView.setImageResource(R.drawable.ic_page) + imageView.setBackgroundResource(R.drawable.ic_page) + return + } + + val url = buildString { + append(baseUrl.trimEnd('/')) + append("/api/attachments/img/${AttachmentType.SPACE_ICON}/${space.logo}") + } + + com.docmost.app.utils.ImageLoader.loadSync(url, imageView, R.drawable.ic_page) + } + + private fun updateSpace(space: Space, newName: String, newDesc: String?) { + binding.progressBar.visibility = View.VISIBLE + CoroutineScope(Dispatchers.IO).launch { + try { + val updated = cachedApi.updateSpace(space.id, newName, newDesc) + withContext(Dispatchers.Main) { + val idx = spaces.indexOfFirst { it.id == space.id } + if (idx >= 0) { + spaces[idx] = updated + refreshAdapter() + } + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Espacio actualizado", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun uploadIconForSpace(space: Space, imageUri: Uri) { + binding.progressBar.visibility = View.VISIBLE + CoroutineScope(Dispatchers.IO).launch { + try { + val fileName = cachedApi.uploadSpaceIcon(space.id, imageUri, this@SpacesActivity) + withContext(Dispatchers.Main) { + val idx = spaces.indexOfFirst { it.id == space.id } + if (idx >= 0) { + spaces[idx] = spaces[idx].copy(logo = fileName) + refreshAdapter() + } + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Icono actualizado", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun removeSpaceIcon(space: Space) { + binding.progressBar.visibility = View.VISIBLE + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.removeSpaceIcon(space.id) + withContext(Dispatchers.Main) { + val idx = spaces.indexOfFirst { it.id == space.id } + if (idx >= 0) { + spaces[idx] = spaces[idx].copy(logo = null) + refreshAdapter() + } + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Icono eliminado", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressBar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun saveUserProfile() { + val name = binding.etUserName.text.toString().trim() + binding.btnSaveProfile.isEnabled = false + + CoroutineScope(Dispatchers.IO).launch { + try { + val user = cachedApi.updateUser(name) + currentUser = user + withContext(Dispatchers.Main) { + binding.btnSaveProfile.isEnabled = true + Toast.makeText(this@SpacesActivity, R.string.profile_updated, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.btnSaveProfile.isEnabled = true + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun uploadAvatarForProfile(imageUri: Uri) { + CoroutineScope(Dispatchers.IO).launch { + try { + val fileName = api.uploadAvatar(imageUri, this@SpacesActivity) + currentUser = currentUser?.copy(avatarUrl = fileName) + withContext(Dispatchers.Main) { + loadAvatarPreview(fileName) + binding.btnRemoveAvatar.visibility = View.VISIBLE + Toast.makeText(this@SpacesActivity, "Foto actualizada", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun removeAvatarForProfile() { + CoroutineScope(Dispatchers.IO).launch { + try { + api.removeAvatar() + currentUser = currentUser?.copy(avatarUrl = null) + withContext(Dispatchers.Main) { + binding.ivAvatar.setImageResource(R.drawable.ic_page) + binding.btnRemoveAvatar.visibility = View.GONE + Toast.makeText(this@SpacesActivity, "Foto eliminada", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun getCurrentBackgroundFile(): java.io.File? { + val creds = sessionManager.getCredentials() ?: return null + val userKey = android.util.Base64.encodeToString( + "${creds.email}_${creds.url}".toByteArray(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP + ).replace("=", "") + val file = java.io.File(filesDir, "bg_$userKey.jpg") + return if (file.exists()) file else null + } + + private fun setBackgroundImage(uri: Uri) { + try { + val creds = sessionManager.getCredentials() ?: return + val userKey = android.util.Base64.encodeToString( + "${creds.email}_${creds.url}".toByteArray(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP + ).replace("=", "") + + val fileName = "bg_$userKey.jpg" + val file = java.io.File(filesDir, fileName) + + val inputStream = contentResolver.openInputStream(uri) + val outputStream = java.io.FileOutputStream(file) + inputStream?.copyTo(outputStream) + inputStream?.close() + outputStream.close() + + val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val blurRadius = if (isNightMode) 25f else 15f + + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + val blurredBitmap = blurBitmap(bitmap, blurRadius) + + binding.ivBackground.setImageBitmap(blurredBitmap) + binding.ivBackground.visibility = View.VISIBLE + binding.btnRemoveBackground.visibility = View.VISIBLE + + Toast.makeText(this, "Imagen de fondo aplicada", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + private fun removeBackgroundImage() { + getCurrentBackgroundFile()?.delete() + + binding.ivBackground.setImageBitmap(null) + binding.ivBackground.visibility = View.GONE + binding.btnRemoveBackground.visibility = View.GONE + + Toast.makeText(this, "Imagen de fondo eliminada", Toast.LENGTH_SHORT).show() + } + + private fun loadBackgroundImage() { + val file = getCurrentBackgroundFile() ?: return + + try { + val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val blurRadius = if (isNightMode) 25f else 15f + + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + val blurredBitmap = blurBitmap(bitmap, blurRadius) + + binding.ivBackground.setImageBitmap(blurredBitmap) + binding.ivBackground.visibility = View.VISIBLE + binding.btnRemoveBackground.visibility = View.VISIBLE + } catch (e: Exception) { + file.delete() + } + } + + private fun blurBitmap(bitmap: Bitmap, radius: Float): Bitmap { + val input = Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true) + val output = Bitmap.createBitmap(input.width, input.height, Bitmap.Config.ARGB_8888) + + val canvas = android.graphics.Canvas(output) + val paint = android.graphics.Paint().apply { + isAntiAlias = true + isFilterBitmap = true + } + + for (i in 1..10) { + paint.maskFilter = android.graphics.BlurMaskFilter(radius, android.graphics.BlurMaskFilter.Blur.NORMAL) + canvas.drawBitmap(input, 0f, 0f, paint) + } + + return Bitmap.createScaledBitmap(output, bitmap.width, bitmap.height, true) + } + + private fun togglePageChildren(page: Page) { + if (expandedPages.contains(page.id)) { + expandedPages.remove(page.id) + refreshAdapter() + } else { + expandedPages.add(page.id) + if (childPagesMap.containsKey(page.id) && childPagesMap[page.id]?.isNotEmpty() == true) { + refreshAdapter() + } else { + loadChildPagesRecursive(page) { + refreshAdapter() + } + } + } + } + + private fun loadChildPagesRecursive(page: Page, onComplete: (() -> Unit)? = null) { + val spaceId = page.spaceId ?: return + CoroutineScope(Dispatchers.IO).launch { + try { + val children = cachedApi.getSidebarPages(spaceId, page.id) + withContext(Dispatchers.Main) { + childPagesMap[page.id] = children + onComplete?.invoke() + for (child in children) { + if (child.hasChildren) { + loadChildPagesRecursive(child) + } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + childPagesMap[page.id] = emptyList() + onComplete?.invoke() + } + } + } + } + + private fun showPageContextMenu(page: Page) { + val view = layoutInflater.inflate(R.layout.dialog_context_menu, null) + val btnDuplicate = view.findViewById(R.id.btnContextDuplicate) + val btnMove = view.findViewById(R.id.btnContextMove) + val btnMoveToPage = view.findViewById(R.id.btnContextMoveToPage) + val btnCopyLink = view.findViewById(R.id.btnContextCopyLink) + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + + btnDuplicate.setOnClickListener { + dialog.dismiss() + duplicatePage(page) + } + + btnMove.setOnClickListener { + dialog.dismiss() + showMovePageDialog(page) + } + + btnMoveToPage.setOnClickListener { + dialog.dismiss() + showMoveToPageDialog(page) + } + + btnCopyLink.setOnClickListener { + dialog.dismiss() + copyPageLink(page) + } + + dialog.show() + } + + private fun duplicatePage(page: Page) { + CoroutineScope(Dispatchers.IO).launch { + try { + val newPage = cachedApi.duplicatePage(page.id) + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, R.string.page_duplicated, Toast.LENGTH_SHORT).show() + val spaceId = newPage.spaceId ?: page.spaceId + if (spaceId != null) { + val space = spaces.find { it.id == spaceId } + if (space != null) { + loadPages(space) + expandedSpaces.add(space.id) + } + } + refreshAdapter() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun showMovePageDialog(page: Page) { + val view = layoutInflater.inflate(R.layout.dialog_space_list, null) + val rv = view.findViewById(R.id.rvSpaceList) + rv.layoutManager = LinearLayoutManager(this) + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + + CoroutineScope(Dispatchers.IO).launch { + try { + val allSpaces = cachedApi.getSpaces() + withContext(Dispatchers.Main) { + val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = allSpaces.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + val v = layoutInflater.inflate(android.R.layout.simple_list_item_1, parent, false) + return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val space = allSpaces[position] + holder.itemView.findViewById(android.R.id.text1).text = space.name + holder.itemView.setOnClickListener { + dialog.dismiss() + movePageToSpace(page, space.id) + } + } + } + rv.adapter = adapter + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + dialog.dismiss() + } + } + } + + dialog.show() + } + + private fun movePageToSpace(page: Page, spaceId: String) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.movePageToSpace(page.id, spaceId) + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, R.string.page_moved, Toast.LENGTH_SHORT).show() + + val oldSpaceId = page.spaceId + if (oldSpaceId != null) { + val oldSpace = spaces.find { it.id == oldSpaceId } + if (oldSpace != null) { + loadPages(oldSpace) + } + } + + val targetSpace = spaces.find { it.id == spaceId } + if (targetSpace != null) { + loadPages(targetSpace) + expandedSpaces.add(spaceId) + } + + refreshAdapter() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun showMoveToPageDialog(page: Page) { + val view = layoutInflater.inflate(R.layout.dialog_page_search, null) + val etSearch = view.findViewById(R.id.etSearchPage) + val rv = view.findViewById(R.id.rvPageList) + val tvEmpty = view.findViewById(R.id.tvEmptyPages) + val btnMoveToRoot = view.findViewById(R.id.btnMoveToRoot) + + rv.layoutManager = LinearLayoutManager(this) + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + + var allPages: List = emptyList() + var filteredPages: List = emptyList() + + data class PageWithLevel(val page: Page, val level: Int) + + fun calculateLevel(pageId: String, pagesMap: Map): Int { + var level = 0 + var currentPage = pagesMap[pageId] + while (currentPage != null && currentPage.parentPageId != null) { + level++ + currentPage = pagesMap[currentPage.parentPageId] + } + return level + } + + fun updateList(query: String) { + val pagesMap = allPages.associateBy { it.id } + + filteredPages = if (query.isBlank()) { + allPages + } else { + allPages.filter { + val title = it.title ?: "" + title.contains(query, ignoreCase = true) + } + } + + if (filteredPages.isEmpty()) { + tvEmpty.visibility = View.VISIBLE + rv.visibility = View.GONE + } else { + tvEmpty.visibility = View.GONE + rv.visibility = View.VISIBLE + } + + val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = filteredPages.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + val v = layoutInflater.inflate(android.R.layout.simple_list_item_1, parent, false) + return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val targetPage = filteredPages[position] + val level = calculateLevel(targetPage.id, pagesMap) + val tv = holder.itemView.findViewById(android.R.id.text1) + val indent = " ".repeat(level) + val prefix = if (level > 0) "└─ " else "" + tv.text = if (targetPage.icon.isNullOrBlank()) { + "$indent$prefix${targetPage.title.ifBlank { "Sin título" }}" + } else { + "$indent$prefix${targetPage.icon} ${targetPage.title.ifBlank { "Sin título" }}" + } + holder.itemView.setOnClickListener { + dialog.dismiss() + movePageToPage(page, targetPage.id) + } + } + } + rv.adapter = adapter + } + + etSearch.addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + updateList(s?.toString() ?: "") + } + override fun afterTextChanged(s: android.text.Editable?) {} + }) + + btnMoveToRoot.setOnClickListener { + dialog.dismiss() + movePageToPage(page, null) + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val spaceId = page.spaceId + val pagesList = if (spaceId != null) { + api.getAllPagesInSpace(spaceId) + } else { + emptyList() + } + allPages = pagesList.filter { it.id != page.id } + withContext(Dispatchers.Main) { + updateList("") + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + dialog.dismiss() + } + } + } + + dialog.show() + } + + private fun movePageToPage(page: Page, parentPageId: String?) { + CoroutineScope(Dispatchers.IO).launch { + try { + val position = calculatePosition(parentPageId, page.spaceId) + cachedApi.movePageToPage(page.id, parentPageId, position) + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, R.string.page_moved, Toast.LENGTH_SHORT).show() + + val spaceId = page.spaceId + if (spaceId != null) { + val space = spaces.find { it.id == spaceId } + if (space != null) { + pagesMap.remove(spaceId) + expandedPages.clear() + childPagesMap.clear() + loadPages(space) + expandedSpaces.add(spaceId) + + if (parentPageId != null) { + expandedPages.add(parentPageId) + loadChildPages(spaceId, parentPageId) + } + } + } + + refreshAdapter() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun loadChildPages(spaceId: String, pageId: String) { + CoroutineScope(Dispatchers.IO).launch { + try { + val children = cachedApi.getSidebarPages(spaceId, pageId) + withContext(Dispatchers.Main) { + childPagesMap[pageId] = children + for (child in children) { + if (child.hasChildren) { + loadChildPagesRecursive(child) + } + } + refreshAdapter() + } + } catch (e: Exception) { + childPagesMap[pageId] = emptyList() + refreshAdapter() + } + } + } + + private suspend fun calculatePosition(parentPageId: String?, spaceId: String?): String { + if (parentPageId == null || spaceId == null) return "a0000" + + val siblings = try { + cachedApi.getSidebarPages(spaceId, parentPageId) + } catch (e: Exception) { + emptyList() + } + + if (siblings.isEmpty()) return "a0000" + + val lastPosition = siblings.last().position ?: return "a0000" + + val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + val lastChar = lastPosition.last() + val idx = chars.indexOf(lastChar) + + return if (idx >= 0 && idx < chars.length - 1) { + lastPosition.dropLast(1) + chars[idx + 1] + } else { + lastPosition + "0" + } + } + + private fun copyPageLink(page: Page) { + val url = "${baseUrl.trimEnd('/')}/page/${page.id}" + val clipboard = getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("page_link", url)) + Toast.makeText(this, R.string.link_copied, Toast.LENGTH_SHORT).show() + } + + private fun showCachedPagesDialog() { + val cachedPages = pageCacheManager.getCachedPages() + if (cachedPages.isEmpty()) { + Toast.makeText(this, R.string.no_cached_pages, Toast.LENGTH_SHORT).show() + return + } + + val view = layoutInflater.inflate(R.layout.dialog_search, null) + val etSearch = view.findViewById(R.id.etSearch) + val rvResults = view.findViewById(R.id.rvSearchResults) + val tvEmpty = view.findViewById(R.id.tvSearchEmpty) + etSearch.visibility = View.GONE + tvEmpty.visibility = View.GONE + + rvResults.layoutManager = LinearLayoutManager(this) + val results = cachedPages.toMutableList() + + val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = results.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false) + return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val r = results[position] + val text1 = holder.itemView.findViewById(android.R.id.text1) + val text2 = holder.itemView.findViewById(android.R.id.text2) + text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}" + text2.text = r.spaceId ?: getString(R.string.offline_mode) + holder.itemView.setOnClickListener { + openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId)) + } + } + } + rvResults.adapter = adapter + + val maxHeight = (resources.displayMetrics.heightPixels * 0.6).toInt() + rvResults.layoutParams.height = maxHeight + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + dialog.show() + } + + private fun showDeletedPagesDialog() { + val view = layoutInflater.inflate(R.layout.dialog_deleted_pages, null) + val rvDeleted = view.findViewById(R.id.rvDeletedPages) + val tvEmpty = view.findViewById(R.id.tvEmptyDeleted) + val progress = view.findViewById(R.id.progressDeleted) + + rvDeleted.layoutManager = LinearLayoutManager(this) + progress.visibility = View.VISIBLE + tvEmpty.visibility = View.GONE + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + dialog.show() + + loadDeletedPages(rvDeleted, tvEmpty, progress, dialog) + } + + private fun loadSavedAccounts() { + val savedAccounts = sessionManager.getSavedAccounts() + val currentCreds = sessionManager.getCredentials() + + val userCardRoot = binding.userCard.root + val tvUserCount = userCardRoot.findViewById(R.id.tvUserCount) + val accountList = userCardRoot.findViewById(R.id.accountList) + + if (savedAccounts.isEmpty()) { + userCardRoot.visibility = View.GONE + return + } + + userCardRoot.visibility = View.VISIBLE + tvUserCount.text = "(${savedAccounts.size})" + + accountList.removeAllViews() + + for (account in savedAccounts) { + val view = LayoutInflater.from(this).inflate(R.layout.item_saved_account, accountList, false) + + val ivAvatar = view.findViewById(R.id.ivAccountAvatar) + val tvEmail = view.findViewById(R.id.tvAccountEmail) + val tvUrl = view.findViewById(R.id.tvAccountUrl) + val btnSwitch = view.findViewById(R.id.btnSwitchAccount) + val btnRemove = view.findViewById(R.id.btnRemoveAccount) + + tvEmail.text = account.email + tvUrl.text = account.url + + if (account.avatarUrl.isNullOrBlank()) { + ivAvatar.setImageResource(R.drawable.ic_page) + } else { + val fullAvatarUrl = buildString { + append(account.url.trimEnd('/')) + append("/api/attachments/img/${AttachmentType.AVATAR}/${account.avatarUrl}") + } + com.docmost.app.utils.ImageLoader.loadSync(fullAvatarUrl, ivAvatar) + } + + val isCurrent = currentCreds?.email == account.email && currentCreds?.url == account.url + btnSwitch.isEnabled = !isCurrent + btnSwitch.text = if (isCurrent) getString(R.string.current_account) else getString(R.string.switch_account) + + btnSwitch.setOnClickListener { + switchToAccount(account) + } + + btnRemove.setOnClickListener { + removeSavedAccount(account.email, account.url) + } + + accountList.addView(view) + } + } + + private fun switchToAccount(account: Credentials) { + CoroutineScope(Dispatchers.IO).launch { + try { + val newApi = DocmostApi(account.url) + val token = newApi.login(account.email, account.password) + + val user = newApi.getCurrentUser() + val avatarUrl = user?.avatarUrl + + val newCredentials = account.copy(authToken = token, avatarUrl = avatarUrl) + + val currentCreds = sessionManager.getCredentials() + if (currentCreds != null) { + sessionManager.saveAccount(currentCreds) + } + + withContext(Dispatchers.Main) { + sessionManager.clear() + sessionManager.saveCredentials(newCredentials) + sessionManager.saveAccount(newCredentials) + + api = DocmostApi(account.url) + api.setAuthToken(token) + + pageCacheManager = PageCacheManager(this@SpacesActivity) + pageCacheManager.setCurrentUser(account.email, account.url) + + spaceCacheManager = SpaceCacheManager(this@SpacesActivity) + spaceCacheManager.setCurrentUser(account.email, account.url) + syncManager = SyncManager.getInstance(this@SpacesActivity, api, pageCacheManager, spaceCacheManager) + cachedApi = CachedApi(api, pageCacheManager, spaceCacheManager, syncManager, networkObserver) + + Toast.makeText(this@SpacesActivity, getString(R.string.switched_to, account.email), Toast.LENGTH_SHORT).show() + + val intent = Intent(this@SpacesActivity, SpacesActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, getString(R.string.error_switching_account, e.message), Toast.LENGTH_LONG).show() + } + } + } + } + + private fun removeSavedAccount(email: String, url: String) { + sessionManager.removeAccount(email, url) + loadSavedAccounts() + Toast.makeText(this, getString(R.string.account_removed), Toast.LENGTH_SHORT).show() + } + + private fun showAddAccountDialog() { + val view = layoutInflater.inflate(R.layout.dialog_add_account, null) + val etUrl = view.findViewById(R.id.etServerUrl) + val etEmail = view.findViewById(R.id.etAccountEmail) + val etPassword = view.findViewById(R.id.etAccountPassword) + + val currentCreds = sessionManager.getCredentials() + etUrl.setText(currentCreds?.url ?: "") + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.add_account_title) + .setView(view) + .setPositiveButton("Guardar") { _, _ -> + val url = etUrl.text.toString().trim() + val email = etEmail.text.toString().trim() + val password = etPassword.text.toString().trim() + + if (url.isEmpty() || email.isEmpty() || password.isEmpty()) { + Toast.makeText(this, R.string.fill_all_fields, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + if (sessionManager.hasAccount(email, url)) { + Toast.makeText(this, R.string.account_already_exists, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val testApi = DocmostApi(url) + val token = testApi.login(email, password) + + val user = testApi.getCurrentUser() + val avatarUrl = user?.avatarUrl + + val credentials = Credentials(url, email, password, token, avatarUrl) + sessionManager.saveAccount(credentials) + + withContext(Dispatchers.Main) { + loadSavedAccounts() + Toast.makeText(this@SpacesActivity, R.string.account_saved, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + .setNegativeButton("Cancelar", null) + .show() + } + + private fun formatDeletedDate(dateString: String): String { + return try { + val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US) + format.timeZone = java.util.TimeZone.getTimeZone("UTC") + val date = format.parse(dateString) ?: return dateString + + val outputFormat = java.text.SimpleDateFormat("dd MMM yyyy, HH:mm", java.util.Locale.getDefault()) + outputFormat.format(date) + } catch (e: Exception) { + dateString + } + } + + private fun formatPageDate(dateString: String): String { + if (dateString.isBlank()) return "" + return try { + val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US) + format.timeZone = java.util.TimeZone.getTimeZone("UTC") + val date = format.parse(dateString) ?: return dateString + val outputFormat = java.text.SimpleDateFormat("dd MMM yyyy, HH:mm", java.util.Locale.getDefault()) + outputFormat.format(date) + } catch (e: Exception) { + dateString + } + } + + private fun loadDeletedPages( + rvDeleted: androidx.recyclerview.widget.RecyclerView, + tvEmpty: TextView, + progress: com.google.android.material.progressindicator.CircularProgressIndicator, + dialog: BottomSheetDialog + ) { + CoroutineScope(Dispatchers.IO).launch { + try { + val spaces = cachedApi.getSpaces() + val allDeletedPages = mutableListOf() + + for (space in spaces) { + try { + val deletedPages = cachedApi.getDeletedPages(space.id) + allDeletedPages.addAll(deletedPages) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("SpacesActivity", "Error getting deleted pages for space ${space.id}: ${e.message}") + } + } + + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + + if (allDeletedPages.isEmpty()) { + tvEmpty.visibility = View.VISIBLE + rvDeleted.visibility = View.GONE + } else { + tvEmpty.visibility = View.GONE + rvDeleted.visibility = View.VISIBLE + + rvDeleted.adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = allDeletedPages.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + val v = layoutInflater.inflate(R.layout.item_deleted_page, parent, false) + return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val page = allDeletedPages[position] + val tvIcon = holder.itemView.findViewById(R.id.tvPageIcon) + val tvTitle = holder.itemView.findViewById(R.id.tvPageTitle) + val tvDeletedAt = holder.itemView.findViewById(R.id.tvDeletedAt) + val btnRestore = holder.itemView.findViewById(R.id.btnRestore) + + tvIcon.text = page.icon ?: "📄" + tvTitle.text = page.title + tvDeletedAt.text = "Eliminado: ${formatDeletedDate(page.deletedAt)}" + + btnRestore.setOnClickListener { + confirmRestorePage(page, dialog) + } + } + } + } + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + tvEmpty.text = "No disponible sin conexión" + tvEmpty.visibility = View.VISIBLE + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + tvEmpty.text = "Error: ${e.message}" + tvEmpty.visibility = View.VISIBLE + } + } + } + } + + private fun confirmRestorePage(page: DeletedPage, dialog: BottomSheetDialog) { + MaterialAlertDialogBuilder(this) + .setTitle("Restaurar página") + .setMessage("¿Quieres restaurar \"${page.title}\"?") + .setPositiveButton("Restaurar") { _, _ -> + restorePage(page, dialog) + } + .setNegativeButton("Cancelar", null) + .show() + } + + private fun restorePage(page: DeletedPage, dialog: BottomSheetDialog) { + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.restorePage(page.id, page.spaceId) + + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Página restaurada: ${page.title}", Toast.LENGTH_SHORT).show() + dialog.dismiss() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error al restaurar: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun loadShares() { + binding.progressShares.visibility = View.VISIBLE + binding.emptyShares.visibility = View.GONE + + CoroutineScope(Dispatchers.IO).launch { + try { + val shares = cachedApi.getShares() + withContext(Dispatchers.Main) { + binding.swipeShares.isRefreshing = false + binding.progressShares.visibility = View.GONE + if (shares.isEmpty()) { + binding.emptyShares.visibility = View.VISIBLE + binding.emptyShares.text = getString(R.string.no_shares) + } + shareAdapter.submitList(shares) + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + binding.swipeShares.isRefreshing = false + binding.progressShares.visibility = View.GONE + binding.emptyShares.visibility = View.VISIBLE + binding.emptyShares.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.swipeShares.isRefreshing = false + binding.progressShares.visibility = View.GONE + binding.emptyShares.visibility = View.VISIBLE + binding.emptyShares.text = "Error: ${e.message}" + } + } + } + } + + private val shareAdapter = object : RecyclerView.Adapter() { + private val items = mutableListOf() + + fun submitList(list: List) { + items.clear() + items.addAll(list) + notifyDataSetChanged() + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + } + + override fun getItemCount(): Int = items.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShareViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_share_card, parent, false) + return ShareViewHolder(view) + } + + override fun onBindViewHolder(holder: ShareViewHolder, position: Int) { + holder.bind(items[position]) + } + } + + inner class ShareViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvTitle: TextView = itemView.findViewById(R.id.tvSharePageTitle) + private val tvIcon: TextView = itemView.findViewById(R.id.tvShareIcon) + private val ivIcon: ImageView = itemView.findViewById(R.id.ivShareIcon) + private val tvUrl: TextView = itemView.findViewById(R.id.tvShareUrl) + private val btnCopy: ImageButton = itemView.findViewById(R.id.btnCopyShareLink) + private val btnDelete: ImageButton = itemView.findViewById(R.id.btnDeleteShare) + + fun bind(share: com.docmost.app.api.Share) { + val pageInfo = share.page + val pageTitle = if (pageInfo?.title.isNullOrBlank()) "Sin título" else pageInfo?.title!! + val pageIcon = pageInfo?.icon + val pageSlugId = pageInfo?.slugId ?: "" + + tvTitle.text = pageTitle + if (pageIcon.isNullOrBlank()) { + tvIcon.visibility = View.GONE + ivIcon.visibility = View.VISIBLE + } else { + tvIcon.visibility = View.VISIBLE + tvIcon.text = pageIcon + ivIcon.visibility = View.GONE + } + + val shareUrl = if (pageSlugId.isNotEmpty()) { + "${baseUrl.trimEnd('/')}/share/${share.key}/p/${pageTitle}-${pageSlugId}" + } else { + "${baseUrl.trimEnd('/')}/share/${share.key}/p/${pageTitle}" + } + tvUrl.text = shareUrl + + btnDelete.setOnClickListener { + deleteShare(share.id) + } + btnCopy.setOnClickListener { + val clipboard = getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("share_link", tvUrl.text)) + Toast.makeText(this@SpacesActivity, R.string.link_copied, Toast.LENGTH_SHORT).show() + } + } + } + + private fun showCreateShareDialog() { + val view = layoutInflater.inflate(R.layout.dialog_search, null) + val etSearch = view.findViewById(R.id.etSearch) + val rvResults = view.findViewById(R.id.rvSearchResults) + val tvEmpty = view.findViewById(R.id.tvSearchEmpty) + rvResults.layoutManager = LinearLayoutManager(this) + + var searchJob: Job? = null + val results = mutableListOf() + val adapter = object : RecyclerView.Adapter() { + override fun getItemCount() = results.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false) + return object : RecyclerView.ViewHolder(v) {} + } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val r = results[position] + val text1 = holder.itemView.findViewById(android.R.id.text1) + val text2 = holder.itemView.findViewById(android.R.id.text2) + text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}" + text2.text = r.spaceName ?: "" + holder.itemView.setOnClickListener { + confirmCreateShare(r.id, r.title) + } + } + } + rvResults.adapter = adapter + + val maxHeight = (resources.displayMetrics.heightPixels * 0.6).toInt() + rvResults.layoutParams.height = maxHeight + + CoroutineScope(Dispatchers.IO).launch { + try { + val recentPages = cachedApi.getRecentPages() + val searchResults = recentPages.map { page -> + SearchResult( + id = page.id, + title = if (page.title.isNullOrBlank()) "Sin título" else page.title, + icon = page.icon, + spaceId = page.spaceId, + spaceName = null + ) + } + withContext(Dispatchers.Main) { + results.clear() + results.addAll(searchResults) + adapter.notifyDataSetChanged() + if (results.isEmpty()) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "No hay páginas recientes" + } + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "Error: ${e.message}" + } + } + } + + etSearch.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + searchJob?.cancel() + val query = s?.toString()?.trim() ?: return + if (query.length < 2) { + CoroutineScope(Dispatchers.IO).launch { + try { + val recentPages = cachedApi.getRecentPages() + val searchResults = recentPages.map { page -> + SearchResult( + id = page.id, + title = if (page.title.isNullOrBlank()) "Sin título" else page.title, + icon = page.icon, + spaceId = page.spaceId, + spaceName = null + ) + } + withContext(Dispatchers.Main) { + results.clear() + results.addAll(searchResults) + adapter.notifyDataSetChanged() + tvEmpty.visibility = if (results.isEmpty()) View.VISIBLE else View.GONE + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "Error: ${e.message}" + } + } + } + return + } + searchJob = CoroutineScope(Dispatchers.IO).launch { + delay(300) + try { + val items = cachedApi.searchPages(query) + withContext(Dispatchers.Main) { + results.clear() + results.addAll(items) + adapter.notifyDataSetChanged() + tvEmpty.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE + tvEmpty.text = if (items.isEmpty()) "No se encontraron resultados" else "" + } + } catch (e: OfflineNotAvailableException) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "No disponible sin conexión" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + tvEmpty.visibility = View.VISIBLE + tvEmpty.text = "Error: ${e.message}" + } + } + } + } + }) + + val dialog = BottomSheetDialog(this) + dialog.setContentView(view) + dialog.show() + } + + private var isCreatingShare = false + + private fun confirmCreateShare(pageId: String, title: String) { + if (isCreatingShare) return + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.share_page) + .setMessage("$title\n${getString(R.string.confirm_share)}") + .setPositiveButton(R.string.share_page) { _, _ -> + createShare(pageId) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun createShare(pageId: String) { + if (isCreatingShare) return + isCreatingShare = true + + CoroutineScope(Dispatchers.IO).launch { + try { + val share = cachedApi.createShare(pageId) + + if (BuildConfig.DEBUG) android.util.Log.d("ShareDebug", "createShare response: key=${share.key}, pageId=${share.pageId}") + + val page = cachedApi.getPageInfo(pageId) + + val pageTitle = if (page.title.isNullOrBlank()) "page" else page.title + val pageSlugId = page.slugId ?: "" + + if (BuildConfig.DEBUG) android.util.Log.d("ShareDebug", "pageTitle=$pageTitle, pageSlugId=$pageSlugId") + + val shareUrl = if (pageSlugId.isNotEmpty()) { + "${baseUrl.trimEnd('/')}/share/${share.key}/p/${pageTitle}-${pageSlugId}" + } else { + "${baseUrl.trimEnd('/')}/share/${share.key}/p/${pageTitle}" + } + + if (BuildConfig.DEBUG) android.util.Log.d("ShareURL", "Final URL: $shareUrl") + + withContext(Dispatchers.Main) { + isCreatingShare = false + val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("share_link", shareUrl)) + Toast.makeText(this@SpacesActivity, R.string.share_created, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + isCreatingShare = false + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun deleteShare(shareId: String) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete) + .setMessage(R.string.delete_confirm) + .setPositiveButton(R.string.delete) { _, _ -> + CoroutineScope(Dispatchers.IO).launch { + try { + cachedApi.deleteShare(shareId) + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, R.string.share_deleted, Toast.LENGTH_SHORT).show() + loadShares() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(this@SpacesActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun openPageEditor(page: Page) { + val intent = Intent(this, PageEditorActivity::class.java) + intent.putExtra("pageId", page.id) + startActivity(intent) + } + + private fun authenticateWithBiometric(callback: ((Boolean) -> Unit)? = null) { + val biometricManager = BiometricManager.from(this) + when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.biometric_title)) + .setSubtitle(getString(R.string.biometric_subtitle)) + .setNegativeButtonText(getString(R.string.cancel)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + val prompt = BiometricPrompt(this, biometricExecutor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + isAuthenticated = true + runOnUiThread { callback?.invoke(true) } + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED) { + if (biometricEnabled) { + runOnUiThread { finishAffinity() } + } + } + runOnUiThread { callback?.invoke(false) } + } + override fun onAuthenticationFailed() { + runOnUiThread { callback?.invoke(false) } + } + }) + prompt.authenticate(promptInfo) + } + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { + runOnUiThread { + Toast.makeText(this, R.string.biometric_not_available, Toast.LENGTH_LONG).show() + callback?.invoke(false) + } + } + } + } +} diff --git a/app/src/main/java/com/docmost/app/ui/SpacesAdapter.kt b/app/src/main/java/com/docmost/app/ui/SpacesAdapter.kt new file mode 100644 index 0000000..363176c --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/SpacesAdapter.kt @@ -0,0 +1,526 @@ +package com.docmost.app.ui + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.docmost.app.R +import com.docmost.app.api.AttachmentType +import com.docmost.app.api.Page +import com.docmost.app.api.Space +import com.docmost.app.BuildConfig +import com.google.android.material.color.MaterialColors + +class SpacesAdapter( + private val onSpaceToggle: (Space) -> Unit, + private val onPageClick: (Page) -> Unit, + private val onSpaceEdit: (Space) -> Unit, + private val onAddPage: (Space) -> Unit, + private val onPageDelete: (Page) -> Unit, + private val onPageLongClick: (Page) -> Unit, + private val onPageToggle: (Page) -> Unit, + private val baseUrl: String, + var currentUserId: String = "" +) : RecyclerView.Adapter() { + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_SPACE = 1 + } + + private sealed class DisplayItem { + data class Header(val label: String) : DisplayItem() + data class SpaceItem(val space: Space) : DisplayItem() + } + + private val displayItems = mutableListOf() + + private val spaces = mutableListOf() + private val pagesMap = mutableMapOf>() + private val expandedSpaces = mutableSetOf() + private val childPagesMap = mutableMapOf>() + private val expandedPages = mutableSetOf() + private var animateEntry = false + + override fun getItemViewType(position: Int): Int { + return when (displayItems[position]) { + is DisplayItem.Header -> TYPE_HEADER + is DisplayItem.SpaceItem -> TYPE_SPACE + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + TYPE_HEADER -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_space_header, parent, false) + HeaderViewHolder(view) + } + else -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_space, parent, false) + SpaceViewHolder(view) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = displayItems[position]) { + is DisplayItem.Header -> (holder as HeaderViewHolder).bind(item.label) + is DisplayItem.SpaceItem -> (holder as SpaceViewHolder).bind(item.space) + } + } + + override fun getItemCount(): Int = displayItems.size + + override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + super.onViewAttachedToWindow(holder) + if (!animateEntry) return + + val pos = holder.absoluteAdapterPosition + if (pos < 0) return + + val density = holder.itemView.resources.displayMetrics.density + holder.itemView.apply { + alpha = 0f + translationY = 40 * density + postDelayed({ + animate() + .alpha(1f) + .translationY(0f) + .setDuration(400) + .setInterpolator(DecelerateInterpolator()) + .start() + }, (pos * 60L).coerceAtMost(500L)) + } + } + + private fun buildDisplayItems() { + displayItems.clear() + + val effectiveUserId = currentUserId.ifBlank { + spaces.firstNotNullOfOrNull { it.membership?.userId } ?: "" + } + + if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", "buildDisplayItems: currentUserId='$currentUserId', effectiveUserId='$effectiveUserId', spaces count=${spaces.size}") + for (space in spaces) { + if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", " space='${space.name}' creatorId='${space.creatorId}' membership=${space.membership}") + } + + val ownSpaces = spaces.filter { + it.creatorId == effectiveUserId + } + val sharedSpaces = spaces.filter { + effectiveUserId.isNotBlank() && it.creatorId != effectiveUserId + } + + if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", "ownSpaces=${ownSpaces.size}, sharedSpaces=${sharedSpaces.size}") + + if (ownSpaces.isNotEmpty()) { + displayItems.add(DisplayItem.Header("Tus espacios")) + for (space in ownSpaces) { + displayItems.add(DisplayItem.SpaceItem(space)) + } + } + + if (sharedSpaces.isNotEmpty()) { + displayItems.add(DisplayItem.Header("Compartidos contigo")) + for (space in sharedSpaces) { + displayItems.add(DisplayItem.SpaceItem(space)) + } + } + } + + fun submitList( + spaces: List, + pagesMap: Map>, + expandedSpaces: Set, + childPagesMap: Map> = emptyMap(), + expandedPages: Set = emptySet() + ) { + val hadItems = this.spaces.isNotEmpty() + this.spaces.clear() + this.spaces.addAll(spaces) + this.pagesMap.clear() + this.pagesMap.putAll(pagesMap) + this.expandedSpaces.clear() + this.expandedSpaces.addAll(expandedSpaces) + this.childPagesMap.clear() + this.childPagesMap.putAll(childPagesMap) + this.expandedPages.clear() + this.expandedPages.addAll(expandedPages) + + buildDisplayItems() + + animateEntry = !hadItems && this.spaces.isNotEmpty() + notifyDataSetChanged() + if (animateEntry) { + recyclerViewRef?.post { animateEntry = false } + } + } + + private var recyclerViewRef: View? = null + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + recyclerViewRef = recyclerView + } + + inner class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvLabel: TextView = itemView.findViewById(R.id.tvHeaderLabel) + + fun bind(label: String) { + tvLabel.text = label + } + } + + inner class SpaceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvName: TextView = itemView.findViewById(R.id.tvSpaceName) + private val tvDesc: TextView = itemView.findViewById(R.id.tvSpaceDescription) + private val tvRole: TextView = itemView.findViewById(R.id.tvRole) + private val btnExpand: ImageButton = itemView.findViewById(R.id.btnExpand) + private val btnEdit: ImageButton = itemView.findViewById(R.id.btnEditSpace) + private val ivIcon: ImageView = itemView.findViewById(R.id.ivSpaceIcon) + private val ivBlurredBg: ImageView = itemView.findViewById(R.id.ivBlurredBg) + private val vScrim: View = itemView.findViewById(R.id.vScrim) + private val ivGradientBorder: View = itemView.findViewById(R.id.ivGradientBorder) + private val pagesContainer: LinearLayout = itemView.findViewById(R.id.pagesContainer) + private val tvPageCount: TextView = itemView.findViewById(R.id.tvPageCount) + private var currentSpace: Space? = null + private var wasExpanded = false + private var isCollapsing = false + private val cardView: com.google.android.material.card.MaterialCardView = + itemView as com.google.android.material.card.MaterialCardView + + private val gradientBorderDrawable = GradientBorderDrawable() + private val density = itemView.resources.displayMetrics.density + + init { + ivGradientBorder.background = gradientBorderDrawable + gradientBorderDrawable.setStroke( + 2 * density, + 12 * density + ) + val defaultStart = MaterialColors.getColor( + itemView, com.google.android.material.R.attr.colorPrimaryContainer + ) + val defaultEnd = MaterialColors.getColor( + itemView, com.google.android.material.R.attr.colorSecondaryContainer + ) + gradientBorderDrawable.setGradientColors(defaultStart, defaultEnd) + } + + private fun extractGradientColors(bitmap: Bitmap): Pair? { + return try { + val software = if (bitmap.config == Bitmap.Config.HARDWARE) { + bitmap.copy(Bitmap.Config.ARGB_8888, false) + } else { + bitmap + } ?: return null + + val size = minOf(software.width, software.height, 50) + val scaled = Bitmap.createScaledBitmap(software, size, size, true) + val colors = mutableListOf() + val step = maxOf(1, size / 10) + for (x in 0 until size step step) { + for (y in 0 until size step step) { + colors.add(scaled.getPixel(x, y)) + } + } + if (colors.size < 2) return null + + colors.sortBy { color -> + 0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color) + } + + Pair(colors.first(), colors.last()) + } catch (e: Exception) { + if (BuildConfig.DEBUG) android.util.Log.e("SpacesAdapter", "extractGradientColors failed: ${e.message}") + null + } + } + + private fun getRoleLabel(role: String): String { + return when (role.lowercase()) { + "admin" -> "Admin" + "writer" -> "Editor" + "reader" -> "Visor" + else -> role + } + } + + fun bind(space: Space) { + currentSpace = space + tvName.text = space.name + if (space.description.isNullOrBlank()) { + tvDesc.visibility = View.GONE + } else { + tvDesc.visibility = View.VISIBLE + tvDesc.text = space.description + } + + val isShared = currentUserId.isNotBlank() && space.creatorId != currentUserId + if (isShared && space.membership?.role.isNullOrBlank().not()) { + tvRole.visibility = View.VISIBLE + tvRole.text = getRoleLabel(space.membership!!.role) + } else { + tvRole.visibility = View.GONE + } + + val isExpanded = expandedSpaces.contains(space.id) + btnExpand.setImageResource( + if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more + ) + + itemView.setOnClickListener { onSpaceToggle(space) } + btnExpand.setOnClickListener { onSpaceToggle(space) } + btnEdit.setOnClickListener { onSpaceEdit(space) } + + val pages = pagesMap[space.id] + if (isExpanded || pages == null) { + tvPageCount.visibility = View.GONE + } else { + tvPageCount.visibility = View.VISIBLE + tvPageCount.text = "${pages.size} páginas" + } + + if (isExpanded && !wasExpanded) { + pagesContainer.visibility = View.VISIBLE + pagesContainer.alpha = 1f + renderPages() + pagesContainer.post { animatePageEntries() } + } else if (!isExpanded && wasExpanded) { + animatePageExit() + } else if (isExpanded) { + pagesContainer.visibility = View.VISIBLE + pagesContainer.alpha = 1f + renderPages() + } else { + pagesContainer.visibility = View.GONE + pagesContainer.removeAllViews() + } + + wasExpanded = isExpanded + loadSpaceIcon(space) + } + + fun animateCollapse(onDone: () -> Unit) { + if (isCollapsing) return + isCollapsing = true + + val density = itemView.resources.displayMetrics.density + val childCount = pagesContainer.childCount + if (childCount == 0) { + isCollapsing = false + onDone() + return + } + + var remaining = childCount + for (i in 0 until childCount) { + pagesContainer.getChildAt(i).animate() + .alpha(0f) + .translationY(-20 * density) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + remaining-- + if (remaining == 0) { + isCollapsing = false + onDone() + } + } + .start() + } + } + + private fun animatePageEntries() { + val density = itemView.resources.displayMetrics.density + for (i in 0 until pagesContainer.childCount) { + val child = pagesContainer.getChildAt(i) + child.alpha = 0f + child.translationY = 30 * density + child.animate() + .alpha(1f) + .translationY(0f) + .setDuration(350) + .setStartDelay((i * 60L).coerceAtMost(400L)) + .setInterpolator(DecelerateInterpolator()) + .start() + } + } + + private fun animatePageExit() { + val childCount = pagesContainer.childCount + if (childCount == 0) { + pagesContainer.visibility = View.GONE + pagesContainer.removeAllViews() + return + } + + if (pagesContainer.getChildAt(0).alpha == 0f) { + pagesContainer.visibility = View.GONE + pagesContainer.removeAllViews() + return + } + + var remaining = childCount + for (i in 0 until childCount) { + pagesContainer.getChildAt(i).animate() + .alpha(0f) + .setDuration(150) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + remaining-- + if (remaining == 0) { + pagesContainer.visibility = View.GONE + pagesContainer.removeAllViews() + } + } + .start() + } + } + + private fun renderPages() { + pagesContainer.removeAllViews() + + val space = currentSpace ?: return + val pages = pagesMap[space.id] + + if (pages.isNullOrEmpty()) { + val addPageView = createAddPageView() + pagesContainer.addView(addPageView) + return + } + + for (page in pages) { + renderPageWithChildren(page, 0) + } + + val addPageView = createAddPageView() + pagesContainer.addView(addPageView) + } + + private fun renderPageWithChildren(page: Page, depth: Int) { + val pageView = createPageView(page, depth) + pagesContainer.addView(pageView) + + if (expandedPages.contains(page.id)) { + val children = childPagesMap[page.id] + if (!children.isNullOrEmpty()) { + for (child in children) { + renderPageWithChildren(child, depth + 1) + } + } + } + } + + private fun createPageView(page: Page, depth: Int): View { + val view = LayoutInflater.from(itemView.context).inflate(R.layout.item_page, pagesContainer, false) + val tvTitle: TextView = view.findViewById(R.id.tvPageTitle) + val tvIcon: TextView = view.findViewById(R.id.tvPageIcon) + val ivIcon: ImageView = view.findViewById(R.id.ivPageIcon) + val btnDelete: ImageButton = view.findViewById(R.id.btnDeletePage) + val btnExpand: ImageButton = view.findViewById(R.id.btnExpandPage) + + tvTitle.text = if (page.title.isNullOrBlank()) "Untitled" else page.title + + if (page.icon.isNullOrBlank()) { + tvIcon.visibility = View.GONE + ivIcon.visibility = View.VISIBLE + } else { + tvIcon.visibility = View.VISIBLE + tvIcon.text = page.icon + ivIcon.visibility = View.GONE + } + + val density = itemView.resources.displayMetrics.density + val basePadding = 24 + val paddingStart = ((basePadding + depth * 24) * density).toInt() + view.setPadding(paddingStart, view.paddingTop, view.paddingRight, view.paddingBottom) + + if (page.hasChildren) { + btnExpand.visibility = View.VISIBLE + val isExpanded = expandedPages.contains(page.id) + btnExpand.setImageResource( + if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more + ) + btnExpand.setOnClickListener { + onPageToggle(page) + } + } else { + btnExpand.visibility = View.GONE + } + + view.setOnClickListener { onPageClick(page) } + view.setOnLongClickListener { onPageLongClick(page); true } + btnDelete.visibility = View.VISIBLE + btnDelete.setOnClickListener { onPageDelete(page) } + + return view + } + + private fun createAddPageView(): View { + val view = LayoutInflater.from(itemView.context).inflate(R.layout.item_add_page, pagesContainer, false) + currentSpace?.let { space -> view.setOnClickListener { onAddPage(space) } } + return view + } + + private fun loadSpaceIcon(space: Space) { + if (space.logo.isNullOrBlank()) { + ivIcon.visibility = View.GONE + ivIcon.setImageDrawable(null) + ivBlurredBg.visibility = View.GONE + vScrim.visibility = View.GONE + return + } + + ivIcon.visibility = View.VISIBLE + val url = buildString { + append(baseUrl.trimEnd('/')) + append("/api/attachments/img/${AttachmentType.SPACE_ICON}/${space.logo}") + } + + ivBlurredBg.visibility = View.GONE + vScrim.visibility = View.GONE + ivIcon.setImageResource(R.drawable.ic_page) + + com.docmost.app.utils.ImageLoader.loadWithBitmap(url, ivIcon, onBitmapLoaded = { bitmap: Bitmap -> + extractGradientColors(bitmap)?.let { (c1, c2) -> + gradientBorderDrawable.setGradientColors(c1, c2) + } + setBlurredBackground(bitmap) + }) + } + + private fun setBlurredBackground(bitmap: Bitmap) { + val surfaceColor = MaterialColors.getColor( + itemView, com.google.android.material.R.attr.colorSurface + ) + + val scale = 6 + val smallW = maxOf(bitmap.width / scale, 1) + val smallH = maxOf(bitmap.height / scale, 1) + val small = Bitmap.createScaledBitmap(bitmap, smallW, smallH, true) + val blurred = Bitmap.createScaledBitmap(small, smallW * 2, smallH * 2, true) + + ivBlurredBg.setImageBitmap(blurred) + ivBlurredBg.alpha = 0.35f + ivBlurredBg.visibility = View.VISIBLE + + vScrim.background = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.TRANSPARENT, Color.TRANSPARENT, surfaceColor, surfaceColor) + ) + vScrim.visibility = View.VISIBLE + } + } +} diff --git a/app/src/main/java/com/docmost/app/ui/VersionViewerActivity.kt b/app/src/main/java/com/docmost/app/ui/VersionViewerActivity.kt new file mode 100644 index 0000000..e1234ce --- /dev/null +++ b/app/src/main/java/com/docmost/app/ui/VersionViewerActivity.kt @@ -0,0 +1,260 @@ +package com.docmost.app.ui + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.docmost.app.R +import com.docmost.app.api.DocmostApi +import com.docmost.app.data.Credentials +import com.docmost.app.data.SessionManager +import com.docmost.app.utils.ContentConverter +import com.google.android.material.button.MaterialButton +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.progressindicator.CircularProgressIndicator +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VersionViewerActivity : AppCompatActivity() { + + private lateinit var webView: WebView + private lateinit var progress: CircularProgressIndicator + private lateinit var actionBar: LinearLayout + private lateinit var ivAvatar: ShapeableImageView + private lateinit var tvAuthor: TextView + private lateinit var tvDateTime: TextView + private lateinit var btnLoadVersion: MaterialButton + private lateinit var api: DocmostApi + private var creds: Credentials? = null + private var currentPageContent: String = "" + private var originalTipTapJson: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_version_viewer) + + // Hide the default ActionBar + supportActionBar?.hide() + + webView = findViewById(R.id.webViewVersion) + progress = findViewById(R.id.progressVersion) + actionBar = findViewById(R.id.actionBar) + ivAvatar = findViewById(R.id.ivAvatar) + tvAuthor = findViewById(R.id.tvAuthor) + tvDateTime = findViewById(R.id.tvDateTime) + btnLoadVersion = findViewById(R.id.btnLoadVersion) + + val pageId = intent.getStringExtra("pageId") ?: return finish() + val historyId = intent.getStringExtra("historyId") ?: return finish() + val author = intent.getStringExtra("author") ?: "Desconocido" + val dateTime = intent.getStringExtra("dateTime") ?: "" + + val sessionManager = SessionManager(this) + com.docmost.app.utils.ImageLoader.init(this, sessionManager) + creds = sessionManager.getCredentials() + if (creds == null) { + finish() + return + } + + api = DocmostApi(creds!!.url) + api.setAuthToken(creds!!.authToken) + + tvAuthor.text = author + tvDateTime.text = dateTime + + // Cargar avatar del autor + CoroutineScope(Dispatchers.IO).launch { + try { + val history = api.getPageHistoryInfo(pageId, historyId) + val avatarUrl = history.lastUpdatedBy?.avatarUrl + if (!avatarUrl.isNullOrBlank()) { + withContext(Dispatchers.Main) { + val fullAvatarUrl = "${creds?.url}/api/attachments/img/avatar/$avatarUrl" + com.docmost.app.utils.ImageLoader.loadSync(fullAvatarUrl, ivAvatar, R.drawable.ic_page) + } + } else { + withContext(Dispatchers.Main) { + ivAvatar.setImageResource(R.drawable.ic_page) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + ivAvatar.setImageResource(R.drawable.ic_page) + } + } + } + + btnLoadVersion.setOnClickListener { + loadVersionInEditor(pageId, historyId) + } + + setupWebView(pageId, historyId) + } + + private fun setupWebView(pageId: String, historyId: String) { + val isDark = when (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) { + android.content.res.Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val history = api.getPageHistoryInfo(pageId, historyId) + val htmlContent = if (history.content != null) { + val gson = Gson() + val json = gson.toJson(history.content) + originalTipTapJson = json + ContentConverter.tipTapToHtml(json) + } else { + "

Sin contenido

" + } + + withContext(Dispatchers.Main) { + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + builtInZoomControls = true + displayZoomControls = false + loadWithOverviewMode = true + useWideViewPort = true + } + + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + progress.visibility = View.GONE + } + } + + val darkModeCss = if (isDark) """ + body { + background-color: #1a1a1a !important; + color: #e0e0e0 !important; + } + .version-banner { + background: #2d2d2d !important; + border-color: #404040 !important; + color: #e0e0e0 !important; + } + pre, code { + background: #2d2d2d !important; + color: #e0e0e0 !important; + } + blockquote { + border-left-color: #404040 !important; + color: #9ca3af !important; + } + th { + background: #2a2a2a !important; + } + td, th { + border-color: #404040 !important; + } + a { + color: #64b5f6 !important; + } + """ else "" + + val fullHtml = """ + + + + + + + +
+ Versión de solo lectura +
+
+ $htmlContent +
+ + + """.trimIndent() + + webView.loadDataWithBaseURL( + "${creds?.url}/", + fullHtml, + "text/html", + "UTF-8", + null + ) + + currentPageContent = htmlContent + + // Mostrar barra de acción con info del usuario y botón + actionBar.visibility = View.VISIBLE + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + progress.visibility = View.GONE + webView.loadData( + "

Error

${e.message}

", + "text/html", + "UTF-8" + ) + } + } + } + } + + private fun loadVersionInEditor(pageId: String, historyId: String) { + val resultIntent = Intent() + resultIntent.putExtra("loadVersion", true) + resultIntent.putExtra("pageId", pageId) + resultIntent.putExtra("historyId", historyId) + resultIntent.putExtra("tipTapJson", originalTipTapJson) + setResult(RESULT_OK, resultIntent) + finish() + } + + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + setupWebView( + intent.getStringExtra("pageId") ?: return, + intent.getStringExtra("historyId") ?: return + ) + } +} diff --git a/app/src/main/java/com/docmost/app/utils/ContentConverter.kt b/app/src/main/java/com/docmost/app/utils/ContentConverter.kt new file mode 100644 index 0000000..df373ee --- /dev/null +++ b/app/src/main/java/com/docmost/app/utils/ContentConverter.kt @@ -0,0 +1,189 @@ +package com.docmost.app.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +object ContentConverter { + private val gson = Gson() + + fun tipTapJsonToHtml(tipTapJson: String): String { + return try { + val mapType = object : TypeToken>() {}.type + val doc = gson.fromJson>(tipTapJson, mapType) + nodeToHtml(doc) + } catch (e: Exception) { + tipTapJson + } + } + + fun tipTapToHtml(tipTapJson: String): String { + return try { + val mapType = object : TypeToken>() {}.type + val doc = gson.fromJson>(tipTapJson, mapType) + nodeToHtml(doc) + } catch (e: Exception) { + tipTapJson + } + } + + private fun nodeToHtml(node: Map): String { + val type = node["type"] as? String + + if (type == "text") { + var text = (node["text"] as? String ?: "").escapeHtml() + val marks = node["marks"] as? List> + marks?.forEach { mark -> + when (mark["type"] as? String) { + "bold" -> text = "$text" + "italic" -> text = "$text" + "code" -> text = "$text" + "strike" -> text = "$text" + "link" -> { + val href = (mark["attrs"] as? Map<*, *>)?.get("href") as? String ?: "" + text = "$text" + } + } + } + return text + } + + if (type == "hardBreak") { + return "
" + } + + val sb = StringBuilder() + val content = node["content"] as? List<*> + + when (type) { + "doc" -> { + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + } + "paragraph" -> { + sb.append("

") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("

") + } + "heading" -> { + val level = (node["attrs"] as? Map<*, *>)?.get("level") as? Int ?: 1 + sb.append("") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("") + } + "bulletList" -> { + sb.append("
    ") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("
") + } + "orderedList" -> { + sb.append("
    ") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("
") + } + "listItem" -> { + sb.append("
  • ") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("
  • ") + } + "codeBlock" -> { + sb.append("
    ")
    +                content?.forEach { child ->
    +                    if (child is Map<*, *>) {
    +                        @Suppress("UNCHECKED_CAST")
    +                        sb.append(nodeToHtml(child as Map))
    +                    }
    +                }
    +                sb.append("
    ") + } + "blockquote" -> { + sb.append("
    ") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("
    ") + } + "image" -> { + val src = (node["attrs"] as? Map<*, *>)?.get("src") as? String ?: "" + val alt = (node["attrs"] as? Map<*, *>)?.get("alt") as? String ?: "" + sb.append("\"${alt.escapeHtml()}\"") + } + "table" -> { + sb.append("") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("
    ") + } + "tableRow" -> { + sb.append("") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("") + } + "tableCell", "tableHeader" -> { + val tag = if (type == "tableHeader") "th" else "td" + sb.append("<$tag>") + content?.forEach { child -> + if (child is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + sb.append(nodeToHtml(child as Map)) + } + } + sb.append("") + } + "horizontalRule" -> { + sb.append("
    ") + } + } + + return sb.toString() + } + + private fun String.escapeHtml(): String { + return this + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } +} diff --git a/app/src/main/java/com/docmost/app/utils/ImageLoader.kt b/app/src/main/java/com/docmost/app/utils/ImageLoader.kt new file mode 100644 index 0000000..5437ae5 --- /dev/null +++ b/app/src/main/java/com/docmost/app/utils/ImageLoader.kt @@ -0,0 +1,144 @@ +package com.docmost.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.widget.ImageView +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.ImageRequest +import com.docmost.app.R +import com.docmost.app.data.SessionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +object ImageLoader { + + private var instance: ImageLoader? = null + private var sessionManager: SessionManager? = null + + fun init(context: Context, sessionManager: SessionManager) { + this.sessionManager = sessionManager + val cacheDir = File(context.cacheDir, "image_cache") + + instance = ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir) + .maxSizePercent(0.25) + .build() + } + .okHttpClient { + okhttp3.OkHttpClient.Builder() + .addInterceptor { chain -> + val original = chain.request() + val creds = sessionManager.getCredentials() + val newRequest = if (creds?.authToken != null) { + original.newBuilder() + .addHeader("Cookie", "authToken=${creds.authToken}") + .build() + } else { + original + } + chain.proceed(newRequest) + } + .build() + } + .build() + } + + suspend fun load( + url: String, + imageView: ImageView, + fallback: Int = R.drawable.ic_page + ) { + withContext(Dispatchers.Main) { + val loader = instance ?: run { + imageView.setImageResource(fallback) + return@withContext + } + + val request = ImageRequest.Builder(imageView.context) + .data(url) + .target( + onSuccess = { result -> + imageView.setImageDrawable(result) + }, + onError = { + imageView.setImageResource(fallback) + } + ) + .build() + + loader.enqueue(request) + } + } + + fun loadSync( + url: String, + imageView: ImageView, + fallback: Int = R.drawable.ic_page + ) { + val loader = instance ?: run { + imageView.setImageResource(fallback) + return + } + + val request = ImageRequest.Builder(imageView.context) + .data(url) + .target( + onSuccess = { result -> + imageView.setImageDrawable(result) + }, + onError = { + imageView.setImageResource(fallback) + } + ) + .build() + + loader.enqueue(request) + } + + fun loadWithBitmap( + url: String, + imageView: ImageView, + onBitmapLoaded: ((Bitmap) -> Unit)? = null, + fallback: Int = R.drawable.ic_page + ) { + val loader = instance ?: run { + imageView.setImageResource(fallback) + return + } + + val request = ImageRequest.Builder(imageView.context) + .data(url) + .target( + onSuccess = { result -> + imageView.setImageDrawable(result) + val bitmap = drawableToBitmap(result) + bitmap?.let { onBitmapLoaded?.invoke(it) } + }, + onError = { + imageView.setImageResource(fallback) + } + ) + .build() + + loader.enqueue(request) + } + + private fun drawableToBitmap(drawable: Drawable): Bitmap? { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + return null + } +} diff --git a/app/src/main/java/com/docmost/app/utils/NetworkConnectivityObserver.kt b/app/src/main/java/com/docmost/app/utils/NetworkConnectivityObserver.kt new file mode 100644 index 0000000..5094b9a --- /dev/null +++ b/app/src/main/java/com/docmost/app/utils/NetworkConnectivityObserver.kt @@ -0,0 +1,120 @@ +package com.docmost.app.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +enum class NetworkStatus { + ONLINE, + OFFLINE, + SYNCING +} + +class NetworkConnectivityObserver(private val context: Context) { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val _networkStatus = MutableStateFlow(checkNetworkStatus()) + val networkStatus: StateFlow = _networkStatus.asStateFlow() + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _networkStatus.value = NetworkStatus.ONLINE + } + + override fun onLost(network: Network) { + _networkStatus.value = NetworkStatus.OFFLINE + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val isOnline = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + _networkStatus.value = if (isOnline) NetworkStatus.ONLINE else NetworkStatus.OFFLINE + } + } + + init { + registerNetworkCallback() + } + + private fun registerNetworkCallback() { + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + + private fun checkNetworkStatus(): NetworkStatus { + return try { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + val hasInternet = capabilities?.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) == true + if (hasInternet) NetworkStatus.ONLINE else NetworkStatus.OFFLINE + } catch (e: Exception) { + NetworkStatus.OFFLINE + } + } + + fun isOnline(): Boolean { + return _networkStatus.value == NetworkStatus.ONLINE + } + + fun isOffline(): Boolean { + return _networkStatus.value == NetworkStatus.OFFLINE + } + + fun setSyncing(isSyncing: Boolean) { + _networkStatus.value = if (isSyncing) NetworkStatus.SYNCING else checkNetworkStatus() + } + + fun observeNetworkStatus(): Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(NetworkStatus.ONLINE) + } + + override fun onLost(network: Network) { + trySend(NetworkStatus.OFFLINE) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val isOnline = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + trySend(if (isOnline) NetworkStatus.ONLINE else NetworkStatus.OFFLINE) + } + } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(networkRequest, callback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() + + fun cleanup() { + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (e: Exception) { + // Already unregistered + } + } +} diff --git a/app/src/main/res/drawable/bg_search.xml b/app/src/main/res/drawable/bg_search.xml new file mode 100644 index 0000000..4109e60 --- /dev/null +++ b/app/src/main/res/drawable/bg_search.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..79373ca --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..54da215 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..dc5a764 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_docmost.xml b/app/src/main/res/drawable/ic_docmost.xml new file mode 100644 index 0000000..51125f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_docmost.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000..25a1496 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..4c9b2c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000..f5d622c --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_page.xml b/app/src/main/res/drawable/ic_page.xml new file mode 100644 index 0000000..0b75aec --- /dev/null +++ b/app/src/main/res/drawable/ic_page.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pencil.xml b/app/src/main/res/drawable/ic_pencil.xml new file mode 100644 index 0000000..77ea071 --- /dev/null +++ b/app/src/main/res/drawable/ic_pencil.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recent.xml b/app/src/main/res/drawable/ic_recent.xml new file mode 100644 index 0000000..b4f002a --- /dev/null +++ b/app/src/main/res/drawable/ic_recent.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..8846a59 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..faf71da --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..334947a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spaces.xml b/app/src/main/res/drawable/ic_spaces.xml new file mode 100644 index 0000000..520154f --- /dev/null +++ b/app/src/main/res/drawable/ic_spaces.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 0000000..34cee37 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..ee93465 --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_page_editor.xml b/app/src/main/res/layout/activity_page_editor.xml new file mode 100644 index 0000000..1219d56 --- /dev/null +++ b/app/src/main/res/layout/activity_page_editor.xml @@ -0,0 +1,569 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_spaces.xml b/app/src/main/res/layout/activity_spaces.xml new file mode 100644 index 0000000..0881c00 --- /dev/null +++ b/app/src/main/res/layout/activity_spaces.xml @@ -0,0 +1,598 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_version_viewer.xml b/app/src/main/res/layout/activity_version_viewer.xml new file mode 100644 index 0000000..fe0a06e --- /dev/null +++ b/app/src/main/res/layout/activity_version_viewer.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_add_account.xml b/app/src/main/res/layout/dialog_add_account.xml new file mode 100644 index 0000000..35699b6 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_account.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_context_menu.xml b/app/src/main/res/layout/dialog_context_menu.xml new file mode 100644 index 0000000..92a22e1 --- /dev/null +++ b/app/src/main/res/layout/dialog_context_menu.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_create_page.xml b/app/src/main/res/layout/dialog_create_page.xml new file mode 100644 index 0000000..9e89c9f --- /dev/null +++ b/app/src/main/res/layout/dialog_create_page.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_create_space.xml b/app/src/main/res/layout/dialog_create_space.xml new file mode 100644 index 0000000..45f6701 --- /dev/null +++ b/app/src/main/res/layout/dialog_create_space.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_deleted_pages.xml b/app/src/main/res/layout/dialog_deleted_pages.xml new file mode 100644 index 0000000..d998e55 --- /dev/null +++ b/app/src/main/res/layout/dialog_deleted_pages.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_edit_space.xml b/app/src/main/res/layout/dialog_edit_space.xml new file mode 100644 index 0000000..780b703 --- /dev/null +++ b/app/src/main/res/layout/dialog_edit_space.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_emoji_picker.xml b/app/src/main/res/layout/dialog_emoji_picker.xml new file mode 100644 index 0000000..6cd240b --- /dev/null +++ b/app/src/main/res/layout/dialog_emoji_picker.xml @@ -0,0 +1,33 @@ + + + + + + + +