瀏覽代碼

Added advanced code block function to Text

Toby Chui 1 天之前
父節點
當前提交
ad024f4196
共有 4 個文件被更改,包括 483 次插入49 次删除
  1. 35 24
      src/mod/media/mediaserver/mediaserver.go
  2. 20 4
      src/web/Productivity/tools/choplib.html
  3. 162 0
      src/web/Text/highlighter.js
  4. 266 21
      src/web/Text/index.html

+ 35 - 24
src/mod/media/mediaserver/mediaserver.go

@@ -188,6 +188,9 @@ func (s *Instance) ServerMedia(w http.ResponseWriter, r *http.Request) {
 		downloadMode = true
 	}
 
+	// Check if nocache mode
+	nocacheMode, _ := utils.GetBool(r, "nocache")
+
 	//New download implementations, allow /download to be used instead of &download=true
 	if strings.Contains(r.RequestURI, "media/download/?file=") {
 		downloadMode = true
@@ -234,6 +237,12 @@ func (s *Instance) ServerMedia(w http.ResponseWriter, r *http.Request) {
 		}
 
 	} else {
+		if nocacheMode {
+			// Add no-cache headers to prevent browser caching, useful for development and testing
+			w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
+			w.Header().Set("Pragma", "no-cache")
+			w.Header().Set("Expires", "0")
+		}
 		if targetFsh.RequireBuffer {
 			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
 			//Check buffer exists
@@ -319,7 +328,8 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 		startTime, _ = strconv.ParseFloat(startTimeStr, 64)
 	}
 
-	targetFshAbs := targetFsh.FileSystemAbstraction
+	//TODO: Cleanup unused code
+	//targetFshAbs := targetFsh.FileSystemAbstraction
 	transcodeSourceFile := realFilepath
 	if filesystem.FileExists(transcodeSourceFile) {
 		//This is a file from the local file system.
@@ -374,32 +384,33 @@ func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Reques
 
 	//Check if it is a remote file system. FFmpeg can only works with local files
 	//if the file is from a remote source, buffer it to local before transcoding.
-	if targetFsh.RequireBuffer {
-		w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
+	/*
+		if targetFsh.RequireBuffer {
+			w.Header().Set("Content-Length", strconv.Itoa(int(targetFshAbs.GetFileSize(realFilepath))))
 
-		remoteStream, err := targetFshAbs.ReadStream(realFilepath)
-		if err != nil {
-			utils.SendErrorResponse(w, err.Error())
-			return
-		}
-		defer remoteStream.Close()
-		io.Copy(w, remoteStream)
+			remoteStream, err := targetFshAbs.ReadStream(realFilepath)
+			if err != nil {
+				utils.SendErrorResponse(w, err.Error())
+				return
+			}
+			defer remoteStream.Close()
+			io.Copy(w, remoteStream)
 
-	} else if !filesystem.FileExists(realFilepath) {
-		//Streaming from remote file system that support fseek
-		f, err := targetFsh.FileSystemAbstraction.Open(realFilepath)
-		if err != nil {
-			w.WriteHeader(http.StatusInternalServerError)
-			w.Write([]byte("500 - Internal Server Error"))
-			return
+		} else if !filesystem.FileExists(realFilepath) {
+			//Streaming from remote file system that support fseek
+			f, err := targetFsh.FileSystemAbstraction.Open(realFilepath)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				w.Write([]byte("500 - Internal Server Error"))
+				return
+			}
+			fstat, _ := f.Stat()
+			defer f.Close()
+			http.ServeContent(w, r, filepath.Base(realFilepath), fstat.ModTime(), f)
+		} else {
+			http.ServeFile(w, r, realFilepath)
 		}
-		fstat, _ := f.Stat()
-		defer f.Close()
-		http.ServeContent(w, r, filepath.Base(realFilepath), fstat.ModTime(), f)
-	} else {
-		http.ServeFile(w, r, realFilepath)
-	}
-
+	*/
 }
 
 func (s *Instance) BufferRemoteFileToTmp(buffFile string, fsh *filesystem.FileSystemHandler, rpath string) error {

+ 20 - 4
src/web/Productivity/tools/choplib.html

@@ -56,19 +56,35 @@
     /* ── Shared chop store (defined once, reused by the PDF Editor) ── */
     if (!window.ProductivityChops) {
         window.ProductivityChops = (function(){
-            var DIR = 'user:/Productivity Chops/';
+            var DIR = 'user:/.appdata/Productivity/Chops/';
             var MOD = 'Productivity', KEY = 'chops';
 
+            // In-memory cache is the source of truth once loaded. ao_module_storage.setStorage()
+            // fires an async write and returns immediately without confirming completion, so
+            // reading the value straight back from the server after a save() can race the write
+            // and return stale data. Caching avoids that round trip entirely.
+            var cache = null;
+            var pending = [];
+            var fetching = false;
+
             function load(cb){
+                if (cache !== null){ cb(cache.slice()); return; }
+                pending.push(cb);
+                if (fetching) return;
+                fetching = true;
                 ao_module_storage.loadStorage(MOD, KEY, function(raw){
                     var arr = [];
                     if (raw){ try { arr = JSON.parse(raw) || []; } catch(e){ arr = []; } }
-                    cb(arr);
+                    cache = arr;
+                    fetching = false;
+                    var queued = pending; pending = [];
+                    queued.forEach(function(fn){ fn(cache.slice()); });
                 });
             }
             function save(arr, cb){
-                ao_module_storage.setStorage(MOD, KEY, JSON.stringify(arr));
-                if (cb) cb(arr);
+                cache = arr.slice();
+                ao_module_storage.setStorage(MOD, KEY, JSON.stringify(cache));
+                if (cb) cb(cache.slice());
             }
             function add(srcPath, name, onDone, onErr){
                 fetch(mediaUrl(srcPath)).then(function(r){

+ 162 - 0
src/web/Text/highlighter.js

@@ -0,0 +1,162 @@
+/*
+    Text - lightweight, dependency-free syntax highlighter
+
+    A small generic tokenizer used to colour fenced code blocks in the editor
+    (```c, ```go, …). It is intentionally compact and self-contained — no remote
+    CDN, no heavy library — and keyword-table driven so new languages are a one
+    line addition. It is NOT a full parser; it colours comments, strings,
+    numbers, keywords, types and call-like identifiers, which covers the vast
+    majority of real-world snippets.
+
+    Output is HTML with <span class="hl-*"> wrappers (hl-com / hl-str / hl-num /
+    hl-kw / hl-typ / hl-fn); the editor styles those classes per theme.
+
+    Exposed as window.TextHL = { highlight(code, lang), supports(lang) }.
+*/
+(function (global) {
+    "use strict";
+
+    function esc(s) {
+        return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+    }
+    function wrap(cls, txt) { return '<span class="' + cls + '">' + esc(txt) + "</span>"; }
+    function toSet(arr) { var o = {}; (arr || []).forEach(function (k) { o[k] = 1; }); return o; }
+    function words(s) { return s.trim().split(/\s+/); }
+
+    // ── language registry ───────────────────────────────────────────────────
+    var LANG = {};
+    // cfg: { line, block:[open,close], strings:[…], hash:"pre"|"comment", ci:bool }
+    function def(names, cfg, kw, ty) {
+        var entry = {
+            cfg: cfg,
+            kw: toSet(cfg.ci ? words(kw).map(function (w) { return w.toLowerCase(); }) : words(kw)),
+            ty: toSet(words(ty || ""))
+        };
+        names.forEach(function (n) { LANG[n] = entry; });
+    }
+
+    var cLike  = { line: "//", block: ["/*", "*/"], strings: ['"', "'"] };
+    var cPre   = { line: "//", block: ["/*", "*/"], strings: ['"', "'"], hash: "pre" };
+    var jsLike = { line: "//", block: ["/*", "*/"], strings: ['"', "'", "`"] };
+    var hashLn = { line: null, block: null, strings: ['"', "'"], hash: "comment" };
+
+    def(["c"], cPre,
+        "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while",
+        "bool size_t ssize_t int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_t FILE wchar_t va_list");
+    def(["cpp", "c++", "cc", "hpp", "cxx"], cPre,
+        "alignas alignof and auto bool break case catch char class compl const constexpr continue decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept not nullptr operator or private protected public register return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while",
+        "string vector map set unordered_map unordered_set pair size_t int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_t shared_ptr unique_ptr");
+    def(["go", "golang"], cLike,
+        "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var true false nil iota append cap close copy delete len make new panic print println recover",
+        "bool byte complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string uint uint8 uint16 uint32 uint64 uintptr any");
+    def(["js", "javascript", "jsx", "mjs", "node"], jsLike,
+        "async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return static super switch this throw try typeof var void while with yield true false null undefined NaN Infinity of as from get set",
+        "Array Object String Number Boolean Symbol Promise Map Set WeakMap WeakSet JSON Math Date RegExp Error console window document");
+    def(["ts", "typescript", "tsx"], jsLike,
+        "abstract any as asserts async await break case catch class const continue debugger declare default delete do else enum export extends finally for from function get if implements import in infer instanceof interface is keyof let namespace never new null number object of private protected public readonly return set static string super switch symbol this throw try type typeof undefined unique unknown var void while yield true false boolean",
+        "Array Object Promise Map Set Record Partial Readonly Pick Omit ReturnType");
+    def(["py", "python"], hashLn,
+        "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield True False None match case",
+        "self int float str bool list dict tuple set bytes object range print len range super Exception");
+    def(["java"], cLike,
+        "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try var void volatile while true false null record sealed yield",
+        "String Integer Long Double Float Boolean Object List Map Set ArrayList HashMap Optional Stream");
+    def(["rust", "rs"], cLike,
+        "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while box",
+        "i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String Vec Option Result Box Rc Arc HashMap HashSet Some None Ok Err");
+    def(["json"], { line: null, block: null, strings: ['"'] },
+        "true false null", "");
+    def(["sql", "mysql", "psql", "postgres"], { line: "--", block: ["/*", "*/"], strings: ["'", '"'], ci: true },
+        "select from where insert into values update set delete create table drop alter add column join left right inner outer full on group by order having limit offset union all distinct as and or not null is in like between exists case when then else end primary key foreign references default index unique constraint cascade view trigger procedure function begin commit rollback",
+        "int integer bigint smallint varchar char text date datetime timestamp boolean decimal numeric float double serial uuid json jsonb");
+    def(["bash", "sh", "shell", "zsh"], hashLn,
+        "if then else elif fi case esac for while until do done in function select return break continue local export readonly declare source eval exec trap set unset shift",
+        "echo cd ls cat grep sed awk printf read test mkdir rm cp mv touch chmod chown kill pwd");
+    def(["php"], { line: "//", block: ["/*", "*/"], strings: ['"', "'"], hash: "comment" },
+        "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list namespace new or print private protected public require require_once return static switch throw trait try unset use var while yield true false null",
+        "int float string bool array object void mixed self parent");
+    def(["cs", "csharp", "c#", "dotnet"], cLike,
+        "abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using var virtual void volatile while yield record",
+        "List Dictionary IEnumerable Task Action Func Console String Int32 Object Nullable");
+    def(["kotlin", "kt"], cLike,
+        "abstract actual annotation as break by catch class companion const constructor continue crossinline data delegate do dynamic else enum expect external false final finally for fun get if import in infix init inline inner interface internal is lateinit lazy noinline null object open operator out override package private protected public reified return sealed set super suspend tailrec this throw true try typealias typeof val var vararg when where while",
+        "Int Long Double Float Boolean String Char Any Unit List Map Set Array MutableList Pair");
+    def(["swift"], cLike,
+        "associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as catch false is nil super self Self throw throws true try weak lazy",
+        "Int Double Float Bool String Character Array Dictionary Set Optional Any AnyObject Void");
+    def(["ruby", "rb"], { line: "#", block: null, strings: ['"', "'"], hash: "comment" },
+        "alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield require require_relative attr_accessor attr_reader attr_writer puts print",
+        "Integer Float String Symbol Array Hash Object Proc Lambda Struct");
+
+    function supports(lang) { return !!LANG[(lang || "").toLowerCase()]; }
+
+    function highlight(code, lang) {
+        var L = LANG[(lang || "").toLowerCase()];
+        if (!L) return esc(code);
+        var cfg = L.cfg, kw = L.kw, ty = L.ty;
+        var i = 0, n = code.length, out = "", lineStart = true;
+
+        while (i < n) {
+            var ch = code[i];
+
+            // block comment
+            if (cfg.block && code.startsWith(cfg.block[0], i)) {
+                var be = code.indexOf(cfg.block[1], i + cfg.block[0].length);
+                be = be < 0 ? n : be + cfg.block[1].length;
+                out += wrap("hl-com", code.slice(i, be)); i = be; lineStart = false; continue;
+            }
+            // line comment
+            if (cfg.line && code.startsWith(cfg.line, i)) {
+                var le = code.indexOf("\n", i); le = le < 0 ? n : le;
+                out += wrap("hl-com", code.slice(i, le)); i = le; continue;
+            }
+            // # is a comment (python/bash/ruby/php) or a C preprocessor directive
+            if (ch === "#" && cfg.hash === "comment") {
+                var he = code.indexOf("\n", i); he = he < 0 ? n : he;
+                out += wrap("hl-com", code.slice(i, he)); i = he; continue;
+            }
+            if (ch === "#" && cfg.hash === "pre" && lineStart) {
+                var pm = /^#\s*[A-Za-z_]+/.exec(code.slice(i));
+                if (pm) { out += wrap("hl-kw", pm[0]); i += pm[0].length; lineStart = false; continue; }
+            }
+            // string
+            if (cfg.strings.indexOf(ch) >= 0) {
+                var j = i + 1;
+                while (j < n) {
+                    if (code[j] === "\\") { j += 2; continue; }
+                    if (code[j] === ch) { j++; break; }
+                    if (code[j] === "\n" && ch !== "`") { break; }   // unterminated on this line
+                    j++;
+                }
+                out += wrap("hl-str", code.slice(i, j)); i = j; lineStart = false; continue;
+            }
+            // number
+            if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(code[i + 1] || ""))) {
+                var k = i + 1;
+                while (k < n && /[0-9a-fA-FxXoObB._]/.test(code[k])) k++;
+                out += wrap("hl-num", code.slice(i, k)); i = k; lineStart = false; continue;
+            }
+            // identifier / keyword / type / call
+            if (/[A-Za-z_$@]/.test(ch)) {
+                var w = i + 1;
+                while (w < n && /[A-Za-z0-9_$]/.test(code[w])) w++;
+                var word = code.slice(i, w);
+                var look = cfg.ci ? word.toLowerCase() : word;
+                var p = w; while (p < n && (code[p] === " " || code[p] === "\t")) p++;
+                if (kw[look]) out += wrap("hl-kw", word);
+                else if (ty[word]) out += wrap("hl-typ", word);
+                else if (code[p] === "(") out += wrap("hl-fn", word);
+                else out += esc(word);
+                i = w; lineStart = false; continue;
+            }
+            // any other character
+            out += esc(ch);
+            if (ch === "\n") lineStart = true;
+            else if (ch !== " " && ch !== "\t") lineStart = false;
+            i++;
+        }
+        return out;
+    }
+
+    global.TextHL = { highlight: highlight, supports: supports };
+})(window);

+ 266 - 21
src/web/Text/index.html

@@ -10,6 +10,7 @@
     <script src="lib/turndown.min.js"></script>
     <script src="lib/turndown-plugin-gfm.min.js"></script>
     <script src="lib/pdf-lib.min.js"></script>
+    <script src="highlighter.js"></script>
     <style>
         *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
@@ -33,6 +34,12 @@
             --quote-bdr:    #d2d8e6;
             --table-bdr:    #dfe3ec;
             --shadow:       0 10px 40px rgba(30,40,70,.18);
+            --hl-com:       #6a9955;
+            --hl-str:       #b5402a;
+            --hl-num:       #1c7d4d;
+            --hl-kw:        #1f4fce;
+            --hl-typ:       #117a8b;
+            --hl-fn:        #8a5a18;
         }
         body.dark {
             --bg:           #0f131b;
@@ -52,6 +59,12 @@
             --quote-bdr:    #313c50;
             --table-bdr:    #2a3445;
             --shadow:       0 10px 40px rgba(0,0,0,.55);
+            --hl-com:       #6a9955;
+            --hl-str:       #ce9178;
+            --hl-num:       #b5cea8;
+            --hl-kw:        #569cd6;
+            --hl-typ:       #4ec9b0;
+            --hl-fn:        #dcdcaa;
         }
 
         html, body {
@@ -172,6 +185,14 @@
             border-radius: 8px; overflow-x: auto;
         }
         .md-content pre code { background: none; padding: 0; font-size: .85em; line-height: 1.55; }
+
+        /* syntax highlight tokens (see highlighter.js) — theme-aware via vars */
+        .hl-com { color: var(--hl-com); font-style: italic; }
+        .hl-str { color: var(--hl-str); }
+        .hl-num { color: var(--hl-num); }
+        .hl-kw  { color: var(--hl-kw); }
+        .hl-typ { color: var(--hl-typ); }
+        .hl-fn  { color: var(--hl-fn); }
         .md-content hr { border: none; border-top: 1px solid var(--sep); margin: 1.6em 0; }
         .md-content img { max-width: 100%; border-radius: 6px; vertical-align: middle; }
         .md-content table { border-collapse: collapse; margin: .8em 0; width: auto; max-width: 100%; }
@@ -193,7 +214,7 @@
         #rich h5.md-active::before { content: "##### ";  }
         #rich h6.md-active::before { content: "###### "; }
         #rich blockquote.md-active > *:first-child::before { content: "> "; }
-        #rich pre.md-active::before { content: "```\A"; white-space: pre; }
+        #rich pre.md-active::before { content: "```" attr(data-lang) "\A"; white-space: pre; }
         #rich pre.md-active::after  { content: "\A```"; white-space: pre; }
         #rich .md-active strong::before, #rich .md-active strong::after,
         #rich .md-active b::before,      #rich .md-active b::after      { content: "**"; }
@@ -203,6 +224,9 @@
         #rich .md-active s::before,      #rich .md-active s::after,
         #rich .md-active strike::before, #rich .md-active strike::after { content: "~~"; }
         #rich .md-active code::before, #rich .md-active code::after { content: "`"; }
+        /* a <code> inside a code block is fenced by the pre's ``` marks, so it
+           must NOT also get the inline single-backtick marks */
+        #rich pre.md-active code::before, #rich pre.md-active code::after { content: none; }
         #rich .md-active a::before { content: "["; }
         #rich .md-active a::after  { content: "](" attr(href) ")"; }
 
@@ -749,6 +773,18 @@ td.addRule("relimg", {
         return "![" + alt + "](" + mdLinkDest(src) + (ttl ? ' "'+ttl+'"' : "") + ")";
     }
 });
+// Plain markdown collapses blank lines, so an intentional empty line would be
+// lost on reload. Serialize empty paragraphs as an &nbsp; line, which marked
+// re-renders as an empty paragraph (normalised back to a clean blank line on
+// load by normalizeEmptyParas) — making blank lines survive the round-trip.
+td.addRule("emptyPara", {
+    filter:function(node){
+        return node.nodeName === "P"
+            && !node.querySelector("img,hr,table,pre,code")
+            && node.textContent.replace(/[\s ​]+/g, "") === "";
+    },
+    replacement:function(){ return "\n\n&nbsp;\n\n"; }
+});
 
 // A markdown link/image destination containing spaces or parentheses must be
 // wrapped in angle brackets, otherwise parsers (marked) treat it as plain text
@@ -798,7 +834,7 @@ $(function(){
             updateActiveBlock();
         }
     });
-    $(document).on("selectionchange", debounce(function(){ updateToolbarState(); updateActiveBlock(); }, 80));
+    $(document).on("selectionchange", debounce(function(){ updateToolbarState(); updateActiveBlock(); syncCodeHighlight(); }, 80));
 
     $("#plain").on("input", function(){ markDirty(); updateStatBar(); });
 
@@ -832,13 +868,21 @@ function isMarkdownExt(name){
 // File load / save
 // ════════════════════════════════════════════════════════════════════════
 function loadFile(){
-    $.get(ao_root + "media?file=" + encodeURIComponent(filepath) + "&t=" + Date.now(), function(data){
-        if (typeof data !== "string") data = JSON.stringify(data, null, 2);
-        setContent(data);
-        dirtyFlag = false;
-        updateTitle(); updateStatBar();
-    }, "text").fail(function(){
-        setStatus("Failed to load file", "error");
+    // strong cache-buster: unique nonce + jQuery cache:false so the browser
+    // always fetches the freshly-saved file, never a stale cached copy
+    var nonce = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
+    $.ajax({
+        url: ao_root + "media?file=" + encodeURIComponent(filepath) + "&nocache=" + nonce,
+        method: "GET",
+        dataType: "text",
+        cache: false,
+        success: function(data){
+            if (typeof data !== "string") data = JSON.stringify(data, null, 2);
+            setContent(data);
+            dirtyFlag = false;
+            updateTitle(); updateStatBar();
+        },
+        error: function(){ setStatus("Failed to load file", "error"); }
     });
 }
 
@@ -847,23 +891,76 @@ function setContent(text){
         plain.value = text;
     } else {
         rich.innerHTML = marked.parse(text || "");
+        normalizeEmptyParas(rich);
         rewriteImageSrcs(rich);
         refreshEmptyState();
         updateActiveBlock();
+        syncCodeHighlight();
+    }
+}
+
+// turn the &nbsp; placeholder paragraphs (see the emptyPara turndown rule) back
+// into clean empty lines so they edit naturally and round-trip stably
+function normalizeEmptyParas(root){
+    var ps = root.querySelectorAll("p");
+    for (var i = 0; i < ps.length; i++){
+        var p = ps[i];
+        if (!p.querySelector("img,hr,table,pre,code,br") &&
+            p.textContent.replace(/[\s ​]+/g, "") === ""){
+            p.innerHTML = "<br>";
+        }
     }
 }
 
 function getContent(){
     if (isTxtMode) return plain.value;
-    var src = rich;
-    if (rich.querySelector(".md-imgsyn")){          // drop editable image marks
-        src = rich.cloneNode(true);
-        var syns = src.querySelectorAll(".md-imgsyn");
-        for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
-    }
+    var src = rich.cloneNode(true);
+    var syns = src.querySelectorAll(".md-imgsyn");
+    for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
+    normalizeSerializableCodeBlocks(src);
     var html = src.innerHTML.replace(/​/g, "");
     var md = td.turndown(html);
-    return md.replace(/\n{3,}/g, "\n\n").replace(/^\s+|\s+$/g, "") + "\n";
+    return tidyMarkdown(md) + "\n";
+}
+
+function normalizeSerializableCodeBlocks(root){
+    var pres = root.querySelectorAll("pre");
+    for (var i = 0; i < pres.length; i++){
+        var pre = pres[i];
+        var codes = [];
+        for (var j = 0; j < pre.children.length; j++){
+            if (pre.children[j].tagName === "CODE") codes.push(pre.children[j]);
+        }
+        if (codes.length <= 1) continue;
+
+        var lang = pre.getAttribute("data-lang") || "";
+        var parts = [];
+        for (var k = 0; k < codes.length; k++){
+            var cls = codes[k].getAttribute("class") || "";
+            if (!lang){
+                var m = cls.match(/(?:^|\s)language-([A-Za-z0-9+#_-]+)/);
+                if (m) lang = m[1].toLowerCase();
+            }
+            parts.push(codes[k].textContent || "");
+        }
+
+        while (pre.firstChild) pre.removeChild(pre.firstChild);
+        var merged = document.createElement("code");
+        if (lang) merged.className = "language-" + lang;
+        merged.textContent = parts.join("").replace(/​/g, "");
+        pre.appendChild(merged);
+    }
+}
+
+// collapse runs of blank lines (turndown can emit extra) and trim the document
+// edges — but never touch the inside of a fenced code block, where blank lines
+// are significant content
+function tidyMarkdown(md){
+    var parts = md.split(/(```[\s\S]*?\n```)/g);
+    for (var i = 0; i < parts.length; i++){
+        if (i % 2 === 0) parts[i] = parts[i].replace(/\n{3,}/g, "\n\n");   // prose only
+    }
+    return parts.join("").replace(/^\s+|\s+$/g, "");
 }
 
 // rich.textContent minus the decoration marks (used for word count / empty state)
@@ -944,6 +1041,11 @@ function setMode(mode){
 function onRichInput(){
     var syn = activeImgSyn();
     if (syn){ syncImgFromSyn(syn); return; }   // editing an image mark, not prose
+    var code = activeCodeBlock();
+    if (code){                                  // typing code: stay plain, no autoformat
+        if (code.querySelector("*")) dehighlightCode(code);
+        markDirty(); updateStatBar(); return;
+    }
     markDirty();
     inlineAutoformat();
     refreshEmptyState();
@@ -970,6 +1072,8 @@ function onRichKeydown(e){
     // shallower / strips it. Backspace also unwraps a blockquote's "> " mark.
     if (e.key === "#" && editHeadingMark(1)){ e.preventDefault(); return; }
     if (e.key === "Backspace" && backspaceAtBlockStart()){ e.preventDefault(); return; }
+    if (e.key === "ArrowDown" && !e.shiftKey && exitCodeBlockDown()){ e.preventDefault(); return; }
+    if (e.key === "Enter" && activeCodeBlock()){ e.preventDefault(); insertNewlineInCode(); return; }
     if (e.key === " "){
         if (blockTransformOnSpace()) e.preventDefault();
     } else if (e.key === "Enter"){
@@ -1047,6 +1151,17 @@ function textBeforeCaret(block){
     return pre.toString();
 }
 
+// text in the current block from the caret to its end
+function textAfterCaret(block){
+    var sel = getSel();
+    if (!sel.rangeCount) return "";
+    var r = sel.getRangeAt(0);
+    var post = document.createRange();
+    post.selectNodeContents(block);
+    try { post.setStart(r.endContainer, r.endOffset); } catch(err){ return ""; }
+    return post.toString();
+}
+
 function blockTransformOnSpace(){
     var block = currentBlock();
     if (!block || block.tagName === "PRE") return false;
@@ -1092,8 +1207,9 @@ function blockTransformOnEnter(){
     var pre = textBeforeCaret(block).trim();
     var whole = block.textContent.trim();
 
-    if (block.tagName !== "PRE" && whole === "```"){
-        block.textContent = ""; insertCodeBlockAt(block); return true;
+    var fence = whole.match(/^```([A-Za-z0-9+#_-]*)$/);
+    if (block.tagName !== "PRE" && fence){
+        block.textContent = ""; insertCodeBlockAt(block, fence[1]); return true;
     }
     if (block.tagName !== "PRE" && (whole === "---" || whole === "***" || whole === "___")){
         var hr = document.createElement("hr");
@@ -1145,9 +1261,11 @@ function wrapBlockquote(block){
     placeCaretAtStart(p); markDirty();
 }
 
-function insertCodeBlockAt(block){
+function insertCodeBlockAt(block, lang){
     var pre = document.createElement("pre");
     var code = document.createElement("code");
+    if (lang){ code.className = "language-" + lang.toLowerCase(); }
+    pre.setAttribute("data-lang", lang ? lang.toLowerCase() : "");
     code.appendChild(document.createTextNode("​"));
     pre.appendChild(code);
     block.parentNode.replaceChild(pre, block);
@@ -1157,6 +1275,120 @@ function insertCodeBlockAt(block){
     markDirty();
 }
 
+// pressing Down on the last line of a code block leaves it instead of wrapping
+// back to the top: move to the following block, creating a paragraph if needed
+function exitCodeBlockDown(){
+    var block = currentBlock();
+    if (!block || block.tagName !== "PRE") return false;
+    if (textAfterCaret(block).replace(/​/g, "").indexOf("\n") >= 0) return false; // more lines below
+    var next = block.nextElementSibling;
+    if (next){
+        placeCaretAtStart(next);
+    } else {
+        var p = document.createElement("p");
+        p.appendChild(document.createElement("br"));
+        block.parentNode.appendChild(p);
+        placeCaretAtStart(p); markDirty();
+    }
+    return true;
+}
+
+// ── Code-block syntax highlighting ──────────────────────────────────────
+// Highlighted code uses <span class="hl-*"> wrappers, which fight the caret
+// while typing — so a block is kept as PLAIN text while the caret is inside it
+// and (re)highlighted only when the caret leaves. syncCodeHighlight() runs on
+// selection change and after content loads to keep every block in the right
+// state. Serialization is unaffected: turndown reads the code's full textContent
+// plus its language-xxx class regardless of the inner spans.
+function codeLang(code){
+    var m = (code.getAttribute("class") || "").match(/language-([A-Za-z0-9+#_-]+)/);
+    return m ? m[1].toLowerCase() : "";
+}
+function activeCodeBlock(){
+    var sel = getSel();
+    var n = sel && sel.anchorNode;
+    if (!n) return null;
+    if (n.nodeType === 3) n = n.parentNode;
+    var pre = (n && n.closest) ? n.closest("pre") : null;
+    if (!pre || !rich.contains(pre)) return null;
+    return pre.querySelector("code");
+}
+function syncPreLang(code){
+    var pre = code.parentNode;
+    if (pre && pre.tagName === "PRE") pre.setAttribute("data-lang", codeLang(code));
+}
+// character offset of the caret within an element (stable across highlighting,
+// since the spans add no text)
+function caretOffsetIn(el){
+    var sel = getSel();
+    if (!sel.rangeCount) return null;
+    var r = sel.getRangeAt(0);
+    if (!el.contains(r.startContainer)) return null;
+    var pre = document.createRange();
+    pre.selectNodeContents(el);
+    try { pre.setEnd(r.startContainer, r.startOffset); } catch(e){ return null; }
+    return pre.toString().length;
+}
+function restoreCaretIn(el, offset){
+    if (offset == null) return;
+    var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
+    var node, count = 0;
+    while ((node = walker.nextNode())){
+        var len = node.data.length;
+        if (count + len >= offset){
+            var r = document.createRange();
+            r.setStart(node, Math.max(0, offset - count)); r.collapse(true);
+            var s = getSel(); s.removeAllRanges(); s.addRange(r);
+            return;
+        }
+        count += len;
+    }
+    var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false);
+    var s2 = getSel(); s2.removeAllRanges(); s2.addRange(r2);
+}
+function highlightCode(code){
+    if (code.querySelector("*")) { syncPreLang(code); return; }   // already highlighted
+    var lang = codeLang(code);
+    var text = code.textContent.replace(/​/g, "");
+    if (!lang || typeof TextHL === "undefined" || !TextHL.supports(lang) || text.trim() === ""){
+        syncPreLang(code); return;                                // unknown / empty → leave plain
+    }
+    code.innerHTML = TextHL.highlight(text, lang);
+    syncPreLang(code);
+}
+function dehighlightCode(code){
+    if (code.querySelector("*")){                                 // strip spans → plain text
+        var off = caretOffsetIn(code);
+        code.textContent = code.textContent.replace(/​/g, "");
+        if (off != null) restoreCaretIn(code, off);
+    }
+    syncPreLang(code);
+}
+function syncCodeHighlight(){
+    if (isTxtMode) return;
+    var active = activeCodeBlock();
+    var codes = rich.querySelectorAll("pre > code");
+    for (var i = 0; i < codes.length; i++){
+        if (codes[i] === active) dehighlightCode(codes[i]);
+        else highlightCode(codes[i]);
+    }
+}
+// Enter inside a code block inserts a real newline (not a <div>/<br>), so the
+// text stays line-accurate for highlighting and serialization
+function insertNewlineInCode(){
+    var sel = getSel();
+    if (!sel.rangeCount) return false;
+    var r = sel.getRangeAt(0); r.deleteContents();
+    var pre = currentBlock();
+    var atEnd = pre && textAfterCaret(pre).replace(/​/g, "") === "";
+    var tn = document.createTextNode(atEnd ? "\n​" : "\n");   // pad a trailing line so it shows
+    r.insertNode(tn);
+    r.setStart(tn, 1); r.collapse(true);
+    sel.removeAllRanges(); sel.addRange(r);
+    markDirty();
+    return true;
+}
+
 function placeCaretAtStart(el){
     var r = document.createRange();
     r.setStart(el, 0); r.collapse(true);
@@ -1332,7 +1564,7 @@ function updateActiveBlock(){
     clearDecorations();
     if (settings.syntax === "none") return;             // Always hide
     if (settings.syntax === "always"){
-        var all = rich.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,blockquote");
+        var all = rich.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,blockquote,pre");
         for (var j = 0; j < all.length; j++) decorateBlock(all[j]);
         return;
     }
@@ -1688,11 +1920,23 @@ function exportCSS(){
         ".md-content pre{margin:.8em 0;padding:14px 16px;background:"+code+";border-radius:8px;overflow-x:auto;}",
         ".md-content pre code{background:none;padding:0;} .md-content hr{border:none;border-top:1px solid "+sep+";margin:1.6em 0;}",
         ".md-content img{max-width:100%;border-radius:6px;}",
-        ".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+";}"
+        ".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+";}",
+        ".hl-com{color:"+getCss("--hl-com")+";font-style:italic;} .hl-str{color:"+getCss("--hl-str")+";} .hl-num{color:"+getCss("--hl-num")+";}",
+        ".hl-kw{color:"+getCss("--hl-kw")+";} .hl-typ{color:"+getCss("--hl-typ")+";} .hl-fn{color:"+getCss("--hl-fn")+";}"
     ].join("\n");
 }
 function getCss(v){ return getComputedStyle(document.body).getPropertyValue(v).trim(); }
 
+// apply syntax highlighting to an export's fenced code blocks
+function highlightExportCode(root){
+    if (typeof TextHL === "undefined") return;
+    var codes = root.querySelectorAll("pre > code");
+    for (var i = 0; i < codes.length; i++){
+        var lang = codeLang(codes[i]);
+        if (lang && TextHL.supports(lang)) codes[i].innerHTML = TextHL.highlight(codes[i].textContent, lang);
+    }
+}
+
 // fetch a same-origin image and return a data URL (for self-contained export)
 function inlineImage(url){
     return new Promise(function(resolve){
@@ -1716,6 +1960,7 @@ function buildExportContainer(){
     var div = document.createElement("div");
     div.className = "md-content";
     div.innerHTML = renderedHTML();
+    highlightExportCode(div);           // colour fenced code so exports match the screen
     // resolve relative srcs to media URLs first
     $(div).find("img").each(function(){
         var rel = this.getAttribute("data-rel");