index.html 103 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  6. <title>Text</title>
  7. <script src="../script/jquery.min.js"></script>
  8. <script src="../script/ao_module.js"></script>
  9. <script src="lib/marked.min.js"></script>
  10. <script src="lib/turndown.min.js"></script>
  11. <script src="lib/turndown-plugin-gfm.min.js"></script>
  12. <script src="lib/pdf-lib.min.js"></script>
  13. <style>
  14. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  15. :root {
  16. --toolbar-h: 42px;
  17. --statusbar-h: 22px;
  18. --bg: #e4e9f2;
  19. --toolbar-bg: #f6f7fb;
  20. --toolbar-bdr: #e2e4ec;
  21. --editor-bg: #ffffff;
  22. --text: #2b2f38;
  23. --text2: #8a90a2;
  24. --accent: #2f6bd8;
  25. --accent-soft: rgba(47,107,216,.12);
  26. --sep: #d7dbe6;
  27. --hover: rgba(0,0,0,.06);
  28. --btn-active-bg:#2f6bd8;
  29. --btn-active-fg:#fff;
  30. --dirty-color: #2f6bd8;
  31. --code-bg: #f1f3f8;
  32. --quote-bdr: #d2d8e6;
  33. --table-bdr: #dfe3ec;
  34. --shadow: 0 10px 40px rgba(30,40,70,.18);
  35. }
  36. body.dark {
  37. --bg: #0f131b;
  38. --toolbar-bg: #161c27;
  39. --toolbar-bdr: #232c3b;
  40. --editor-bg: #121722;
  41. --text: #d6def0;
  42. --text2: #6b7793;
  43. --accent: #5b9cf6;
  44. --accent-soft: rgba(91,156,246,.16);
  45. --sep: #2a3445;
  46. --hover: rgba(255,255,255,.07);
  47. --btn-active-bg:#5b9cf6;
  48. --btn-active-fg:#0f131b;
  49. --dirty-color: #5b9cf6;
  50. --code-bg: #1b2230;
  51. --quote-bdr: #313c50;
  52. --table-bdr: #2a3445;
  53. --shadow: 0 10px 40px rgba(0,0,0,.55);
  54. }
  55. html, body {
  56. height: 100%;
  57. background: var(--bg);
  58. color: var(--text);
  59. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  60. font-size: 13px;
  61. }
  62. #app { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
  63. /* ── Toolbar ──────────────────────────────────────────────────────── */
  64. #toolbar {
  65. display: flex; align-items: center; gap: 1px; flex-shrink: 0;
  66. height: var(--toolbar-h); padding: 0 8px;
  67. background: var(--toolbar-bg);
  68. border-bottom: 1px solid var(--toolbar-bdr);
  69. box-shadow: 0 1px 4px rgba(0,0,0,.06);
  70. overflow-x: auto; overflow-y: hidden; position: relative; z-index: 5;
  71. scrollbar-width: thin;
  72. }
  73. #toolbar::-webkit-scrollbar { height: 4px; }
  74. #toolbar::-webkit-scrollbar-thumb { background: var(--sep); border-radius: 3px; }
  75. .tb-btn {
  76. display: inline-flex; align-items: center; gap: 4px; height: 28px;
  77. padding: 0 8px; border: none; border-radius: 6px; background: none;
  78. color: var(--text); font-size: 12px; font-family: inherit; cursor: pointer;
  79. white-space: nowrap; flex-shrink: 0; transition: background .1s; user-select: none;
  80. }
  81. .tb-btn:hover { background: var(--hover); }
  82. .tb-btn.active { background: var(--btn-active-bg); color: var(--btn-active-fg); }
  83. .tb-btn svg { display: block; }
  84. .tb-btn .glyph { width: 15px; text-align: center; font-size: 13px; line-height: 1; }
  85. .tb-sep { width: 1px; height: 20px; background: var(--sep); flex-shrink: 0; margin: 0 4px; }
  86. .tb-spacer { flex: 1 1 auto; min-width: 6px; }
  87. .tb-label { font-size: 11px; color: var(--text2); flex-shrink: 0; white-space: nowrap; }
  88. .tb-select {
  89. height: 26px; padding: 0 6px; border: 1px solid var(--toolbar-bdr);
  90. border-radius: 6px; background: var(--editor-bg); color: var(--text);
  91. font-size: 12px; font-family: inherit; outline: none; cursor: pointer;
  92. flex-shrink: 0; transition: border-color .1s;
  93. }
  94. .tb-select:focus { border-color: var(--accent); }
  95. /* dropdown menu (export / image) */
  96. .menu-wrap { position: relative; flex-shrink: 0; }
  97. .menu {
  98. display: none; position: fixed; min-width: 180px;
  99. background: var(--editor-bg); border: 1px solid var(--toolbar-bdr);
  100. border-radius: 10px; box-shadow: var(--shadow); padding: 6px; z-index: 120;
  101. }
  102. .menu.show { display: block; }
  103. .menu-item {
  104. display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px;
  105. border: none; background: none; color: var(--text); font-size: 12.5px;
  106. font-family: inherit; border-radius: 7px; cursor: pointer; text-align: left;
  107. }
  108. .menu-item:hover { background: var(--accent-soft); }
  109. .menu-item svg { flex-shrink: 0; color: var(--text2); }
  110. /* hide markdown-only controls when editing plain text */
  111. body.mode-txt .md-only { display: none !important; }
  112. /* ── Editor surfaces ──────────────────────────────────────────────── */
  113. #editor-scroll {
  114. flex: 1; min-height: 0; overflow-y: auto; background: var(--editor-bg);
  115. transition: background .2s, color .2s; position: relative;
  116. }
  117. #rich, #plain {
  118. max-width: 820px; margin: 0 auto; min-height: 100%;
  119. padding: 40px clamp(18px, 6vw, 60px) 120px;
  120. outline: none; color: var(--text); line-height: 1.7;
  121. font-size: 16px; word-wrap: break-word;
  122. }
  123. #plain {
  124. display: none; width: 100%; border: none; resize: none; background: none;
  125. font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
  126. font-size: 14px; white-space: pre-wrap; tab-size: 4; -moz-tab-size: 4;
  127. }
  128. body.mode-txt #rich { display: none; }
  129. body.mode-txt #plain { display: block; }
  130. #rich:empty::before, #rich.is-empty::before {
  131. content: attr(data-ph); color: var(--text2); pointer-events: none;
  132. }
  133. /* ── Markdown content typography (shared by editor + export) ──────── */
  134. .md-content h1, .md-content h2, .md-content h3,
  135. .md-content h4, .md-content h5, .md-content h6 {
  136. font-weight: 700; line-height: 1.3; margin: 1.4em 0 .6em;
  137. }
  138. .md-content h1 { font-size: 2em; border-bottom: 1px solid var(--sep); padding-bottom: .25em; }
  139. .md-content h2 { font-size: 1.6em; border-bottom: 1px solid var(--sep); padding-bottom: .2em; }
  140. .md-content h3 { font-size: 1.3em; }
  141. .md-content h4 { font-size: 1.1em; }
  142. .md-content h5 { font-size: 1em; }
  143. .md-content h6 { font-size: .9em; color: var(--text2); }
  144. .md-content p { margin: .55em 0; }
  145. .md-content a { color: var(--accent); text-decoration: none; }
  146. .md-content a:hover { text-decoration: underline; }
  147. .md-content ul, .md-content ol { margin: .5em 0; padding-left: 1.7em; }
  148. .md-content li { margin: .25em 0; }
  149. .md-content blockquote {
  150. margin: .8em 0; padding: .2em 1em; color: var(--text2);
  151. border-left: 3px solid var(--quote-bdr); background: var(--accent-soft);
  152. border-radius: 0 6px 6px 0;
  153. }
  154. .md-content code {
  155. font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; font-size: .88em;
  156. background: var(--code-bg); padding: .15em .4em; border-radius: 4px;
  157. }
  158. .md-content pre {
  159. margin: .8em 0; padding: 14px 16px; background: var(--code-bg);
  160. border-radius: 8px; overflow-x: auto;
  161. }
  162. .md-content pre code { background: none; padding: 0; font-size: .85em; line-height: 1.55; }
  163. .md-content hr { border: none; border-top: 1px solid var(--sep); margin: 1.6em 0; }
  164. .md-content img { max-width: 100%; border-radius: 6px; vertical-align: middle; }
  165. .md-content table { border-collapse: collapse; margin: .8em 0; width: auto; max-width: 100%; }
  166. .md-content th, .md-content td { border: 1px solid var(--table-bdr); padding: 6px 12px; }
  167. .md-content th { background: var(--code-bg); font-weight: 600; }
  168. .md-content ul.contains-task-list { list-style: none; padding-left: 1.2em; }
  169. .md-content li.task-list-item { list-style: none; }
  170. .md-content input[type=checkbox] { margin-right: .5em; }
  171. /* ── Status bar ───────────────────────────────────────────────────── */
  172. #statusbar {
  173. flex-shrink: 0; height: var(--statusbar-h); display: flex; align-items: center;
  174. justify-content: space-between; padding: 0 12px; background: var(--toolbar-bg);
  175. border-top: 1px solid var(--toolbar-bdr); font-size: 10.5px; color: var(--text2);
  176. }
  177. #status-msg.dirty { color: var(--dirty-color); }
  178. #status-msg.error { color: #d9534f; }
  179. #status-right { display: flex; gap: 12px; align-items: center; }
  180. /* ── Settings panel ───────────────────────────────────────────────── */
  181. .overlay {
  182. display: none; position: fixed; inset: 0; background: rgba(10,15,25,.45);
  183. backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  184. z-index: 200; align-items: center; justify-content: center; padding: 20px;
  185. }
  186. .overlay.show { display: flex; }
  187. #settings-box {
  188. background: var(--editor-bg); border: 1px solid var(--toolbar-bdr);
  189. border-radius: 14px; box-shadow: var(--shadow); width: 760px; max-width: 100%;
  190. height: 520px; max-height: 90vh; display: flex; overflow: hidden;
  191. }
  192. #settings-nav {
  193. width: 180px; flex-shrink: 0; background: var(--toolbar-bg);
  194. border-right: 1px solid var(--toolbar-bdr); padding: 14px 10px; overflow-y: auto;
  195. }
  196. #settings-nav .nav-title { font-size: 13px; font-weight: 700; padding: 4px 10px 12px; }
  197. .nav-item {
  198. display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px;
  199. border: none; background: none; color: var(--text); font-size: 13px;
  200. font-family: inherit; border-radius: 8px; cursor: pointer; text-align: left; margin-bottom: 2px;
  201. }
  202. .nav-item:hover { background: var(--hover); }
  203. .nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
  204. .nav-item svg { flex-shrink: 0; }
  205. #settings-body { flex: 1; padding: 22px 26px; overflow-y: auto; }
  206. .settings-section { display: none; }
  207. .settings-section.active { display: block; }
  208. .settings-section h2 { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
  209. .settings-section .sub { font-size: 12px; color: var(--text2); margin-bottom: 18px; }
  210. .set-row {
  211. display: flex; align-items: center; justify-content: space-between;
  212. gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--toolbar-bdr);
  213. }
  214. .set-row:last-child { border-bottom: none; }
  215. .set-label { font-size: 13px; font-weight: 600; }
  216. .set-desc { font-size: 11.5px; color: var(--text2); margin-top: 2px; }
  217. .set-ctl { flex-shrink: 0; }
  218. .set-input, .set-select {
  219. height: 30px; padding: 0 8px; border: 1px solid var(--toolbar-bdr);
  220. border-radius: 7px; background: var(--editor-bg); color: var(--text);
  221. font-size: 12.5px; font-family: inherit; outline: none;
  222. }
  223. .set-input:focus, .set-select:focus { border-color: var(--accent); }
  224. .set-input.num { width: 76px; }
  225. /* toggle switch */
  226. .switch { position: relative; display: inline-block; width: 42px; height: 24px; flex-shrink: 0; }
  227. .switch input { opacity: 0; width: 0; height: 0; }
  228. .slider {
  229. position: absolute; cursor: pointer; inset: 0; background: var(--sep);
  230. border-radius: 24px; transition: .2s;
  231. }
  232. .slider::before {
  233. content: ""; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px;
  234. background: #fff; border-radius: 50%; transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.3);
  235. }
  236. .switch input:checked + .slider { background: var(--accent); }
  237. .switch input:checked + .slider::before { transform: translateX(18px); }
  238. /* segmented control (mode) */
  239. .seg { display: inline-flex; border: 1px solid var(--toolbar-bdr); border-radius: 8px; overflow: hidden; }
  240. .seg button {
  241. border: none; background: var(--editor-bg); color: var(--text); font-family: inherit;
  242. font-size: 12.5px; padding: 7px 14px; cursor: pointer;
  243. }
  244. .seg button.active { background: var(--accent); color: #fff; }
  245. /* shortcuts list */
  246. .key-row {
  247. display: flex; align-items: center; justify-content: space-between;
  248. padding: 9px 0; border-bottom: 1px solid var(--toolbar-bdr);
  249. }
  250. .key-row .key-name { font-size: 13px; }
  251. .key-cap {
  252. min-width: 120px; text-align: center; padding: 6px 10px; border-radius: 7px;
  253. border: 1px solid var(--toolbar-bdr); background: var(--code-bg); color: var(--text);
  254. font-size: 12px; font-family: 'SF Mono','Consolas',monospace; cursor: pointer;
  255. }
  256. .key-cap:hover { border-color: var(--accent); }
  257. .key-cap.recording { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
  258. .range-wrap { display: flex; align-items: center; gap: 10px; }
  259. input[type=range] { accent-color: var(--accent); }
  260. .btn-text {
  261. border: 1px solid var(--toolbar-bdr); background: var(--editor-bg); color: var(--text);
  262. font-family: inherit; font-size: 12.5px; padding: 7px 14px; border-radius: 8px; cursor: pointer;
  263. }
  264. .btn-text:hover { background: var(--hover); }
  265. .btn-text.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
  266. #settings-foot {
  267. display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; margin-top: 8px;
  268. }
  269. /* ── Confirm dialog ───────────────────────────────────────────────── */
  270. #confirm-box {
  271. background: var(--editor-bg); border: 1px solid var(--toolbar-bdr);
  272. border-radius: 13px; padding: 24px 24px 18px; width: 320px; box-shadow: var(--shadow);
  273. }
  274. #confirm-box h3 { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
  275. #confirm-box p { font-size: 12.5px; color: var(--text2); line-height: 1.55; margin-bottom: 18px; }
  276. .confirm-row { display: flex; gap: 8px; justify-content: flex-end; }
  277. .cbtn {
  278. padding: 7px 14px; border-radius: 7px; font-size: 12.5px; font-weight: 600;
  279. font-family: inherit; cursor: pointer; border: none; transition: opacity .1s;
  280. }
  281. .cbtn:hover { opacity: .88; }
  282. .cbtn-cancel { background: var(--hover); color: var(--text); border: 1px solid var(--toolbar-bdr); }
  283. .cbtn-discard { background: rgba(217,83,79,.14); color: #d9534f; border: 1px solid rgba(217,83,79,.32); }
  284. .cbtn-save { background: var(--accent); color: #fff; }
  285. /* busy spinner for export */
  286. #busy {
  287. display: none; position: fixed; inset: 0; z-index: 400; align-items: center;
  288. justify-content: center; background: rgba(10,15,25,.4); color: #fff; font-size: 13px;
  289. flex-direction: column; gap: 14px;
  290. }
  291. #busy.show { display: flex; }
  292. .spin {
  293. width: 34px; height: 34px; border: 3px solid rgba(255,255,255,.25);
  294. border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite;
  295. }
  296. @keyframes spin { to { transform: rotate(360deg); } }
  297. /* ── Responsive ───────────────────────────────────────────────────── */
  298. @media (max-width: 680px) {
  299. .tb-label, .tb-btn .tb-text { display: none; }
  300. .tb-btn { padding: 0 7px; }
  301. #settings-box { flex-direction: column; height: 92vh; }
  302. #settings-nav {
  303. width: 100%; display: flex; gap: 4px; overflow-x: auto; padding: 8px;
  304. border-right: none; border-bottom: 1px solid var(--toolbar-bdr);
  305. }
  306. #settings-nav .nav-title { display: none; }
  307. .nav-item { width: auto; white-space: nowrap; margin-bottom: 0; }
  308. #rich, #plain { padding: 24px 16px 100px; font-size: 15px; }
  309. .set-row { flex-direction: column; align-items: stretch; }
  310. .set-ctl { align-self: flex-start; }
  311. }
  312. @media print {
  313. #toolbar, #statusbar, .overlay, #busy { display: none !important; }
  314. #editor-scroll { overflow: visible; }
  315. #rich, #plain { max-width: 100%; padding: 0; }
  316. }
  317. </style>
  318. </head>
  319. <body>
  320. <div id="app">
  321. <!-- ── Toolbar ──────────────────────────────────────────────────────── -->
  322. <div id="toolbar">
  323. <button class="tb-btn" onclick="openFile()" title="Open file">
  324. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
  325. <!-- <span class="tb-text">Open</span> -->
  326. </button>
  327. <button class="tb-btn" onclick="saveFile()" title="Save (Ctrl+S)">
  328. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
  329. <!-- <span class="tb-text">Save</span> -->
  330. </button>
  331. <div class="tb-sep md-only"></div>
  332. <!-- Heading / block format -->
  333. <select id="sel-head" class="tb-select md-only" style="width:104px;" onchange="onHeadingSelect()" title="Paragraph style">
  334. <option value="p">Paragraph</option>
  335. <option value="h1">Heading 1</option>
  336. <option value="h2">Heading 2</option>
  337. <option value="h3">Heading 3</option>
  338. <option value="h4">Heading 4</option>
  339. <option value="h5">Heading 5</option>
  340. <option value="h6">Heading 6</option>
  341. </select>
  342. <div class="tb-sep md-only"></div>
  343. <button class="tb-btn md-only" id="btn-bold" onclick="actBold()" title="Bold"><span class="glyph"><strong>B</strong></span></button>
  344. <button class="tb-btn md-only" id="btn-italic" onclick="actItalic()" title="Italic"><span class="glyph"><em>I</em></span></button>
  345. <button class="tb-btn md-only" id="btn-strike" onclick="actStrike()" title="Strikethrough"><span class="glyph"><s>S</s></span></button>
  346. <button class="tb-btn md-only" id="btn-code" onclick="actInlineCode()" title="Inline code"><span class="glyph" style="font-family:monospace;">&lt;&gt;</span></button>
  347. <div class="tb-sep md-only"></div>
  348. <button class="tb-btn md-only" onclick="actQuote()" title="Blockquote">
  349. <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h4v4H8.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7zm7 0h4v4h-2.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7z"/></svg>
  350. </button>
  351. <button class="tb-btn md-only" onclick="actBullet()" title="Bullet list">
  352. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.3" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1.3" fill="currentColor" stroke="none"/></svg>
  353. </button>
  354. <button class="tb-btn md-only" onclick="actNumber()" title="Numbered list">
  355. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="10" y1="6" x2="20" y2="6"/><line x1="10" y1="12" x2="20" y2="12"/><line x1="10" y1="18" x2="20" y2="18"/><text x="2" y="8" font-size="7" fill="currentColor" stroke="none">1</text><text x="2" y="14" font-size="7" fill="currentColor" stroke="none">2</text><text x="2" y="20" font-size="7" fill="currentColor" stroke="none">3</text></svg>
  356. </button>
  357. <button class="tb-btn md-only" onclick="actCodeBlock()" title="Code block">
  358. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
  359. </button>
  360. <div class="tb-sep md-only"></div>
  361. <button class="tb-btn md-only" onclick="actLink()" title="Insert link">
  362. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>
  363. </button>
  364. <div class="menu-wrap md-only">
  365. <button class="tb-btn" id="btn-image" onclick="toggleMenu('img-menu')" title="Insert image">
  366. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
  367. </button>
  368. <div class="menu" id="img-menu">
  369. <button class="menu-item" onclick="hideMenus();pickDeviceImage()">
  370. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
  371. From this device
  372. </button>
  373. <button class="menu-item" onclick="hideMenus();pickServerImage()">
  374. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
  375. From server
  376. </button>
  377. <!-- <button class="menu-item" onclick="hideMenus();insertImageByUrl()">
  378. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/></svg>
  379. By URL
  380. </button> -->
  381. </div>
  382. </div>
  383. <div class="tb-spacer"></div>
  384. <button class="tb-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle theme">
  385. <svg id="icon-sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
  386. <svg id="icon-moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
  387. </button>
  388. <div class="menu-wrap">
  389. <button class="tb-btn" onclick="toggleMenu('export-menu')" title="Export">
  390. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
  391. <span class="tb-text">Export</span>
  392. </button>
  393. <div class="menu" id="export-menu">
  394. <button class="menu-item" id="mi-download" onclick="hideMenus();exportDocument()">
  395. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
  396. <span id="mi-download-lbl">Download document</span>
  397. </button>
  398. <button class="menu-item" onclick="hideMenus();exportHTML()">
  399. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
  400. Export as HTML
  401. </button>
  402. <button class="menu-item" onclick="hideMenus();exportPDF()">
  403. <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
  404. Export as PDF
  405. </button>
  406. </div>
  407. </div>
  408. <button class="tb-btn" onclick="openSettings()" title="Settings">
  409. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
  410. </button>
  411. </div>
  412. <!-- ── Editor ───────────────────────────────────────────────────────── -->
  413. <div id="editor-scroll">
  414. <div id="rich" class="md-content" contenteditable="true" spellcheck="false"
  415. data-ph="Start writing… type # for a heading, ** for bold"></div>
  416. <textarea id="plain" spellcheck="false" placeholder="Start typing…"></textarea>
  417. </div>
  418. <!-- ── Status bar ───────────────────────────────────────────────────── -->
  419. <div id="statusbar">
  420. <span id="status-msg">Ready</span>
  421. <div id="status-right">
  422. <span id="stat-mode">Markdown</span>
  423. <span id="stat-count">0 words</span>
  424. </div>
  425. </div>
  426. </div>
  427. <!-- hidden file input for device image picking -->
  428. <input type="file" id="device-file" accept="image/*" style="display:none;">
  429. <!-- ── Settings overlay ─────────────────────────────────────────────────── -->
  430. <div class="overlay" id="settings-overlay">
  431. <div id="settings-box">
  432. <div id="settings-nav">
  433. <div class="nav-title">Preferences</div>
  434. <button class="nav-item active" data-sec="general" onclick="showSection('general')">
  435. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
  436. General
  437. </button>
  438. <button class="nav-item" data-sec="keys" onclick="showSection('keys')">
  439. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="14" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/></svg>
  440. Shortcuts
  441. </button>
  442. <button class="nav-item" data-sec="images" onclick="showSection('images')">
  443. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
  444. Images
  445. </button>
  446. </div>
  447. <div id="settings-body">
  448. <!-- General -->
  449. <div class="settings-section active" id="sec-general">
  450. <h2>General</h2>
  451. <div class="sub">Editor behaviour and appearance.</div>
  452. <div class="set-row">
  453. <div><div class="set-label">Document mode</div><div class="set-desc">Markdown enables live formatting; Plain text edits raw characters.</div></div>
  454. <div class="set-ctl"><div class="seg" id="seg-mode">
  455. <button data-mode="md" onclick="setMode('md')">Markdown</button>
  456. <button data-mode="txt" onclick="setMode('txt')">Plain text</button>
  457. </div></div>
  458. </div>
  459. <div class="set-row">
  460. <div><div class="set-label">Dark theme</div><div class="set-desc">Match a dark workspace.</div></div>
  461. <div class="set-ctl"><label class="switch"><input type="checkbox" id="set-dark" onchange="setTheme(this.checked)"><span class="slider"></span></label></div>
  462. </div>
  463. <div class="set-row">
  464. <div><div class="set-label">Editor font</div></div>
  465. <div class="set-ctl"><select class="set-select" id="set-font" onchange="applyTypography()">
  466. <option value="system">System Default</option>
  467. <option value="serif">Serif</option>
  468. <option value="mono">Monospace</option>
  469. <option value="georgia">Georgia</option>
  470. <option value="times">Times New Roman</option>
  471. </select></div>
  472. </div>
  473. <div class="set-row">
  474. <div><div class="set-label">Font size</div></div>
  475. <div class="set-ctl"><input type="number" min="11" max="32" class="set-input num" id="set-fontsize" onchange="applyTypography()"> px</div>
  476. </div>
  477. <div class="set-row">
  478. <div><div class="set-label">Line spacing</div></div>
  479. <div class="set-ctl"><select class="set-select" id="set-lh" onchange="applyTypography()">
  480. <option value="1.4">Compact</option>
  481. <option value="1.7">Relaxed</option>
  482. <option value="2.0">Double</option>
  483. </select></div>
  484. </div>
  485. </div>
  486. <!-- Shortcuts -->
  487. <div class="settings-section" id="sec-keys">
  488. <h2>Keyboard Shortcuts</h2>
  489. <div class="sub">Click a shortcut, then press the new key combination. Press Esc to cancel.</div>
  490. <div id="keys-list"></div>
  491. <div id="settings-foot">
  492. <button class="btn-text" onclick="resetKeys()">Reset to defaults</button>
  493. </div>
  494. </div>
  495. <!-- Images -->
  496. <div class="settings-section" id="sec-images">
  497. <h2>Images</h2>
  498. <div class="sub">How imported images are stored next to your document.</div>
  499. <div class="set-row">
  500. <div><div class="set-label">Store directory</div><div class="set-desc">Relative to the document. Use {name} for the document name.</div></div>
  501. <div class="set-ctl"><input type="text" class="set-input" id="set-imgdir" style="width:150px;" onchange="saveImgPrefs()"></div>
  502. </div>
  503. <div class="set-row">
  504. <div><div class="set-label">Compress images</div><div class="set-desc">Re-encode large images before storing to save space.</div></div>
  505. <div class="set-ctl"><label class="switch"><input type="checkbox" id="set-compress" onchange="onCompressToggle()"><span class="slider"></span></label></div>
  506. </div>
  507. <div class="set-row" id="row-quality">
  508. <div><div class="set-label">Quality</div><div class="set-desc">Lower = smaller file.</div></div>
  509. <div class="set-ctl"><div class="range-wrap">
  510. <input type="range" min="30" max="100" id="set-quality" oninput="onQualityInput()" onchange="saveImgPrefs()">
  511. <span id="quality-val" style="width:38px;">80%</span>
  512. </div></div>
  513. </div>
  514. <div class="set-row" id="row-maxw">
  515. <div><div class="set-label">Max width</div><div class="set-desc">Larger images are scaled down to this width (px). 0 = keep.</div></div>
  516. <div class="set-ctl"><input type="number" min="0" max="8000" class="set-input num" id="set-maxw" onchange="saveImgPrefs()"> px</div>
  517. </div>
  518. </div>
  519. </div>
  520. </div>
  521. </div>
  522. <!-- ── Unsaved-changes dialog ────────────────────────────────────────────── -->
  523. <div class="overlay" id="confirm-overlay">
  524. <div id="confirm-box">
  525. <h3>Unsaved Changes</h3>
  526. <p id="confirm-msg">Your changes haven't been saved. Save before closing?</p>
  527. <div class="confirm-row">
  528. <button class="cbtn cbtn-cancel" onclick="dlgCancel()">Cancel</button>
  529. <button class="cbtn cbtn-discard" onclick="dlgDiscard()">Discard</button>
  530. <button class="cbtn cbtn-save" onclick="dlgSave()">Save</button>
  531. </div>
  532. </div>
  533. </div>
  534. <div id="busy"><div class="spin"></div><span id="busy-msg">Working…</span></div>
  535. <script>
  536. "use strict";
  537. /* ════════════════════════════════════════════════════════════════════════
  538. Text — Typora-style editor for ArozOS
  539. ════════════════════════════════════════════════════════════════════════ */
  540. // ── State ───────────────────────────────────────────────────────────────
  541. var filepath = "";
  542. var filename = "";
  543. var isTxtMode = false; // false = markdown WYSIWYG, true = plain text
  544. var isDark = false;
  545. var dirtyFlag = false;
  546. var dlgCallback = null;
  547. var pendingClose = null;
  548. var recordingAction = null; // shortcut currently being re-bound
  549. var rich = document.getElementById("rich");
  550. var plain = document.getElementById("plain");
  551. // ── Settings (defaults) ─────────────────────────────────────────────────
  552. var settings = {
  553. font: "system", fontSize: 16, lineHeight: "1.7",
  554. imgDir: "img/{name}", compress: false, quality: 80, maxWidth: 1600
  555. };
  556. var FONT_MAP = {
  557. system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
  558. serif: "Georgia, 'Times New Roman', serif",
  559. mono: "'SF Mono', 'Consolas', 'Courier New', monospace",
  560. georgia: "Georgia, serif",
  561. times: "'Times New Roman', Times, serif"
  562. };
  563. // ── Shortcuts ───────────────────────────────────────────────────────────
  564. var DEFAULT_KEYS = {
  565. save:"ctrl+s", bold:"ctrl+b", italic:"ctrl+i", strike:"ctrl+shift+x", inlineCode:"ctrl+e",
  566. h1:"ctrl+1", h2:"ctrl+2", h3:"ctrl+3", h4:"ctrl+4", h5:"ctrl+5", h6:"ctrl+6", paragraph:"ctrl+0",
  567. blockquote:"ctrl+shift+q", bulletList:"ctrl+shift+u", orderedList:"ctrl+shift+o",
  568. codeBlock:"ctrl+shift+k", link:"ctrl+k", image:"ctrl+shift+i", hr:"ctrl+shift+h"
  569. };
  570. var ACTIONS = {
  571. save: { label:"Save", fn:saveFile, md:false },
  572. bold: { label:"Bold", fn:actBold, md:true },
  573. italic: { label:"Italic", fn:actItalic, md:true },
  574. strike: { label:"Strikethrough", fn:actStrike, md:true },
  575. inlineCode:{ label:"Inline code", fn:actInlineCode, md:true },
  576. h1: { label:"Heading 1", fn:function(){setBlock("h1");}, md:true },
  577. h2: { label:"Heading 2", fn:function(){setBlock("h2");}, md:true },
  578. h3: { label:"Heading 3", fn:function(){setBlock("h3");}, md:true },
  579. h4: { label:"Heading 4", fn:function(){setBlock("h4");}, md:true },
  580. h5: { label:"Heading 5", fn:function(){setBlock("h5");}, md:true },
  581. h6: { label:"Heading 6", fn:function(){setBlock("h6");}, md:true },
  582. paragraph: { label:"Paragraph", fn:function(){setBlock("p");}, md:true },
  583. blockquote:{ label:"Blockquote", fn:actQuote, md:true },
  584. bulletList:{ label:"Bullet list", fn:actBullet, md:true },
  585. orderedList:{label:"Numbered list", fn:actNumber, md:true },
  586. codeBlock: { label:"Code block", fn:actCodeBlock, md:true },
  587. link: { label:"Insert link", fn:actLink, md:true },
  588. image: { label:"Insert image", fn:function(){pickDeviceImage();}, md:true },
  589. hr: { label:"Horizontal rule", fn:actHr, md:true }
  590. };
  591. var keymap = JSON.parse(JSON.stringify(DEFAULT_KEYS));
  592. // ── Turndown (HTML → Markdown) ──────────────────────────────────────────
  593. var td = new TurndownService({
  594. headingStyle:"atx", codeBlockStyle:"fenced", bulletListMarker:"-",
  595. emDelimiter:"*", strongDelimiter:"**", hr:"---"
  596. });
  597. if (window.turndownPluginGfm) td.use(turndownPluginGfm.gfm);
  598. // GFM plugin emits single-tilde strikethrough; marked only renders double-tilde
  599. td.addRule("strike2", {
  600. filter:["del","s","strike"],
  601. replacement:function(content){ return "~~" + content + "~~"; }
  602. });
  603. td.addRule("relimg", {
  604. filter:"img",
  605. replacement:function(content, node){
  606. var alt = node.getAttribute("alt") || "";
  607. var src = node.getAttribute("data-rel") || node.getAttribute("src") || "";
  608. var ttl = node.getAttribute("title");
  609. return "![" + alt + "](" + mdLinkDest(src) + (ttl ? ' "'+ttl+'"' : "") + ")";
  610. }
  611. });
  612. // A markdown link/image destination containing spaces or parentheses must be
  613. // wrapped in angle brackets, otherwise parsers (marked) treat it as plain text
  614. // and the image never becomes an <img> — which is why such images appeared as
  615. // literal "![](path)" markdown when exported. The raw path is kept inside the
  616. // brackets so mediaURLFor() can still percent-encode it for the media endpoint.
  617. function mdLinkDest(src){
  618. return /[\s()]/.test(src) ? "<" + src + ">" : src;
  619. }
  620. // ════════════════════════════════════════════════════════════════════════
  621. // Init
  622. // ════════════════════════════════════════════════════════════════════════
  623. $(function(){
  624. if (document.execCommand) {
  625. try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch(e){}
  626. try { document.execCommand("styleWithCSS", false, false); } catch(e){}
  627. }
  628. loadPrefs();
  629. var files = ao_module_loadInputFiles();
  630. if (files && files.length > 0){
  631. filepath = files[0].filepath;
  632. filename = files[0].filename;
  633. isTxtMode = !isMarkdownExt(filename);
  634. applyMode();
  635. loadFile();
  636. } else {
  637. isTxtMode = false;
  638. applyMode();
  639. updateTitle();
  640. }
  641. // editor events
  642. $("#rich").on("input", onRichInput);
  643. $("#rich").on("keydown", onRichKeydown);
  644. $("#rich").on("paste", onRichPaste);
  645. $("#rich").on("drop", onRichDrop);
  646. $("#rich").on("dragover", function(e){ e.preventDefault(); });
  647. $(document).on("selectionchange", debounce(updateToolbarState, 80));
  648. $("#plain").on("input", function(){ markDirty(); updateStatBar(); });
  649. // global keydown for shortcuts
  650. $(document).on("keydown", globalKeydown);
  651. // device file input
  652. document.getElementById("device-file").addEventListener("change", onDeviceFileChosen);
  653. // close menus when clicking elsewhere
  654. document.addEventListener("click", function(e){
  655. if (!e.target.closest(".menu-wrap")) hideMenus();
  656. });
  657. // keep selection alive when clicking toolbar buttons
  658. document.getElementById("toolbar").addEventListener("mousedown", function(e){
  659. if (e.target.closest(".tb-btn") && !e.target.closest("select")) e.preventDefault();
  660. });
  661. buildKeyList();
  662. syncSettingsUI();
  663. applyTypography();
  664. updateStatBar();
  665. });
  666. function isMarkdownExt(name){
  667. var ext = (name.split(".").pop() || "").toLowerCase();
  668. return ext === "md" || ext === "markdown" || ext === "mdown" || ext === "mkd";
  669. }
  670. // ════════════════════════════════════════════════════════════════════════
  671. // File load / save
  672. // ════════════════════════════════════════════════════════════════════════
  673. function loadFile(){
  674. $.get(ao_root + "media?file=" + encodeURIComponent(filepath) + "&t=" + Date.now(), function(data){
  675. if (typeof data !== "string") data = JSON.stringify(data, null, 2);
  676. setContent(data);
  677. dirtyFlag = false;
  678. updateTitle(); updateStatBar();
  679. }, "text").fail(function(){
  680. setStatus("Failed to load file", "error");
  681. });
  682. }
  683. function setContent(text){
  684. if (isTxtMode){
  685. plain.value = text;
  686. } else {
  687. rich.innerHTML = marked.parse(text || "");
  688. rewriteImageSrcs(rich);
  689. refreshEmptyState();
  690. }
  691. }
  692. function getContent(){
  693. if (isTxtMode) return plain.value;
  694. var html = rich.innerHTML.replace(/​/g, "");
  695. var md = td.turndown(html);
  696. return md.replace(/\n{3,}/g, "\n\n").replace(/^\s+|\s+$/g, "") + "\n";
  697. }
  698. function saveFile(){
  699. if (!filepath){
  700. var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
  701. ao_module_openFileSelector(handleSaveAs, "user:/Desktop", "new", false, { defaultName: def });
  702. return;
  703. }
  704. doSave();
  705. }
  706. function handleSaveAs(fd){
  707. if (!fd || !fd.length) return;
  708. filepath = fd[0].filepath; filename = fd[0].filename;
  709. doSave();
  710. }
  711. function doSave(callback){
  712. var content = getContent();
  713. ao_module_agirun("Text/filesaver.js", { filepath: filepath, content: content }, function(data){
  714. if (data && data.error){
  715. setStatus("Save failed: " + data.error, "error");
  716. } else {
  717. dirtyFlag = false; updateTitle(); setStatus("Saved");
  718. if (callback) callback();
  719. }
  720. }, function(){ setStatus("Save failed", "error"); });
  721. }
  722. function openFile(){
  723. ao_module_openFileSelector(handleOpenFile, "user:/", "file", false, {
  724. filter: ["txt","md","markdown","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
  725. });
  726. }
  727. function handleOpenFile(fd){
  728. if (!fd || !fd.length) return;
  729. filepath = fd[0].filepath; filename = fd[0].filename;
  730. isTxtMode = !isMarkdownExt(filename);
  731. applyMode();
  732. dirtyFlag = false;
  733. loadFile();
  734. }
  735. // ════════════════════════════════════════════════════════════════════════
  736. // Mode (markdown / plain text)
  737. // ════════════════════════════════════════════════════════════════════════
  738. function applyMode(){
  739. document.body.classList.toggle("mode-txt", isTxtMode);
  740. $("#stat-mode").text(isTxtMode ? "Plain text" : "Markdown");
  741. $("#mi-download-lbl").text(isTxtMode ? "Download .txt" : "Download .md");
  742. $("#seg-mode button").removeClass("active");
  743. $('#seg-mode button[data-mode="' + (isTxtMode ? "txt" : "md") + '"]').addClass("active");
  744. applyTypography();
  745. }
  746. // invoked from the settings segmented control — converts current content
  747. function setMode(mode){
  748. var wantTxt = (mode === "txt");
  749. if (wantTxt === isTxtMode) return;
  750. var current = getContent(); // serialise from the current surface
  751. isTxtMode = wantTxt;
  752. applyMode();
  753. setContent(current); // re-hydrate into the new surface
  754. markDirty(); updateStatBar();
  755. }
  756. // ════════════════════════════════════════════════════════════════════════
  757. // WYSIWYG editing
  758. // ════════════════════════════════════════════════════════════════════════
  759. function onRichInput(){
  760. markDirty();
  761. inlineAutoformat();
  762. refreshEmptyState();
  763. updateStatBar();
  764. }
  765. function refreshEmptyState(){
  766. var t = rich.textContent.replace(/​/g, "").trim();
  767. rich.classList.toggle("is-empty", t === "" && rich.children.length <= 1 && rich.querySelector("img,hr,table") === null);
  768. }
  769. function onRichKeydown(e){
  770. // block-level transforms triggered by Space / Enter (no modifier)
  771. if (e.ctrlKey || e.metaKey || e.altKey) return;
  772. if (e.key === " "){
  773. if (blockTransformOnSpace()) e.preventDefault();
  774. } else if (e.key === "Enter"){
  775. if (blockTransformOnEnter()) e.preventDefault();
  776. }
  777. }
  778. function getSel(){ return window.getSelection(); }
  779. function currentBlock(){
  780. var sel = getSel();
  781. if (!sel.rangeCount) return null;
  782. var n = sel.getRangeAt(0).startContainer;
  783. if (n.nodeType === 3) n = n.parentNode;
  784. while (n && n !== rich){
  785. if (/^(P|H[1-6]|LI|BLOCKQUOTE|PRE|DIV)$/.test(n.tagName)) return n;
  786. n = n.parentNode;
  787. }
  788. return null;
  789. }
  790. // text in the current block, from its start to the caret
  791. function textBeforeCaret(block){
  792. var sel = getSel();
  793. if (!sel.rangeCount) return "";
  794. var r = sel.getRangeAt(0);
  795. var pre = document.createRange();
  796. pre.selectNodeContents(block);
  797. try { pre.setEnd(r.startContainer, r.startOffset); } catch(err){ return ""; }
  798. return pre.toString();
  799. }
  800. function blockTransformOnSpace(){
  801. var block = currentBlock();
  802. if (!block || block.tagName === "PRE") return false;
  803. var pre = textBeforeCaret(block);
  804. var li = (block.tagName === "LI");
  805. if (/^#{1,6}$/.test(pre) && !li){
  806. clearMarkers(block, pre.length); changeBlockTag(block, "h" + pre.length); return true;
  807. }
  808. if (pre === ">" && !li){
  809. clearMarkers(block, 1); wrapBlockquote(block); return true;
  810. }
  811. if ((pre === "-" || pre === "*" || pre === "+") && !li){
  812. clearMarkers(block, 1); makeList(block, false); return true;
  813. }
  814. if (/^\d+\.$/.test(pre) && !li){
  815. clearMarkers(block, pre.length); makeList(block, true); return true;
  816. }
  817. return false;
  818. }
  819. // turn a block into a single-item list, merging with an adjacent list of the same kind
  820. function makeList(block, ordered){
  821. var tag = ordered ? "ol" : "ul";
  822. var li = document.createElement("li");
  823. while (block.firstChild) li.appendChild(block.firstChild);
  824. if (!li.firstChild) li.appendChild(document.createElement("br"));
  825. var prev = block.previousElementSibling;
  826. if (prev && prev.tagName.toLowerCase() === tag){
  827. prev.appendChild(li);
  828. block.parentNode.removeChild(block);
  829. } else {
  830. var list = document.createElement(tag);
  831. list.appendChild(li);
  832. block.parentNode.replaceChild(list, block);
  833. }
  834. placeCaretAtStart(li); markDirty();
  835. }
  836. function blockTransformOnEnter(){
  837. var block = currentBlock();
  838. if (!block) return false;
  839. var pre = textBeforeCaret(block).trim();
  840. var whole = block.textContent.trim();
  841. if (block.tagName !== "PRE" && whole === "```"){
  842. block.textContent = ""; insertCodeBlockAt(block); return true;
  843. }
  844. if (block.tagName !== "PRE" && (whole === "---" || whole === "***" || whole === "___")){
  845. var hr = document.createElement("hr");
  846. var p = document.createElement("p"); p.appendChild(document.createElement("br"));
  847. block.parentNode.replaceChild(p, block);
  848. p.parentNode.insertBefore(hr, p);
  849. placeCaretAtStart(p); markDirty(); return true;
  850. }
  851. // pressing Enter at the end of a heading starts a normal paragraph
  852. if (/^H[1-6]$/.test(block.tagName) && pre === whole){
  853. var np = document.createElement("p"); np.appendChild(document.createElement("br"));
  854. if (block.nextSibling) block.parentNode.insertBefore(np, block.nextSibling);
  855. else block.parentNode.appendChild(np);
  856. placeCaretAtStart(np); markDirty(); return true;
  857. }
  858. return false;
  859. }
  860. // remove the first `count` characters (markdown markers) from a block
  861. function clearMarkers(block, count){
  862. var r = document.createRange();
  863. r.selectNodeContents(block);
  864. var walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, null);
  865. var first = walker.nextNode();
  866. if (first){
  867. r.setStart(first, 0);
  868. r.setEnd(first, Math.min(count, first.data.length));
  869. r.deleteContents();
  870. } else {
  871. block.textContent = "";
  872. }
  873. }
  874. function changeBlockTag(block, tag){
  875. var nb = document.createElement(tag);
  876. while (block.firstChild) nb.appendChild(block.firstChild);
  877. if (!nb.firstChild) nb.appendChild(document.createElement("br"));
  878. block.parentNode.replaceChild(nb, block);
  879. placeCaretAtStart(nb); markDirty();
  880. }
  881. function wrapBlockquote(block){
  882. var bq = document.createElement("blockquote");
  883. var p = document.createElement("p");
  884. while (block.firstChild) p.appendChild(block.firstChild);
  885. if (!p.firstChild) p.appendChild(document.createElement("br"));
  886. bq.appendChild(p);
  887. block.parentNode.replaceChild(bq, block);
  888. placeCaretAtStart(p); markDirty();
  889. }
  890. function insertCodeBlockAt(block){
  891. var pre = document.createElement("pre");
  892. var code = document.createElement("code");
  893. code.appendChild(document.createTextNode("​"));
  894. pre.appendChild(code);
  895. block.parentNode.replaceChild(pre, block);
  896. var r = document.createRange();
  897. r.setStart(code.firstChild, 1); r.collapse(true);
  898. var s = getSel(); s.removeAllRanges(); s.addRange(r);
  899. markDirty();
  900. }
  901. function placeCaretAtStart(el){
  902. var r = document.createRange();
  903. r.setStart(el, 0); r.collapse(true);
  904. var s = getSel(); s.removeAllRanges(); s.addRange(r);
  905. rich.focus();
  906. }
  907. // inline autoformat: **bold** *italic* `code` ~~strike~~
  908. function inlineAutoformat(){
  909. var sel = getSel();
  910. if (!sel.rangeCount || !sel.isCollapsed) return;
  911. var node = sel.getRangeAt(0).startContainer;
  912. if (node.nodeType !== 3) return;
  913. var offset = sel.getRangeAt(0).startOffset;
  914. var text = node.data.substring(0, offset);
  915. var patterns = [
  916. { re:/\*\*([^*\s][^*]*?)\*\*$/, tag:"strong" },
  917. { re:/__([^_\s][^_]*?)__$/, tag:"strong" },
  918. { re:/`([^`]+?)`$/, tag:"code" },
  919. { re:/~~([^~\s][^~]*?)~~$/, tag:"del" },
  920. { re:/(?<![*\w])\*([^*\s][^*]*?)\*$/, tag:"em" },
  921. { re:/(?<![_\w])_([^_\s][^_]*?)_$/, tag:"em" }
  922. ];
  923. for (var i = 0; i < patterns.length; i++){
  924. var m = patterns[i].re.exec(text);
  925. if (m){
  926. applyInline(node, offset - m[0].length, offset, m[1], patterns[i].tag);
  927. return;
  928. }
  929. }
  930. }
  931. function applyInline(node, start, end, inner, tag){
  932. var r = document.createRange();
  933. r.setStart(node, start); r.setEnd(node, end);
  934. r.deleteContents();
  935. var el = document.createElement(tag);
  936. el.textContent = inner;
  937. r.insertNode(el);
  938. // exit the formatted span with a zero-width spacer so typing continues plain
  939. var after = document.createTextNode("​");
  940. el.parentNode.insertBefore(after, el.nextSibling);
  941. var nr = document.createRange();
  942. nr.setStart(after, 1); nr.collapse(true);
  943. var s = getSel(); s.removeAllRanges(); s.addRange(nr);
  944. markDirty();
  945. }
  946. // ── Toolbar actions ─────────────────────────────────────────────────────
  947. function exec(cmd, val){
  948. rich.focus();
  949. document.execCommand(cmd, false, val || null);
  950. markDirty(); updateStatBar(); updateToolbarState();
  951. }
  952. function actBold(){ if(isTxtMode) return; exec("bold"); }
  953. function actItalic(){ if(isTxtMode) return; exec("italic"); }
  954. function actStrike(){ if(isTxtMode) return; exec("strikeThrough"); }
  955. function actBullet(){ if(isTxtMode) return; exec("insertUnorderedList"); }
  956. function actNumber(){ if(isTxtMode) return; exec("insertOrderedList"); }
  957. function actInlineCode(){ if(isTxtMode) return; wrapSelection("code"); }
  958. function actQuote(){
  959. if(isTxtMode) return;
  960. var b = currentBlock();
  961. if (b && b.closest("blockquote")){ exec("formatBlock", "p"); }
  962. else { exec("formatBlock", "blockquote"); }
  963. }
  964. function actCodeBlock(){
  965. if(isTxtMode) return;
  966. var b = currentBlock(); if (!b) { rich.focus(); b = currentBlock(); }
  967. if (b) insertCodeBlockAt(b);
  968. }
  969. function actHr(){
  970. if(isTxtMode) return;
  971. rich.focus();
  972. document.execCommand("insertHTML", false, "<hr><p><br></p>");
  973. markDirty();
  974. }
  975. function actLink(){
  976. if(isTxtMode) return;
  977. var url = prompt("Link URL:", "https://");
  978. if (!url) return;
  979. rich.focus();
  980. var sel = getSel();
  981. if (sel.rangeCount && !sel.isCollapsed){
  982. exec("createLink", url);
  983. } else {
  984. document.execCommand("insertHTML", false, '<a href="'+escapeAttr(url)+'">'+escapeHtml(url)+'</a>');
  985. markDirty();
  986. }
  987. }
  988. function setBlock(tag){
  989. if(isTxtMode) return;
  990. exec("formatBlock", tag);
  991. }
  992. function onHeadingSelect(){
  993. setBlock($("#sel-head").val());
  994. }
  995. function wrapSelection(tag){
  996. rich.focus();
  997. var sel = getSel();
  998. if (!sel.rangeCount) return;
  999. var r = sel.getRangeAt(0);
  1000. var el = document.createElement(tag);
  1001. if (r.collapsed){
  1002. el.appendChild(document.createTextNode("​"));
  1003. r.insertNode(el);
  1004. var nr = document.createRange(); nr.setStart(el.firstChild, 1); nr.collapse(true);
  1005. sel.removeAllRanges(); sel.addRange(nr);
  1006. } else {
  1007. el.appendChild(r.extractContents());
  1008. r.insertNode(el);
  1009. }
  1010. markDirty();
  1011. }
  1012. function updateToolbarState(){
  1013. if (isTxtMode) return;
  1014. if (!rich.contains(getSel().anchorNode)) return;
  1015. try {
  1016. $("#btn-bold").toggleClass("active", document.queryCommandState("bold"));
  1017. $("#btn-italic").toggleClass("active", document.queryCommandState("italic"));
  1018. $("#btn-strike").toggleClass("active", document.queryCommandState("strikeThrough"));
  1019. } catch(e){}
  1020. var b = currentBlock();
  1021. var tag = b ? b.tagName.toLowerCase() : "p";
  1022. if (!/^h[1-6]$/.test(tag)) tag = "p";
  1023. $("#sel-head").val(tag);
  1024. $("#btn-code").toggleClass("active", !!(getSel().anchorNode && getSel().anchorNode.parentNode && getSel().anchorNode.parentNode.closest("code")));
  1025. }
  1026. // ════════════════════════════════════════════════════════════════════════
  1027. // Shortcuts
  1028. // ════════════════════════════════════════════════════════════════════════
  1029. function comboFromEvent(e){
  1030. var parts = [];
  1031. if (e.ctrlKey || e.metaKey) parts.push("ctrl");
  1032. if (e.shiftKey) parts.push("shift");
  1033. if (e.altKey) parts.push("alt");
  1034. var k = e.key;
  1035. if (k === " ") k = "space";
  1036. else if (k.length === 1) k = k.toLowerCase();
  1037. else k = k.toLowerCase();
  1038. if (["control","shift","alt","meta"].indexOf(k) >= 0) return null;
  1039. parts.push(k);
  1040. return parts.join("+");
  1041. }
  1042. function globalKeydown(e){
  1043. if (recordingAction){ recordKey(e); return; }
  1044. if ($("#settings-overlay").hasClass("show")) {
  1045. if (e.key === "Escape") closeSettings();
  1046. return;
  1047. }
  1048. var combo = comboFromEvent(e);
  1049. if (!combo) return;
  1050. for (var action in keymap){
  1051. if (keymap[action] === combo){
  1052. var def = ACTIONS[action];
  1053. if (!def) continue;
  1054. if (def.md && isTxtMode) continue; // markdown-only action in txt mode
  1055. e.preventDefault();
  1056. def.fn();
  1057. return;
  1058. }
  1059. }
  1060. }
  1061. function prettyCombo(c){
  1062. return c.split("+").map(function(p){
  1063. if (p === "ctrl") return "Ctrl";
  1064. if (p === "shift") return "Shift";
  1065. if (p === "alt") return "Alt";
  1066. if (p === "space") return "Space";
  1067. return p.length === 1 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1);
  1068. }).join(" + ");
  1069. }
  1070. function buildKeyList(){
  1071. var html = "";
  1072. for (var action in ACTIONS){
  1073. html += '<div class="key-row"><span class="key-name">' + escapeHtml(ACTIONS[action].label) +
  1074. '</span><button class="key-cap" data-action="' + action + '" onclick="startRecord(\'' + action + '\')">' +
  1075. escapeHtml(prettyCombo(keymap[action] || "")) + '</button></div>';
  1076. }
  1077. document.getElementById("keys-list").innerHTML = html;
  1078. }
  1079. function startRecord(action){
  1080. if (recordingAction){
  1081. $('.key-cap[data-action="'+recordingAction+'"]').removeClass("recording")
  1082. .text(prettyCombo(keymap[recordingAction]));
  1083. }
  1084. recordingAction = action;
  1085. $('.key-cap[data-action="'+action+'"]').addClass("recording").text("Press keys…");
  1086. }
  1087. function recordKey(e){
  1088. e.preventDefault();
  1089. if (e.key === "Escape"){
  1090. $('.key-cap[data-action="'+recordingAction+'"]').removeClass("recording")
  1091. .text(prettyCombo(keymap[recordingAction]));
  1092. recordingAction = null;
  1093. return;
  1094. }
  1095. var combo = comboFromEvent(e);
  1096. if (!combo || ["ctrl","shift","alt"].indexOf(combo) >= 0) return; // wait for a real key
  1097. // free the combo from any other action
  1098. for (var a in keymap){ if (keymap[a] === combo && a !== recordingAction) keymap[a] = ""; }
  1099. keymap[recordingAction] = combo;
  1100. recordingAction = null;
  1101. savePrefs();
  1102. buildKeyList();
  1103. }
  1104. function resetKeys(){
  1105. keymap = JSON.parse(JSON.stringify(DEFAULT_KEYS));
  1106. savePrefs(); buildKeyList();
  1107. }
  1108. // ════════════════════════════════════════════════════════════════════════
  1109. // Images
  1110. // ════════════════════════════════════════════════════════════════════════
  1111. function docDir(){
  1112. var i = filepath.lastIndexOf("/");
  1113. return i < 0 ? "" : filepath.substring(0, i);
  1114. }
  1115. function docName(){
  1116. var n = filename.replace(/\.[^.]+$/, "");
  1117. return n || "document";
  1118. }
  1119. function relDir(){
  1120. return settings.imgDir.replace(/\{name\}/g, docName()).replace(/^\.?\/+/, "");
  1121. }
  1122. function mediaURLFor(rel){
  1123. rel = rel.replace(/^\.?\/+/, "");
  1124. return ao_root + "media?file=" + encodeURIComponent(docDir() + "/" + rel);
  1125. }
  1126. function rewriteImageSrcs(root){
  1127. if (!filepath) return;
  1128. $(root).find("img").each(function(){
  1129. var src = this.getAttribute("src") || "";
  1130. if (/^(https?:|data:|blob:)/i.test(src) || src.indexOf("media?file=") >= 0) return;
  1131. var rel = src.replace(/^\.?\/+/, "");
  1132. this.setAttribute("data-rel", rel);
  1133. this.setAttribute("src", mediaURLFor(rel));
  1134. });
  1135. }
  1136. function makeImgName(orig, forceExt){
  1137. var base = (orig || "image").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_\-]+/g, "_").substr(0, 40) || "image";
  1138. var ext = forceExt || ((orig || "").split(".").pop() || "png").toLowerCase();
  1139. var ts = Date.now().toString(36);
  1140. return base + "-" + ts + "." + ext;
  1141. }
  1142. // ensure the image folder exists on the server, then run cb(reldir)
  1143. function ensureImgDir(cb){
  1144. if (!filepath){
  1145. alert("Please save the document first — images are stored in a folder next to it.");
  1146. return;
  1147. }
  1148. var rel = relDir();
  1149. ao_module_agirun("Text/imgtool.agi", { action:"mkdir", docpath:filepath, reldir:rel }, function(data){
  1150. if (data && data.error){ setStatus("Image folder error: " + data.error, "error"); return; }
  1151. cb(rel);
  1152. }, function(){ setStatus("Could not prepare image folder", "error"); });
  1153. }
  1154. function pickDeviceImage(){
  1155. if (isTxtMode) return;
  1156. if (!filepath){ alert("Please save the document first."); return; }
  1157. document.getElementById("device-file").click();
  1158. }
  1159. function onDeviceFileChosen(e){
  1160. var file = e.target.files[0];
  1161. e.target.value = "";
  1162. if (!file) return;
  1163. ensureImgDir(function(rel){
  1164. prepareBlob(file, function(blob, ext){
  1165. var fname = makeImgName(file.name, ext);
  1166. setStatus("Uploading image…");
  1167. ao_module_uploadFile(new File([blob], fname), docDir() + "/" + rel, function(){
  1168. insertImage(rel + "/" + fname, docName());
  1169. setStatus("Image inserted");
  1170. }, undefined, function(){ setStatus("Image upload failed", "error"); });
  1171. });
  1172. });
  1173. }
  1174. // client-side compression via canvas (only when enabled)
  1175. function prepareBlob(file, cb){
  1176. if (!settings.compress || !/^image\//.test(file.type) || file.type === "image/gif"){
  1177. cb(file, (file.name.split(".").pop() || "png").toLowerCase());
  1178. return;
  1179. }
  1180. var img = new Image();
  1181. img.onload = function(){
  1182. var w = img.naturalWidth, h = img.naturalHeight;
  1183. var maxW = settings.maxWidth;
  1184. if (maxW > 0 && w > maxW){ h = Math.round(h * (maxW / w)); w = maxW; }
  1185. var c = document.createElement("canvas");
  1186. c.width = w; c.height = h;
  1187. c.getContext("2d").drawImage(img, 0, 0, w, h);
  1188. c.toBlob(function(blob){
  1189. URL.revokeObjectURL(img.src);
  1190. if (blob) cb(blob, "jpg");
  1191. else cb(file, (file.name.split(".").pop() || "png").toLowerCase());
  1192. }, "image/jpeg", settings.quality / 100);
  1193. };
  1194. img.onerror = function(){ cb(file, (file.name.split(".").pop() || "png").toLowerCase()); };
  1195. img.src = URL.createObjectURL(file);
  1196. }
  1197. function pickServerImage(){
  1198. if (isTxtMode) return;
  1199. if (!filepath){ alert("Please save the document first."); return; }
  1200. ao_module_openFileSelector(handleServerImage, "user:/", "file", false, {
  1201. filter: ["jpg","jpeg","png","gif","webp","bmp","svg"]
  1202. });
  1203. }
  1204. function handleServerImage(fd){
  1205. if (!fd || !fd.length) return;
  1206. var src = fd[0].filepath, name = fd[0].filename;
  1207. var rel = relDir();
  1208. var dest = makeImgName(name, settings.compress ? "jpg" : null);
  1209. setStatus("Importing image…");
  1210. ao_module_agirun("Text/imgtool.agi", {
  1211. action:"import", docpath:filepath, reldir:rel, src:src, destname:dest,
  1212. compress: settings.compress ? "true" : "false", maxwidth: settings.maxWidth
  1213. }, function(data){
  1214. if (data && data.error){ setStatus("Import failed: " + data.error, "error"); return; }
  1215. insertImage(data.rel || (rel + "/" + dest), docName());
  1216. setStatus("Image inserted");
  1217. }, function(){ setStatus("Image import failed", "error"); });
  1218. }
  1219. function insertImageByUrl(){
  1220. if (isTxtMode) return;
  1221. var url = prompt("Image URL:", "https://");
  1222. if (!url) return;
  1223. rich.focus();
  1224. document.execCommand("insertHTML", false, '<img src="'+escapeAttr(url)+'" alt="">');
  1225. markDirty();
  1226. }
  1227. // insert an image referencing a path relative to the document
  1228. function insertImage(rel, alt){
  1229. if (isTxtMode){
  1230. insertAtTextarea(plain, "![" + (alt||"") + "](" + mdLinkDest(rel) + ")");
  1231. markDirty(); return;
  1232. }
  1233. rich.focus();
  1234. var html = '<img src="'+escapeAttr(mediaURLFor(rel))+'" data-rel="'+escapeAttr(rel)+'" alt="'+escapeAttr(alt||"")+'">';
  1235. document.execCommand("insertHTML", false, html);
  1236. refreshEmptyState(); markDirty();
  1237. }
  1238. function insertAtTextarea(ta, text){
  1239. var s = ta.selectionStart, e = ta.selectionEnd;
  1240. ta.value = ta.value.substring(0, s) + text + ta.value.substring(e);
  1241. ta.selectionStart = ta.selectionEnd = s + text.length;
  1242. ta.focus();
  1243. }
  1244. // paste / drop images straight into the editor
  1245. function onRichPaste(e){
  1246. var items = (e.originalEvent || e).clipboardData && (e.originalEvent || e).clipboardData.items;
  1247. if (!items) return;
  1248. for (var i = 0; i < items.length; i++){
  1249. if (items[i].type && items[i].type.indexOf("image") === 0){
  1250. var file = items[i].getAsFile();
  1251. if (file){ e.preventDefault(); handleDroppedImage(file); return; }
  1252. }
  1253. }
  1254. }
  1255. function onRichDrop(e){
  1256. var dt = (e.originalEvent || e).dataTransfer;
  1257. if (dt && dt.files && dt.files.length && /^image\//.test(dt.files[0].type)){
  1258. e.preventDefault(); handleDroppedImage(dt.files[0]);
  1259. }
  1260. }
  1261. function handleDroppedImage(file){
  1262. if (!filepath){ alert("Please save the document first to store pasted images."); return; }
  1263. ensureImgDir(function(rel){
  1264. prepareBlob(file, function(blob, ext){
  1265. var fname = makeImgName(file.name || "pasted.png", ext);
  1266. setStatus("Uploading image…");
  1267. ao_module_uploadFile(new File([blob], fname), docDir() + "/" + rel, function(){
  1268. insertImage(rel + "/" + fname, docName());
  1269. setStatus("Image inserted");
  1270. }, undefined, function(){ setStatus("Image upload failed", "error"); });
  1271. });
  1272. });
  1273. }
  1274. // ════════════════════════════════════════════════════════════════════════
  1275. // Export
  1276. // ════════════════════════════════════════════════════════════════════════
  1277. function renderedHTML(){
  1278. if (isTxtMode) return "<pre>" + escapeHtml(plain.value) + "</pre>";
  1279. return marked.parse(getContent());
  1280. }
  1281. // collect the CSS that styles .md-content so exports match the editor
  1282. function exportCSS(){
  1283. var bg = getCss("--editor-bg"), text = getCss("--text"), accent = getCss("--accent");
  1284. var code = getCss("--code-bg"), sep = getCss("--sep"), quote = getCss("--quote-bdr");
  1285. var tbl = getCss("--table-bdr"), text2 = getCss("--text2");
  1286. return [
  1287. "body{margin:0;background:"+bg+";color:"+text+";font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:"+settings.lineHeight+";}",
  1288. ".md-content{max-width:820px;margin:0 auto;padding:48px 40px;font-size:"+settings.fontSize+"px;word-wrap:break-word;}",
  1289. ".md-content h1,.md-content h2,.md-content h3,.md-content h4,.md-content h5,.md-content h6{font-weight:700;line-height:1.3;margin:1.4em 0 .6em;}",
  1290. ".md-content h1{font-size:2em;border-bottom:1px solid "+sep+";padding-bottom:.25em;}",
  1291. ".md-content h2{font-size:1.6em;border-bottom:1px solid "+sep+";padding-bottom:.2em;}",
  1292. ".md-content h3{font-size:1.3em;} .md-content h4{font-size:1.1em;} .md-content h6{color:"+text2+";}",
  1293. ".md-content p{margin:.55em 0;} .md-content a{color:"+accent+";text-decoration:none;}",
  1294. ".md-content ul,.md-content ol{margin:.5em 0;padding-left:1.7em;} .md-content li{margin:.25em 0;}",
  1295. ".md-content blockquote{margin:.8em 0;padding:.2em 1em;color:"+text2+";border-left:3px solid "+quote+";border-radius:0 6px 6px 0;}",
  1296. ".md-content code{font-family:'SF Mono',Consolas,monospace;font-size:.88em;background:"+code+";padding:.15em .4em;border-radius:4px;}",
  1297. ".md-content pre{margin:.8em 0;padding:14px 16px;background:"+code+";border-radius:8px;overflow-x:auto;}",
  1298. ".md-content pre code{background:none;padding:0;} .md-content hr{border:none;border-top:1px solid "+sep+";margin:1.6em 0;}",
  1299. ".md-content img{max-width:100%;border-radius:6px;}",
  1300. ".md-content table{border-collapse:collapse;margin:.8em 0;} .md-content th,.md-content td{border:1px solid "+tbl+";padding:6px 12px;} .md-content th{background:"+code+";}"
  1301. ].join("\n");
  1302. }
  1303. function getCss(v){ return getComputedStyle(document.body).getPropertyValue(v).trim(); }
  1304. // fetch a same-origin image and return a data URL (for self-contained export)
  1305. function inlineImage(url){
  1306. return new Promise(function(resolve){
  1307. var xhr = new XMLHttpRequest();
  1308. xhr.open("GET", url, true); xhr.responseType = "blob";
  1309. xhr.onload = function(){
  1310. if (xhr.status === 200){
  1311. var fr = new FileReader();
  1312. fr.onloadend = function(){ resolve(fr.result); };
  1313. fr.onerror = function(){ resolve(url); };
  1314. fr.readAsDataURL(xhr.response);
  1315. } else resolve(url);
  1316. };
  1317. xhr.onerror = function(){ resolve(url); };
  1318. xhr.send();
  1319. });
  1320. }
  1321. // build an offscreen DOM with all images inlined as data URLs
  1322. function buildExportContainer(){
  1323. var div = document.createElement("div");
  1324. div.className = "md-content";
  1325. div.innerHTML = renderedHTML();
  1326. // resolve relative srcs to media URLs first
  1327. $(div).find("img").each(function(){
  1328. var rel = this.getAttribute("data-rel");
  1329. var src = this.getAttribute("src") || "";
  1330. if (rel) this.setAttribute("src", mediaURLFor(rel));
  1331. else if (!/^(https?:|data:)/i.test(src) && filepath) this.setAttribute("src", mediaURLFor(src));
  1332. });
  1333. var imgs = Array.prototype.slice.call(div.querySelectorAll("img"));
  1334. return Promise.all(imgs.map(function(im){
  1335. return inlineImage(im.getAttribute("src")).then(function(d){ im.setAttribute("src", d); });
  1336. })).then(function(){ return div; });
  1337. }
  1338. function downloadBlob(blob, name){
  1339. var a = document.createElement("a");
  1340. a.href = URL.createObjectURL(blob);
  1341. a.download = name;
  1342. document.body.appendChild(a); a.click();
  1343. setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); }, 1000);
  1344. }
  1345. function exportHTML(){
  1346. showBusy("Building HTML…");
  1347. buildExportContainer().then(function(div){
  1348. var title = escapeHtml(docName());
  1349. var doc = "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>" +
  1350. title + "</title>\n<style>\n" + exportCSS() + "\n</style></head>\n<body>\n" +
  1351. div.outerHTML + "\n</body></html>";
  1352. downloadBlob(new Blob([doc], { type:"text/html" }), docName() + ".html");
  1353. hideBusy(); setStatus("Exported HTML");
  1354. }).catch(function(err){ hideBusy(); setStatus("HTML export failed", "error"); console.error(err); });
  1355. }
  1356. // ── Download the raw document (.md / .txt), zipping it with its images ─────
  1357. function collectDocImages(){
  1358. var seen = {}, out = [];
  1359. function add(p){
  1360. if (!p) return;
  1361. p = p.replace(/^\.?\/+/, "");
  1362. if (/^(https?:|data:)/i.test(p) || p.indexOf("media?file=") >= 0) return;
  1363. if (!seen[p]){ seen[p] = 1; out.push(p); }
  1364. }
  1365. if (isTxtMode){
  1366. // capture both bare and angle-bracket-wrapped destinations (the latter
  1367. // is used when a path contains spaces)
  1368. var re = /!\[[^\]]*\]\(\s*(?:<([^>]+)>|([^)\s]+))/g, m;
  1369. while ((m = re.exec(plain.value))) add(m[1] || m[2]);
  1370. } else {
  1371. $(rich).find("img").each(function(){
  1372. add(this.getAttribute("data-rel") || this.getAttribute("src"));
  1373. });
  1374. }
  1375. return out;
  1376. }
  1377. function exportDocument(){
  1378. var content = getContent();
  1379. var ext = isTxtMode ? "txt" : "md";
  1380. var name = docName() + "." + ext;
  1381. var imgs = collectDocImages();
  1382. if (imgs.length === 0){
  1383. downloadBlob(new Blob([content], { type: isTxtMode ? "text/plain" : "text/markdown" }), name);
  1384. setStatus("Downloaded " + name);
  1385. return;
  1386. }
  1387. if (!filepath){ alert("Please save the document first so its images can be bundled."); return; }
  1388. showBusy("Bundling document…");
  1389. ao_module_agirun("Text/export.agi", {
  1390. action:"zip", docpath:filepath, name:name, content:content, images:JSON.stringify(imgs)
  1391. }, function(data){
  1392. if (!data || data.error){ hideBusy(); setStatus("Export failed: " + ((data && data.error) || "unknown"), "error"); return; }
  1393. var url = ao_root + "media/download/?file=" + encodeURIComponent(data.zip);
  1394. fetch(url).then(function(r){ return r.blob(); }).then(function(blob){
  1395. downloadBlob(blob, docName() + ".zip");
  1396. hideBusy(); setStatus("Downloaded " + docName() + ".zip");
  1397. // best-effort temp cleanup
  1398. ao_module_agirun("Text/export.agi", { action:"cleanup", zip:data.zip, workdir:data.workdir }, function(){}, function(){});
  1399. }).catch(function(){ hideBusy(); setStatus("Export download failed", "error"); });
  1400. }, function(){ hideBusy(); setStatus("Export failed", "error"); });
  1401. }
  1402. // ════════════════════════════════════════════════════════════════════════
  1403. // Text-based PDF export (pdf-lib): real selectable text + embedded images.
  1404. // Mirrors the Productivity PDF Editor — standard fonts for encodable text,
  1405. // rasterise fallback for glyphs the standard fonts can't encode (e.g. CJK).
  1406. // ════════════════════════════════════════════════════════════════════════
  1407. function exportPDF(){
  1408. showBusy("Rendering PDF…");
  1409. pdfBuild().then(function(bytes){
  1410. downloadBlob(new Blob([bytes], { type:"application/pdf" }), docName() + ".pdf");
  1411. hideBusy(); setStatus("Exported PDF");
  1412. }).catch(function(err){ hideBusy(); setStatus("PDF export failed", "error"); console.error(err); });
  1413. }
  1414. var _measureCtx = null;
  1415. function measureCtx(){ if (!_measureCtx) _measureCtx = document.createElement("canvas").getContext("2d"); return _measureCtx; }
  1416. function PDF_COLORS(){
  1417. var rgb = PDFLib.rgb;
  1418. return {
  1419. text: rgb(0.16, 0.18, 0.22),
  1420. head: rgb(0.10, 0.12, 0.16),
  1421. accent: rgb(0.16, 0.40, 0.82),
  1422. muted: rgb(0.45, 0.49, 0.57),
  1423. code: rgb(0.20, 0.22, 0.30),
  1424. codebg: rgb(0.96, 0.97, 0.99),
  1425. rule: rgb(0.85, 0.87, 0.91),
  1426. quote: rgb(0.74, 0.78, 0.85),
  1427. tline: rgb(0.80, 0.83, 0.88),
  1428. thead: rgb(0.95, 0.96, 0.98)
  1429. };
  1430. }
  1431. function assignStyle(a, b){ var o = {}; for (var k in a) o[k] = a[k]; for (var k in b) o[k] = b[k]; return o; }
  1432. function mkRun(text, s){ s = s || {}; return { text: text, bold:!!s.bold, italic:!!s.italic, mono:!!s.mono, code:!!s.code, link:!!s.link, strike:!!s.strike }; }
  1433. function decodeEntities(s){ var ta = document.createElement("textarea"); ta.innerHTML = s || ""; return ta.value; }
  1434. function stripTags(s){ return (s || "").replace(/<[^>]*>/g, ""); }
  1435. // flatten marked inline tokens into styled runs
  1436. function inlineRuns(tokens, style){
  1437. style = style || {};
  1438. var out = [];
  1439. (tokens || []).forEach(function(t){
  1440. switch (t.type){
  1441. case "text":
  1442. if (t.tokens && t.tokens.length) out = out.concat(inlineRuns(t.tokens, style));
  1443. else out.push(mkRun(decodeEntities(t.text), style));
  1444. break;
  1445. case "strong": out = out.concat(inlineRuns(t.tokens, assignStyle(style, { bold:true }))); break;
  1446. case "em": out = out.concat(inlineRuns(t.tokens, assignStyle(style, { italic:true }))); break;
  1447. case "del": out = out.concat(inlineRuns(t.tokens, assignStyle(style, { strike:true }))); break;
  1448. case "codespan": out.push(mkRun(decodeEntities(t.text), assignStyle(style, { mono:true, code:true }))); break;
  1449. case "link": out = out.concat(inlineRuns(t.tokens, assignStyle(style, { link:true }))); break;
  1450. case "image": out.push({ image:true, href:t.href, alt:t.text || "" }); break;
  1451. case "br": out.push({ brk:true }); break;
  1452. case "escape": out.push(mkRun(t.text, style)); break;
  1453. case "html": out.push(mkRun(stripTags(t.text), style)); break;
  1454. default: if (t.text) out.push(mkRun(decodeEntities(t.text), style)); break;
  1455. }
  1456. });
  1457. return out;
  1458. }
  1459. function pdfPickFont(P, run){
  1460. if (run.mono) return run.bold ? P.fonts.monoBold : P.fonts.mono;
  1461. if (run.bold && run.italic) return P.fonts.bi;
  1462. if (run.bold) return P.fonts.bold;
  1463. if (run.italic) return P.fonts.ital;
  1464. return P.fonts.reg;
  1465. }
  1466. function pdfNewCursor(P){
  1467. var c = { pageW:595.28, pageH:841.89, margin:56, y:0, page:null };
  1468. c.page = P.pdf.addPage([c.pageW, c.pageH]);
  1469. c.y = c.pageH - c.margin;
  1470. return c;
  1471. }
  1472. function pdfEnsure(P, h){
  1473. var c = P.cur;
  1474. if (c.y - h < c.margin){ c.page = P.pdf.addPage([c.pageW, c.pageH]); c.y = c.pageH - c.margin; }
  1475. }
  1476. function pdfRule(P){
  1477. var c = P.cur;
  1478. c.page.drawLine({ start:{x:c.margin, y:c.y}, end:{x:c.pageW - c.margin, y:c.y}, thickness:0.7, color:P.col.rule });
  1479. }
  1480. function pdfColToCss(col){
  1481. if (!col) return "#2a2e38";
  1482. return "rgb(" + Math.round((col.red||0)*255) + "," + Math.round((col.green||0)*255) + "," + Math.round((col.blue||0)*255) + ")";
  1483. }
  1484. // split text for wrapping: whole words, runs of spaces, and individual CJK chars
  1485. function tokenizeWrap(text){
  1486. return (text || "").match(/[ -〿぀-ヿ㐀-䶿一-鿿豈-﫿＀-￯가-힯]|\s+|[^\s -〿぀-ヿ㐀-䶿一-鿿豈-﫿＀-￯가-힯]+/g) || [];
  1487. }
  1488. function canvasToEmbeddedPng(pdf, canvas){
  1489. return new Promise(function(res, rej){
  1490. canvas.toBlob(function(b){
  1491. if (!b){ rej(new Error("canvas")); return; }
  1492. var fr = new FileReader();
  1493. fr.onload = function(){ pdf.embedPng(new Uint8Array(fr.result)).then(res, rej); };
  1494. fr.onerror = rej;
  1495. fr.readAsArrayBuffer(b);
  1496. }, "image/png");
  1497. });
  1498. }
  1499. // draw one word as real text; on encoding failure, rasterise it and place as image
  1500. function pdfDrawWord(P, font, word, size, x, color, strike){
  1501. var c = P.cur;
  1502. try {
  1503. var w = font.widthOfTextAtSize(word, size);
  1504. c.page.drawText(word, { x:x, y:c.y - size*0.80, size:size, font:font, color:color });
  1505. if (strike) c.page.drawLine({ start:{x:x, y:c.y - size*0.45}, end:{x:x + w, y:c.y - size*0.45}, thickness:0.6, color:color });
  1506. return Promise.resolve(w);
  1507. } catch (e){
  1508. return pdfDrawRasterWord(P, word, size, x, color);
  1509. }
  1510. }
  1511. function pdfDrawRasterWord(P, word, size, x, color){
  1512. var c = P.cur, k = 3;
  1513. var ctx = measureCtx(); ctx.font = size + "px sans-serif";
  1514. var wPt = Math.max(1, ctx.measureText(word).width), hPt = size * 1.2;
  1515. var cv = document.createElement("canvas");
  1516. cv.width = Math.ceil(wPt * k); cv.height = Math.ceil(hPt * k);
  1517. var cx = cv.getContext("2d");
  1518. cx.scale(k, k); cx.fillStyle = pdfColToCss(color); cx.textBaseline = "alphabetic"; cx.font = size + "px sans-serif";
  1519. cx.fillText(word, 0, size * 0.92);
  1520. return canvasToEmbeddedPng(P.pdf, cv).then(function(png){
  1521. c.page.drawImage(png, { x:x, y:c.y - size, width:wPt, height:hPt });
  1522. return wPt;
  1523. });
  1524. }
  1525. // flow styled runs with word wrapping; returns a promise (images/raster are async)
  1526. function pdfFlow(P, runs, startX, fontSize, lineH, baseColor){
  1527. var c = P.cur, maxX = c.pageW - c.margin, x = startX;
  1528. pdfEnsure(P, lineH);
  1529. function nl(){ c.y -= lineH; x = startX; pdfEnsure(P, lineH); }
  1530. var i = 0, words = [], wi = 0, run = null, font = null, size = 0, color = null;
  1531. function nextRun(){
  1532. if (i >= runs.length) return null;
  1533. return runs[i++];
  1534. }
  1535. function step(){
  1536. // advance through words of the current run
  1537. while (run && wi < words.length){
  1538. var word = words[wi++];
  1539. if (/^\s+$/.test(word)){ if (x === startX) continue; word = " "; }
  1540. var ww;
  1541. try { ww = font.widthOfTextAtSize(word, size); }
  1542. catch (e){ var ctx = measureCtx(); ctx.font = size + "px sans-serif"; ww = ctx.measureText(word).width; }
  1543. if (word !== " " && x + ww > maxX && x > startX) nl();
  1544. var px = x;
  1545. x += ww;
  1546. return pdfDrawWord(P, font, word, size, px, color, run.strike).then(function(realW){
  1547. x = px + realW; return step();
  1548. });
  1549. }
  1550. // current run done — get next
  1551. run = nextRun();
  1552. if (!run){ c.y -= lineH; return Promise.resolve(); } // consume final line
  1553. if (run.brk){ nl(); return step(); }
  1554. if (run.image){
  1555. if (x > startX){ c.y -= lineH; x = startX; }
  1556. return pdfDrawImage(P, run.href, startX).then(step);
  1557. }
  1558. font = pdfPickFont(P, run);
  1559. size = run.mono ? fontSize * 0.94 : fontSize;
  1560. color = run.link ? P.col.accent : (run.code ? P.col.code : baseColor);
  1561. words = tokenizeWrap(run.text); wi = 0;
  1562. return step();
  1563. }
  1564. return step();
  1565. }
  1566. function fetchBytes(url){
  1567. return fetch(url).then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); })
  1568. .then(function(b){ return new Uint8Array(b); });
  1569. }
  1570. function pdfResolveSrc(href){
  1571. if (/^(https?:|data:)/i.test(href)) return href;
  1572. return filepath ? mediaURLFor(href) : href;
  1573. }
  1574. function pdfEmbedBytes(pdf, bytes){
  1575. if (bytes[0] === 0x89 && bytes[1] === 0x50) return pdf.embedPng(bytes);
  1576. if (bytes[0] === 0xFF && bytes[1] === 0xD8) return pdf.embedJpg(bytes);
  1577. return new Promise(function(res, rej){ // gif/webp/svg/bmp → rasterise
  1578. var im = new Image();
  1579. im.onload = function(){
  1580. var cv = document.createElement("canvas");
  1581. cv.width = im.naturalWidth || 300; cv.height = im.naturalHeight || 150;
  1582. cv.getContext("2d").drawImage(im, 0, 0);
  1583. canvasToEmbeddedPng(pdf, cv).then(res, rej);
  1584. };
  1585. im.onerror = function(){ rej(new Error("image decode")); };
  1586. im.src = URL.createObjectURL(new Blob([bytes]));
  1587. });
  1588. }
  1589. function pdfDrawImage(P, href, startX){
  1590. var c = P.cur;
  1591. return fetchBytes(pdfResolveSrc(href)).then(function(bytes){ return pdfEmbedBytes(P.pdf, bytes); }).then(function(img){
  1592. var natW = img.width, natH = img.height;
  1593. var dispW = Math.min(natW, c.pageW - c.margin - startX), dispH = natH * (dispW / natW);
  1594. var maxH = c.pageH - 2 * c.margin;
  1595. if (dispH > maxH){ dispH = maxH; dispW = natW * (dispH / natH); }
  1596. pdfEnsure(P, dispH + 8);
  1597. c.page.drawImage(img, { x:startX, y:c.y - dispH, width:dispW, height:dispH });
  1598. c.y -= dispH + 8;
  1599. }).catch(function(){
  1600. c.page.drawText("[image]", { x:startX, y:c.y - 11, size:11, font:P.fonts.ital, color:P.col.muted });
  1601. c.y -= 18;
  1602. });
  1603. }
  1604. function pdfHeading(P, token){
  1605. var c = P.cur;
  1606. var sizes = [23, 19, 16, 14, 12.5, 11.5], fs = sizes[(token.depth || 1) - 1] || 12;
  1607. c.y -= fs * 0.8;
  1608. return pdfFlow(P, inlineRuns(token.tokens || [{ type:"text", text:token.text }], { bold:true }), c.margin, fs, fs * 1.32, P.col.head)
  1609. .then(function(){
  1610. if ((token.depth || 1) <= 2){ c.y += 4; pdfEnsure(P, 6); pdfRule(P); c.y -= 8; }
  1611. c.y -= fs * 0.25;
  1612. });
  1613. }
  1614. function pdfParagraph(P, token){
  1615. var c = P.cur; c.y -= 3;
  1616. return pdfFlow(P, inlineRuns(token.tokens || [{ type:"text", text:token.text }], {}), c.margin, 11, 16.5, P.col.text)
  1617. .then(function(){ c.y -= 4; });
  1618. }
  1619. function pdfHr(P){ var c = P.cur; c.y -= 8; pdfEnsure(P, 8); pdfRule(P); c.y -= 10; }
  1620. function pdfList(P, token, depth){
  1621. var c = P.cur, indent = c.margin + depth * 18, idx = token.start || 1;
  1622. var chain = Promise.resolve();
  1623. token.items.forEach(function(item){
  1624. var n = idx++;
  1625. chain = chain.then(function(){
  1626. pdfEnsure(P, 11 * 1.5);
  1627. if (item.task){
  1628. c.page.drawRectangle({ x:indent, y:c.y - 10, width:9, height:9, borderWidth:0.9, borderColor:P.col.muted, color: item.checked ? P.col.accent : undefined });
  1629. } else if (token.ordered){
  1630. try { c.page.drawText(n + ".", { x:indent, y:c.y - 11*0.80, size:11, font:P.fonts.reg, color:P.col.text }); } catch(e){}
  1631. } else {
  1632. c.page.drawCircle({ x:indent + 3, y:c.y - 7, size:1.7, color:P.col.text });
  1633. }
  1634. return pdfListItem(P, item, indent + 16, depth);
  1635. });
  1636. });
  1637. return chain.then(function(){ c.y -= 3; });
  1638. }
  1639. function pdfListItem(P, item, startX, depth){
  1640. var toks = item.tokens || [];
  1641. if (toks.length === 0) return pdfFlow(P, inlineRuns([{ type:"text", text:item.text || "" }], {}), startX, 11, 16.5, P.col.text);
  1642. var chain = Promise.resolve();
  1643. toks.forEach(function(t){
  1644. chain = chain.then(function(){
  1645. if (t.type === "list") return pdfList(P, t, depth + 1);
  1646. if (t.type === "code") return pdfCode(P, t, startX);
  1647. if (t.type === "blockquote") return pdfBlockquote(P, t);
  1648. var rr = inlineRuns(t.tokens || [{ type:"text", text:t.text || "" }], {});
  1649. return pdfFlow(P, rr, startX, 11, 16.5, P.col.text);
  1650. });
  1651. });
  1652. return chain;
  1653. }
  1654. function pdfBlockquote(P, token){
  1655. var c = P.cur; c.y -= 3;
  1656. var startY = c.y, startPage = c.page, x = c.margin + 16;
  1657. var chain = Promise.resolve();
  1658. (token.tokens || []).forEach(function(t){
  1659. chain = chain.then(function(){
  1660. if (t.type === "list") return pdfList(P, t, 1);
  1661. if (t.type === "code") return pdfCode(P, t, x);
  1662. var rr = inlineRuns(t.tokens || [{ type:"text", text:t.text || "" }], {});
  1663. return pdfFlow(P, rr, x, 11, 16.5, P.col.muted);
  1664. });
  1665. });
  1666. return chain.then(function(){
  1667. if (c.page === startPage) c.page.drawRectangle({ x:c.margin + 4, y:c.y + 6, width:2.5, height:Math.max(2, startY - c.y), color:P.col.quote });
  1668. c.y -= 4;
  1669. });
  1670. }
  1671. function pdfCode(P, token, startX){
  1672. var c = P.cur; startX = startX || c.margin;
  1673. var size = 9.5, lh = 12.5, pad = 8, font = P.fonts.mono;
  1674. var charW = font.widthOfTextAtSize("m", size);
  1675. var maxChars = Math.max(8, Math.floor((c.pageW - c.margin - startX - 2 * pad) / charW));
  1676. var lines = [];
  1677. (token.text || "").replace(/\n$/, "").split("\n").forEach(function(ln){
  1678. if (ln.length <= maxChars) lines.push(ln);
  1679. else for (var i = 0; i < ln.length; i += maxChars) lines.push(ln.substr(i, maxChars));
  1680. });
  1681. if (lines.length === 0) lines = [""];
  1682. c.y -= 4;
  1683. var chain = Promise.resolve();
  1684. lines.forEach(function(txt){
  1685. chain = chain.then(function(){
  1686. pdfEnsure(P, lh);
  1687. c.page.drawRectangle({ x:startX, y:c.y - lh + 2, width:c.pageW - c.margin - startX, height:lh, color:P.col.codebg });
  1688. try { c.page.drawText(txt, { x:startX + pad, y:c.y - lh + 3.5, size:size, font:font, color:P.col.code }); return; }
  1689. catch (e){ return pdfDrawRasterWord(P, txt, size, startX + pad, P.col.code).then(function(){}); }
  1690. }).then(function(){ c.y -= lh; });
  1691. });
  1692. return chain.then(function(){ c.y -= 6; });
  1693. }
  1694. function pdfTable(P, token){
  1695. var c = P.cur; c.y -= 4;
  1696. var cols = (token.header && token.header.length) || 1;
  1697. var colW = (c.pageW - 2 * c.margin) / cols, size = 10, pad = 5, rowH = 18;
  1698. function fit(font, txt, maxW){
  1699. try { if (font.widthOfTextAtSize(txt, size) <= maxW) return txt; } catch (e){ return txt; }
  1700. var s = txt;
  1701. while (s.length > 1){ s = s.slice(0, -1); try { if (font.widthOfTextAtSize(s + "…", size) <= maxW) return s + "…"; } catch (e){ break; } }
  1702. return s;
  1703. }
  1704. function row(cells, header){
  1705. pdfEnsure(P, rowH);
  1706. var x = c.margin;
  1707. for (var i = 0; i < cols; i++){
  1708. var cell = cells[i] || { text:"" };
  1709. if (header) c.page.drawRectangle({ x:x, y:c.y - rowH, width:colW, height:rowH, color:P.col.thead });
  1710. c.page.drawRectangle({ x:x, y:c.y - rowH, width:colW, height:rowH, borderWidth:0.6, borderColor:P.col.tline });
  1711. var f = header ? P.fonts.bold : P.fonts.reg;
  1712. var txt = fit(f, decodeEntities(cell.text || ""), colW - 2 * pad);
  1713. try { c.page.drawText(txt, { x:x + pad, y:c.y - rowH + 6, size:size, font:f, color:P.col.text }); } catch (e){}
  1714. x += colW;
  1715. }
  1716. c.y -= rowH;
  1717. }
  1718. row(token.header, true);
  1719. (token.rows || []).forEach(function(r){ row(r, false); });
  1720. c.y -= 6;
  1721. }
  1722. function pdfRenderTokens(P, tokens){
  1723. var chain = Promise.resolve();
  1724. tokens.forEach(function(t){
  1725. chain = chain.then(function(){
  1726. switch (t.type){
  1727. case "heading": return pdfHeading(P, t);
  1728. case "paragraph": return pdfParagraph(P, t);
  1729. case "list": return pdfList(P, t, 0);
  1730. case "blockquote": return pdfBlockquote(P, t);
  1731. case "code": return pdfCode(P, t, P.cur.margin);
  1732. case "hr": pdfHr(P); return;
  1733. case "table": return pdfTable(P, t);
  1734. case "space": P.cur.y -= 6; return;
  1735. case "html": return;
  1736. default: if (t.tokens) return pdfParagraph(P, t); if (t.text) return pdfParagraph(P, { text:t.text }); return;
  1737. }
  1738. });
  1739. });
  1740. return chain;
  1741. }
  1742. function pdfBuild(){
  1743. var L = PDFLib, SF = L.StandardFonts, pdf;
  1744. return L.PDFDocument.create().then(function(d){
  1745. pdf = d;
  1746. return Promise.all([
  1747. pdf.embedFont(SF.Helvetica), pdf.embedFont(SF.HelveticaBold),
  1748. pdf.embedFont(SF.HelveticaOblique), pdf.embedFont(SF.HelveticaBoldOblique),
  1749. pdf.embedFont(SF.Courier), pdf.embedFont(SF.CourierBold)
  1750. ]);
  1751. }).then(function(f){
  1752. var P = { pdf:pdf, col:PDF_COLORS(), fonts:{
  1753. reg:f[0], bold:f[1], ital:f[2], bi:f[3], mono:f[4], monoBold:f[5]
  1754. }, cur:null };
  1755. P.cur = pdfNewCursor(P);
  1756. if (isTxtMode){
  1757. var chain = Promise.resolve();
  1758. plain.value.split("\n").forEach(function(ln){
  1759. chain = chain.then(function(){ return pdfFlow(P, [mkRun(ln || " ", { mono:true })], P.cur.margin, 10, 13.5, P.col.text); });
  1760. });
  1761. return chain.then(function(){ return pdf.save(); });
  1762. }
  1763. return pdfRenderTokens(P, marked.lexer(getContent())).then(function(){ return pdf.save(); });
  1764. });
  1765. }
  1766. // ════════════════════════════════════════════════════════════════════════
  1767. // Settings panel
  1768. // ════════════════════════════════════════════════════════════════════════
  1769. function openSettings(){ syncSettingsUI(); $("#settings-overlay").addClass("show"); }
  1770. function closeSettings(){ $("#settings-overlay").removeClass("show"); }
  1771. $(function(){
  1772. $("#settings-overlay").on("click", function(e){ if (e.target === this) closeSettings(); });
  1773. });
  1774. function showSection(sec){
  1775. $(".nav-item").removeClass("active");
  1776. $('.nav-item[data-sec="'+sec+'"]').addClass("active");
  1777. $(".settings-section").removeClass("active");
  1778. $("#sec-" + sec).addClass("active");
  1779. }
  1780. function syncSettingsUI(){
  1781. $("#set-dark").prop("checked", isDark);
  1782. $("#set-font").val(settings.font);
  1783. $("#set-fontsize").val(settings.fontSize);
  1784. $("#set-lh").val(settings.lineHeight);
  1785. $("#set-imgdir").val(settings.imgDir);
  1786. $("#set-compress").prop("checked", settings.compress);
  1787. $("#set-quality").val(settings.quality);
  1788. $("#quality-val").text(settings.quality + "%");
  1789. $("#set-maxw").val(settings.maxWidth);
  1790. $("#seg-mode button").removeClass("active");
  1791. $('#seg-mode button[data-mode="'+(isTxtMode?"txt":"md")+'"]').addClass("active");
  1792. onCompressToggle(true);
  1793. }
  1794. function applyTypography(){
  1795. settings.font = $("#set-font").val() || settings.font;
  1796. settings.fontSize = parseInt($("#set-fontsize").val()) || settings.fontSize;
  1797. settings.lineHeight = $("#set-lh").val() || settings.lineHeight;
  1798. var fam = FONT_MAP[settings.font] || FONT_MAP.system;
  1799. [rich, plain].forEach(function(el){
  1800. el.style.fontFamily = (el === plain ? FONT_MAP.mono : fam);
  1801. el.style.fontSize = (el === plain ? (settings.fontSize - 1) : settings.fontSize) + "px";
  1802. el.style.lineHeight = settings.lineHeight;
  1803. });
  1804. savePrefs();
  1805. }
  1806. function saveImgPrefs(){
  1807. settings.imgDir = $("#set-imgdir").val().trim() || "img/{name}";
  1808. settings.quality = parseInt($("#set-quality").val()) || 80;
  1809. settings.maxWidth = parseInt($("#set-maxw").val()); if (isNaN(settings.maxWidth)) settings.maxWidth = 0;
  1810. settings.compress = $("#set-compress").prop("checked");
  1811. savePrefs();
  1812. }
  1813. function onCompressToggle(silent){
  1814. settings.compress = $("#set-compress").prop("checked");
  1815. var on = settings.compress;
  1816. $("#row-quality").css("display", on ? "" : "none");
  1817. $("#row-maxw").css("display", on ? "" : "none");
  1818. if (!silent) saveImgPrefs();
  1819. }
  1820. function onQualityInput(){
  1821. $("#quality-val").text($("#set-quality").val() + "%");
  1822. }
  1823. // ════════════════════════════════════════════════════════════════════════
  1824. // Theme
  1825. // ════════════════════════════════════════════════════════════════════════
  1826. function setTheme(dark){
  1827. isDark = dark;
  1828. document.body.classList.toggle("dark", isDark);
  1829. $("#icon-sun").toggle(!isDark); $("#icon-moon").toggle(isDark);
  1830. $("#set-dark").prop("checked", isDark);
  1831. try { ao_module_setWindowTheme(isDark ? "dark" : "light"); } catch(e){}
  1832. savePrefs();
  1833. }
  1834. function toggleTheme(){ setTheme(!isDark); }
  1835. // ════════════════════════════════════════════════════════════════════════
  1836. // Preferences persistence (localStorage + ao_module_storage)
  1837. // ════════════════════════════════════════════════════════════════════════
  1838. function prefsObject(){
  1839. return { settings: settings, keymap: keymap, isDark: isDark };
  1840. }
  1841. function savePrefs(){
  1842. var json = JSON.stringify(prefsObject());
  1843. try { localStorage.setItem("text_prefs", json); } catch(e){}
  1844. try { ao_module_storage.setStorage("Text", "prefs", json); } catch(e){}
  1845. }
  1846. function applyPrefs(obj){
  1847. if (!obj) return;
  1848. if (obj.settings) for (var k in obj.settings) settings[k] = obj.settings[k];
  1849. if (obj.keymap){
  1850. keymap = JSON.parse(JSON.stringify(DEFAULT_KEYS));
  1851. for (var a in obj.keymap) if (a in keymap) keymap[a] = obj.keymap[a];
  1852. }
  1853. if (typeof obj.isDark === "boolean") setTheme(obj.isDark);
  1854. }
  1855. function loadPrefs(){
  1856. var local = null;
  1857. try { local = JSON.parse(localStorage.getItem("text_prefs")); } catch(e){}
  1858. if (local) applyPrefs(local);
  1859. // async server copy (per-user) overrides local if present
  1860. try {
  1861. ao_module_storage.loadStorage("Text", "prefs", function(val){
  1862. if (val && typeof val === "string" && val.charAt(0) === "{"){
  1863. try {
  1864. var obj = JSON.parse(val);
  1865. applyPrefs(obj);
  1866. buildKeyList(); syncSettingsUI(); applyTypography();
  1867. } catch(e){}
  1868. }
  1869. });
  1870. } catch(e){}
  1871. }
  1872. // ════════════════════════════════════════════════════════════════════════
  1873. // Status bar, title, dirty tracking
  1874. // ════════════════════════════════════════════════════════════════════════
  1875. function markDirty(){ if (!dirtyFlag){ dirtyFlag = true; updateTitle(); } }
  1876. function isDirty(){ return dirtyFlag; }
  1877. function updateTitle(){
  1878. var title = !filepath ? "Untitled" : (isDirty() ? filename + " — Edited" : filename);
  1879. try { ao_module_setWindowTitle(title); } catch(e){}
  1880. document.title = title;
  1881. if (isDirty()) setStatus("Unsaved changes", "dirty");
  1882. else if (filepath) setStatus("Saved");
  1883. else setStatus("Ready");
  1884. }
  1885. function setStatus(msg, cls){
  1886. var el = $("#status-msg");
  1887. el.text(msg).attr("class", cls || "");
  1888. if (cls === "error") setTimeout(updateTitle, 3500);
  1889. }
  1890. function updateStatBar(){
  1891. var txt = isTxtMode ? plain.value : rich.textContent.replace(/​/g, "");
  1892. var words = txt.trim() ? txt.trim().split(/\s+/).length : 0;
  1893. var chars = txt.length;
  1894. $("#stat-count").text(words + " word" + (words !== 1 ? "s" : "") + " · " + chars + " chars");
  1895. }
  1896. // ════════════════════════════════════════════════════════════════════════
  1897. // Menus / busy / dialog
  1898. // ════════════════════════════════════════════════════════════════════════
  1899. function toggleMenu(id){
  1900. var m = document.getElementById(id);
  1901. var show = !m.classList.contains("show");
  1902. hideMenus();
  1903. if (show){ m.classList.add("show"); positionMenu(m); }
  1904. }
  1905. // position a fixed dropdown just below its trigger, right-aligned, so it is not
  1906. // clipped by the toolbar's horizontal-scroll overflow
  1907. function positionMenu(m){
  1908. var btn = m.parentElement.querySelector(".tb-btn");
  1909. if (!btn) return;
  1910. var r = btn.getBoundingClientRect();
  1911. m.style.top = (r.bottom + 4) + "px";
  1912. m.style.left = "auto";
  1913. m.style.right = Math.max(6, window.innerWidth - r.right) + "px";
  1914. }
  1915. function hideMenus(){ $(".menu").removeClass("show"); }
  1916. function showBusy(msg){ $("#busy-msg").text(msg || "Working…"); $("#busy").addClass("show"); }
  1917. function hideBusy(){ $("#busy").removeClass("show"); }
  1918. // ── helpers ─────────────────────────────────────────────────────────────
  1919. function escapeHtml(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
  1920. function escapeAttr(s){ return escapeHtml(s).replace(/"/g,"&quot;"); }
  1921. function debounce(fn, ms){ var t; return function(){ clearTimeout(t); var a=arguments,c=this; t=setTimeout(function(){ fn.apply(c,a); }, ms); }; }
  1922. // ════════════════════════════════════════════════════════════════════════
  1923. // Close handling (unsaved changes)
  1924. // ════════════════════════════════════════════════════════════════════════
  1925. function showDialog(cb){ dlgCallback = cb; $("#confirm-overlay").addClass("show"); }
  1926. function hideDialog(){ $("#confirm-overlay").removeClass("show"); }
  1927. function dlgCancel(){ var cb=dlgCallback; hideDialog(); dlgCallback=null; if(cb) cb("cancel"); }
  1928. function dlgDiscard(){ var cb=dlgCallback; hideDialog(); dlgCallback=null; if(cb) cb("discard"); }
  1929. function dlgSave(){
  1930. pendingClose = dlgCallback; dlgCallback = null; hideDialog();
  1931. if (!filepath){
  1932. var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
  1933. ao_module_openFileSelector(handleDlgSaveAs, "user:/Desktop", "new", false, { defaultName: def });
  1934. } else {
  1935. doSave(function(){ if (pendingClose){ pendingClose("saved"); pendingClose=null; } });
  1936. }
  1937. }
  1938. function handleDlgSaveAs(fd){
  1939. if (!fd || !fd.length){ pendingClose = null; return; }
  1940. filepath = fd[0].filepath; filename = fd[0].filename;
  1941. doSave(function(){ if (pendingClose){ pendingClose("saved"); pendingClose=null; } });
  1942. }
  1943. function ao_module_close(){
  1944. if (!isDirty()){ ao_module_closeHandler(); return; }
  1945. showDialog(function(result){
  1946. if (result === "saved" || result === "discard") ao_module_closeHandler();
  1947. });
  1948. }
  1949. if (!ao_module_virtualDesktop){
  1950. window.onbeforeunload = function(){ if (isDirty()) return "You have unsaved changes. Leave anyway?"; };
  1951. }
  1952. </script>
  1953. </body>
  1954. </html>