From ed2ac4d7ea2e09f977a9f412a1e4b4d45ad8de41 Mon Sep 17 00:00:00 2001 From: Phani Date: Wed, 25 Mar 2026 00:50:01 +0530 Subject: [PATCH] feat: update styles and add utility script for version tagging - Changed body position to sticky for better header behavior. - Removed redundant syntax highlighting styles and replaced with a dark theme variable. - Removed mobile tabs section styles to streamline the CSS. - Updated mermaid diagram styles for better responsiveness and usability. - Introduced a new script `tag.sh` for calculating the next version tag based on Calendar Versioning. - Added a .gitignore file to exclude Salesforce DX files. --- .github/workflows/desktop-build.yml | 16 +- .github/workflows/docker-publish.yml | 75 +- .gitignore | 1 + desktop-app/.dockerignore | 1 + desktop-app/.gitignore | 19 +- desktop-app/Dockerfile | 2 +- desktop-app/README.md | 35 +- desktop-app/neutralino.config.json | 6 +- desktop-app/package.json | 22 +- desktop-app/prepare.js | 23 +- desktop-app/resources/js/main.js | 65 +- desktop-app/tag.sh | 48 + index.html | 162 +- script.js | 2329 +++++++++----------------- styles.css | 849 +--------- 15 files changed, 1125 insertions(+), 2528 deletions(-) create mode 100644 .gitignore create mode 100644 desktop-app/tag.sh diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 0a3a28e..ab31660 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -4,13 +4,14 @@ on: push: tags: - "desktop-v*" + workflow_dispatch: permissions: contents: write jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout repository @@ -21,13 +22,21 @@ jobs: with: node-version: "lts/*" + - name: Inject version from tag + working-directory: desktop-app + run: | + VERSION="${GITHUB_REF_NAME#desktop-v}" + jq --arg v "$VERSION" '.version = $v' neutralino.config.json > tmp.json \ + && mv tmp.json neutralino.config.json + echo "::notice::Building version $VERSION" + - name: Setup Neutralinojs binaries working-directory: desktop-app run: npm run setup - name: Build all binaries (embedded + portable) working-directory: desktop-app - run: npm run build:all + run: npm run build - name: Stage release assets working-directory: desktop-app @@ -63,6 +72,7 @@ jobs: --exclude='desktop-app/bin' \ --exclude='desktop-app/node_modules' \ --exclude='desktop-app/output' \ + --exclude="desktop-app/$STAGING" \ --exclude='.git' \ desktop-app/ cd desktop-app @@ -74,6 +84,6 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - name: "Markdown Viewer Desktop ${{ github.ref_name }}" + name: "${{ github.ref_name }}" generate_release_notes: true files: desktop-app/release-assets/* diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6a5177a..a6d8f22 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,9 +2,13 @@ name: Build and Push Docker Image on: push: - branches: [ main ] + branches: [main] + paths-ignore: + - "desktop-app/**" pull_request: - branches: [ main ] + branches: [main] + paths-ignore: + - "desktop-app/**" env: REGISTRY: ghcr.io @@ -18,37 +22,36 @@ jobs: packages: write steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=sha,prefix=sha- - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d866157 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.sfdx/ diff --git a/desktop-app/.dockerignore b/desktop-app/.dockerignore index 28f2c2a..6be7bc7 100644 --- a/desktop-app/.dockerignore +++ b/desktop-app/.dockerignore @@ -1,5 +1,6 @@ # Build-generated resources resources/js/script.js +resources/js/neutralino* resources/styles.css resources/assets/ resources/index.html diff --git a/desktop-app/.gitignore b/desktop-app/.gitignore index 4c77b9d..9fc6d7b 100644 --- a/desktop-app/.gitignore +++ b/desktop-app/.gitignore @@ -5,17 +5,16 @@ node_modules/ .lite_workspace.lua # Neutralinojs binaries and builds -/bin -/dist - -# Neutralinojs client (minified) -neutralino.js +bin/ +dist/ # Build-generated resources (copied from root by prepare.js) -/resources/js/script.js -/resources/styles.css -/resources/assets/ -/resources/index.html +resources/js/script.js +resources/js/neutralino* +resources/styles.css +resources/assets/ +resources/index.html + # Neutralinojs related files .storage @@ -25,4 +24,4 @@ neutralino.js .tmp # Docker build output -/output \ No newline at end of file +output/ \ No newline at end of file diff --git a/desktop-app/Dockerfile b/desktop-app/Dockerfile index 9c5f12d..1f613b1 100644 --- a/desktop-app/Dockerfile +++ b/desktop-app/Dockerfile @@ -13,7 +13,7 @@ COPY . . WORKDIR /app/desktop-app # Setup (download binaries + prepare resources) and build all variants -RUN npm run build:all +RUN npm run build # Final stage: Export the dist artifacts FROM alpine:latest diff --git a/desktop-app/README.md b/desktop-app/README.md index 210d881..a925842 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -11,9 +11,8 @@ Neutralinojs platform binaries are managed by `setup-binaries.js`, which downloa Desktop-only files (not generated): - `resources/js/main.js` — Neutralinojs lifecycle, tray menu, window events -- `resources/js/neutralino.js` — Neutralinojs client library - `neutralino.config.json` — App configuration -- `setup-binaries.js` — Idempotent binary setup (downloads on first use) +- `setup-binaries.js` — Idempotent binary setup (downloads on first use or updates if `cli.binaryVersion` changes) ## Development @@ -45,7 +44,7 @@ For more information, see the [Neutralinojs documentation](https://neutralino.js ### Building the app -**Default** — Single-file executables with embedded resources: +**Default** — Single-file executables with embedded resources + release ZIP bundle with separate `resources.neu` file: ```bash npm run build @@ -57,10 +56,10 @@ npm run build npm run build:portable ``` -**Both** — Build embedded + portable in one step: +**Embedded** — Single-file executables with embedded resources: ```bash -npm run build:all +npm run build:embedded ``` Build output is placed in `dist/`. @@ -79,7 +78,31 @@ Build artifacts will be output to `desktop-app/output/`. ## Releases -Prebuilt binaries are automatically built and published as GitHub Releases when a tag matching `desktop-v*` is pushed (e.g., `desktop-v1.0.0`). See [`.github/workflows/desktop-build.yml`](../.github/workflows/desktop-build.yml). +Prebuilt binaries are automatically built and published as GitHub Releases when a tag matching `desktop-v*` is pushed (e.g., `desktop-v2026.2.0`). See [`.github/workflows/desktop-build.yml`](../.github/workflows/desktop-build.yml). + +### Versioning + +The Git tag is the **single source of truth** for the release version, using CalVer (Calendar Versioning) format `desktop-vYYYY.M.P`; + +- `YYYY` = Year +- `M` = Month +- `P` = Patch (Defaults to 0, bumped if new release occurs same month) + +The CI workflow extracts the version from the tag (e.g., `desktop-v2026.2.0` → `2026.2.0`) and injects it into `neutralino.config.json` at build time. `package.json` carries a placeholder version (`0.0.0-dev`) since this is *not* an npm package. + +To create a release, you can use the utility script `tag.sh` to calculate the next [lightweight tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging): + +```bash +./tag.sh # Calculates the next tag based on the current date, latest tag, and commit SHA +``` + +or run the following commands, replacing `` with the desired version (e.g., `2026.2.1`): + +```bash +git tag desktop-v && git push origin desktop-v +``` + +### Release assets Each release includes: diff --git a/desktop-app/neutralino.config.json b/desktop-app/neutralino.config.json index cc55a4e..a2c7fca 100644 --- a/desktop-app/neutralino.config.json +++ b/desktop-app/neutralino.config.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json", - "applicationId": "js.neutralino.sample", - "version": "1.0.0", + "applicationId": "js.markdownviewer.desktop", + "version": "2026.2.0", "defaultMode": "window", "port": 0, "documentRoot": "/resources/", @@ -13,7 +13,7 @@ "enabled": true, "writeToLogFile": true }, - "nativeAllowList": ["app.*", "os.*", "filesystem.readFile", "debug.log"], + "nativeAllowList": ["app.*", "os.*", "debug.log"], "globalVariables": {}, "modes": { "window": { diff --git a/desktop-app/package.json b/desktop-app/package.json index b02da6d..6447a2d 100644 --- a/desktop-app/package.json +++ b/desktop-app/package.json @@ -1,17 +1,29 @@ { "name": "markdown-viewer-desktop", - "version": "1.0.0", + "author": "ramezio", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ramezio/markdown-viewer-fork.git" + }, + "contributors": [ + "ramezio", + "JBroeren", + "ThisIs-Developer" + ], + "version": "0.0.0-dev", "private": true, - "description": "Neutralinojs desktop port of Markdown Viewer", + "description": "Neutralinojs desktop port of Markdown Viewer (https://github.com/ThisIs-Developer/markdown-viewer)", "scripts": { "setup": "node setup-binaries.js", "postsetup": "node prepare.js", + "clean": "npx -y rimraf bin dist node_modules .tmp .neutralinojs.log resources/js/script.js resources/styles.css resources/assets resources/index.html resources/js/neutralino.js resources/js/neutralino.d.ts", "predev": "npm run setup", "dev": "npx -y @neutralinojs/neu@11.7.0 run", "prebuild": "npm run setup", - "build": "npx -y @neutralinojs/neu@11.7.0 build --embed-resources", - "build:portable": "npx -y @neutralinojs/neu@11.7.0 build --release", - "build:all": "npm run build && npm run build:portable" + "build": "npx -y @neutralinojs/neu@11.7.0 build --embed-resources --release", + "build:portable": "npm run setup && npx -y @neutralinojs/neu@11.7.0 build --release", + "build:embedded": "npm run setup && npx -y @neutralinojs/neu@11.7.0 build --embed-resources" }, "dependencies": {} } diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index 2e03333..f709069 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -64,20 +64,39 @@ let html = fs.readFileSync(path.join(ROOT_DIR, "index.html"), "utf-8"); html = html.replace(/href="assets\//g, 'href="/assets/'); html = html.replace(/href="styles\.css"/g, 'href="/styles.css"'); /** Replace root script.js tag with neutralino.js + main.js + script.js under /js/ */ +const originalHtml = html; +const scriptTagRegex = /<\/script>/; + +if (!scriptTagRegex.test(html)) { + console.error("✗ Could not find root script.js tag in index.html"); + process.exit(1); +} + html = html.replace( - /\n \n ', ); /** Inject Neutralinojs app-info element after .app-container */ +const appContainerMarker = '
'; +if (!html.includes(appContainerMarker)) { + console.error("✗ Could not find app container marker in index.html"); + process.exit(1); +} + html = html.replace( - '
', + appContainerMarker, `
`, ); +if (html === originalHtml) { + console.error("✗ No prepare.js transformations were applied"); + process.exit(1); +} + fs.writeFileSync(path.join(RESOURCES_DIR, "index.html"), html, "utf-8"); console.log( "✓ Generated resources/index.html (Neutralinojs injections applied)", diff --git a/desktop-app/resources/js/main.js b/desktop-app/resources/js/main.js index 70773eb..7e11bc7 100644 --- a/desktop-app/resources/js/main.js +++ b/desktop-app/resources/js/main.js @@ -1,36 +1,3 @@ -// This is just a sample app. You can structure your Neutralinojs app code as you wish. -// This example app is written with vanilla JavaScript and HTML. -// Feel free to use any frontend framework you like :) -// See more details: https://neutralino.js.org/docs/how-to/use-a-frontend-library - -/* - Function to display information about the Neutralino app. - This function updates the content of the 'info' element in the HTML - with details regarding the running Neutralino application, including - its ID, port, operating system, and version information. -*/ -function showInfo() { - return ` - ${NL_APPID} is running on port ${NL_PORT} inside ${NL_OS} -

- server: v${NL_VERSION} . client: v${NL_CVERSION} - `; -} - -/* - Function to open the official Neutralino documentation in the default web browser. -*/ -function openDocs() { - Neutralino.os.open("https://neutralino.js.org/docs"); -} - -/* - Function to open a tutorial video on Neutralino's official YouTube channel in the default web browser. -*/ -function openTutorial() { - Neutralino.os.open("https://www.youtube.com/c/CodeZri"); -} - /* Function to set up a system tray menu with options specific to the window mode. This function checks if the application is running in window mode, and if so, @@ -45,7 +12,7 @@ function setTray() { // Define tray menu items let tray = { - icon: "/resources/icons/trayIcon.png", + icon: "/resources/assets/icon.jpg", menuItems: [ { id: "VERSION", text: "Get version" }, { id: "SEP", text: "-" }, @@ -68,7 +35,7 @@ function onTrayMenuItemClicked(event) { // Display version information Neutralino.os.showMessageBox( "Version information", - `Neutralinojs server: v${NL_VERSION} | Neutralinojs client: v${NL_CVERSION}`, + `Neutralinojs server: v${NL_VERSION}\nNeutralinojs client: v${NL_CVERSION}\nOS Name: ${NL_OS}\nArchitecture: ${NL_ARCH}\nApplication ID: ${NL_APPID}\nApplication Version: ${NL_APPVERSION}\nPort: ${NL_PORT}\nMode: ${NL_MODE}\nNeutralinojs server: v${NL_VERSION}\nNeutralinojs client: v${NL_CVERSION}\nCurrent working directory: ${NL_CWD}\nApplication path: ${NL_PATH}\nApplication data path: ${NL_DATAPATH}\nCommand-line arguments: ${NL_ARGS}\nProcess ID: ${NL_PID}\nResource mode: ${NL_RESMODE}\nExtensions enabled: ${NL_EXTENABLED}\nFramework binary's release commit hash: ${NL_COMMIT}\nClient library's release commit hash: ${NL_CCOMMIT}\nCustom method identifiers: ${NL_CMETHODS}\nInitial window state was loaded from the saved configuration: ${NL_WSAVSTLOADED}\nUser System Locale: ${NL_LOCALE}\nData passed during the framework binary compilation via the NEU_COMPILATION_DATA definition in the BuildZri configuration file: ${NL_COMPDATA}`, ); break; case "QUIT": @@ -97,31 +64,3 @@ if (NL_OS != "Darwin") { // TODO: Fix https://github.com/neutralinojs/neutralinojs/issues/615 setTray(); } - -// Open file passed as command-line argument (e.g. when double-clicking a .md file) -(async function loadInitialFile() { - const args = Array.isArray(NL_ARGS) ? NL_ARGS : (() => { try { return JSON.parse(NL_ARGS); } catch(e) { return []; } })(); - const filePath = args.find(a => typeof a === 'string' && /\.(md|markdown)$/i.test(a)); - if (!filePath) return; - - try { - const content = await Neutralino.filesystem.readFile(filePath); - - function applyContent() { - const editor = document.getElementById('markdown-editor'); - const dropzone = document.getElementById('dropzone'); - if (!editor) return; - editor.value = content; - editor.dispatchEvent(new Event('input')); - if (dropzone) dropzone.style.display = 'none'; - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyContent); - } else { - setTimeout(applyContent, 0); - } - } catch (e) { - console.warn('Could not open initial file:', e); - } -})(); diff --git a/desktop-app/tag.sh b/desktop-app/tag.sh new file mode 100644 index 0000000..c8ea173 --- /dev/null +++ b/desktop-app/tag.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +echo "" +echo "@tag.sh - Utility script to calculate the next tag for the desktop app" +echo "---" + +DEFAULT_VERSION="$(date +"%Y.%-m.0")" +DEFAULT_TAG_NAME="desktop-v$DEFAULT_VERSION" + +# Get the latest tag for the current branch and prune deleted tags +TAG_NAME=$(git fetch --tags --prune --prune-tags && git tag -l --contains HEAD | tail -n1) + +# If no tag is found, create one using CalVer (Calendar Versioning) +if [ -z "$TAG_NAME" ]; then + echo "[WARNING] No tag found, creating one using CalVer (Calendar Versioning)" + # Use CalVer (Calendar Versioning) + # format: YYYY.M.P + # YYYY = Year, M = Month, P = Patch (Defaults to 0 if not specified) + # Example: 2026.2.0 + TAG_NAME="$DEFAULT_TAG_NAME" + +else # If a tag is found, determine the next tag + # Remove "desktop-v" prefix + TAG_NAME=${TAG_NAME#desktop-v} + + # Check if not from current month or year + if [ "$(echo "$TAG_NAME" | awk -F. '{print $2}')" != "$(date +"%-m")" ] || [ "$(echo "$TAG_NAME" | awk -F. '{print $1}')" != "$(date +"%Y")" ]; then + # Reset patch to 0 and set YYYY.M to current date + TAG_NAME="$DEFAULT_VERSION" + else + # Same month & year => only increment the patch number + TAG_NAME=$(echo "$TAG_NAME" | awk -F. '{$NF = $NF + 1; OFS="."; print}') + fi + # Add "desktop-v" prefix back + TAG_NAME="desktop-v$TAG_NAME" +fi + +# Get the current short commit-hash +COMMIT_HASH=$(git show -s --format=%h) + +# Print the tag and commit-hash +echo "TAG "$'\t'""$'\t '" | COMMIT" +echo "----------------- | --------" +echo "$TAG_NAME | $COMMIT_HASH" +echo "" +echo "To create and push the tag, run:" +echo "git tag \"$TAG_NAME\" && git push origin \"$TAG_NAME\"" diff --git a/index.html b/index.html index 550fee4..c201da7 100644 --- a/index.html +++ b/index.html @@ -32,28 +32,24 @@ + - + - - - - -
@@ -97,17 +93,20 @@

Markdown Viewer

+ + + + - - -
-
- Documents - -
-
- -
- -
-
- - - - + + + + - - @@ -236,56 +219,6 @@
Menu
- -
-
- -
- - - - - - - - - -
- - - - + \ No newline at end of file diff --git a/script.js b/script.js index 97ad3d4..f403d0c 100644 --- a/script.js +++ b/script.js @@ -13,8 +13,10 @@ document.addEventListener("DOMContentLoaded", function () { const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); const themeToggle = document.getElementById("theme-toggle"); - const importFromFileButton = document.getElementById("import-from-file"); - const importFromGithubButton = document.getElementById("import-from-github"); + const openButton = document.getElementById("open-button"); + const saveButton = document.getElementById("save-button"); + const insertAdoTocButton = document.getElementById("insert-ado-toc"); + const insertAdoNoteButton = document.getElementById("insert-ado-note"); const fileInput = document.getElementById("file-input"); const exportMd = document.getElementById("export-md"); const exportHtml = document.getElementById("export-html"); @@ -52,26 +54,15 @@ document.addEventListener("DOMContentLoaded", function () { const mobileWordCount = document.getElementById("mobile-word-count"); const mobileCharCount = document.getElementById("mobile-char-count"); const mobileToggleSync = document.getElementById("mobile-toggle-sync"); - const mobileImportBtn = document.getElementById("mobile-import-button"); - const mobileImportGithubBtn = document.getElementById("mobile-import-github-button"); + const mobileOpenBtn = document.getElementById("mobile-open-button"); + const mobileSaveBtn = document.getElementById("mobile-save-button"); + const mobileInsertAdoTocBtn = document.getElementById("mobile-insert-ado-toc"); + const mobileInsertAdoNoteBtn = document.getElementById("mobile-insert-ado-note"); const mobileExportMd = document.getElementById("mobile-export-md"); const mobileExportHtml = document.getElementById("mobile-export-html"); const mobileExportPdf = document.getElementById("mobile-export-pdf"); const mobileCopyMarkdown = document.getElementById("mobile-copy-markdown"); const mobileThemeToggle = document.getElementById("mobile-theme-toggle"); - const shareButton = document.getElementById("share-button"); - const mobileShareButton = document.getElementById("mobile-share-button"); - const githubImportModal = document.getElementById("github-import-modal"); - const githubImportTitle = document.getElementById("github-import-title"); - const githubImportUrlInput = document.getElementById("github-import-url"); - const githubImportFileSelect = document.getElementById("github-import-file-select"); - const githubImportSelectionToolbar = document.getElementById("github-import-selection-toolbar"); - const githubImportSelectedCount = document.getElementById("github-import-selected-count"); - const githubImportSelectAllBtn = document.getElementById("github-import-select-all"); - const githubImportTree = document.getElementById("github-import-tree"); - const githubImportError = document.getElementById("github-import-error"); - const githubImportCancelBtn = document.getElementById("github-import-cancel"); - const githubImportSubmitBtn = document.getElementById("github-import-submit"); // Check dark mode preference first for proper initialization const prefersDarkMode = @@ -94,8 +85,8 @@ document.addEventListener("DOMContentLoaded", function () { mermaid.initialize({ startOnLoad: false, theme: mermaidTheme, - securityLevel: 'loose', - flowchart: { useMaxWidth: true, htmlLabels: true }, + securityLevel: 'strict', + flowchart: { useMaxWidth: true, htmlLabels: false }, fontSize: 16 }); }; @@ -106,11 +97,13 @@ document.addEventListener("DOMContentLoaded", function () { console.warn("Mermaid initialization failed:", e); } + let currentFileName = "document.md"; + let currentFileHandle = null; + const markedOptions = { gfm: true, breaks: false, pedantic: false, - sanitize: false, smartypants: false, xhtml: false, headerIds: true, @@ -134,8 +127,422 @@ document.addEventListener("DOMContentLoaded", function () { marked.setOptions({ ...markedOptions, renderer: renderer, + highlight: function (code, language) { + if (language === 'mermaid') return code; + const validLanguage = hljs.getLanguage(language) ? language : "plaintext"; + return hljs.highlight(code, { language: validLanguage }).value; + }, }); + function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function slugifyHeading(text) { + return String(text) + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + + function buildAdoTocHtml(markdown) { + const sourceWithoutCode = markdown.replace(/```[\s\S]*?```/g, ''); + const headingRegex = /^(#{1,6})\s+(.+)$/gm; + const items = []; + let match; + + while ((match = headingRegex.exec(sourceWithoutCode)) !== null) { + const level = match[1].length; + const rawText = match[2].replace(/\s+#+\s*$/, '').trim(); + const anchor = slugifyHeading(rawText); + if (!anchor) continue; + + items.push(`
  • ${escapeHtml(rawText)}
  • `); + } + + if (items.length === 0) { + return '
    Table of contents
    No headings found.
    '; + } + + return ``; + } + + function transformAdoWikiLinks(markdown) { + return markdown.replace(/\[\[([^\]]+)\]\]/g, (fullMatch, content) => { + const trimmed = content.trim(); + if (!trimmed) return fullMatch; + if (trimmed.toUpperCase() === '_TOC_') return fullMatch; + + const pipeIndex = trimmed.indexOf('|'); + const targetPart = pipeIndex >= 0 ? trimmed.slice(0, pipeIndex).trim() : trimmed; + const labelPart = pipeIndex >= 0 ? trimmed.slice(pipeIndex + 1).trim() : ''; + + if (!targetPart) return fullMatch; + + const hashIndex = targetPart.indexOf('#'); + const pagePart = hashIndex >= 0 ? targetPart.slice(0, hashIndex).trim() : targetPart; + const sectionPart = hashIndex >= 0 ? targetPart.slice(hashIndex + 1).trim() : ''; + const label = labelPart || targetPart; + + if (/^https?:\/\//i.test(targetPart)) { + return `[${label}](${targetPart})`; + } + + if (targetPart.startsWith('#')) { + const anchorOnly = slugifyHeading(targetPart.slice(1)); + return `[${label}](#${anchorOnly})`; + } + + const encodedPage = encodeURIComponent(pagePart).replace(/%2F/g, '/'); + const anchor = sectionPart ? `#${slugifyHeading(sectionPart)}` : ''; + const href = `${encodedPage}${anchor}`; + return `[${label}](${href})`; + }); + } + + function transformAdoCallouts(markdown) { + const lines = markdown.split('\n'); + const output = []; + let i = 0; + + while (i < lines.length) { + const startMatch = lines[i].match(/^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i); + if (!startMatch) { + output.push(lines[i]); + i++; + continue; + } + + const kind = startMatch[1].toLowerCase(); + const title = startMatch[1].toUpperCase(); + i++; + + const bodyLines = []; + while (i < lines.length && /^\s*>/.test(lines[i])) { + bodyLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + + const bodyHtml = escapeHtml(bodyLines.join('\n').trim()).replace(/\n/g, '
    '); + output.push(`
    ${title}
    ${bodyHtml}
    `); + } + + return output.join('\n'); + } + + function preprocessMarkdown(markdown) { + if (!markdown) return markdown; + + let result = markdown; + + // ADO wiki TOC token support. + result = result.replace(/\[\[_TOC_\]\]/gi, () => buildAdoTocHtml(markdown)); + + // ADO wiki alerts and wiki links support. + result = transformAdoCallouts(result); + result = transformAdoWikiLinks(result); + + // Support ::: mermaid containers by converting them to fenced code blocks. + result = result.replace( + /(^|\n)([ \t]{0,3}):::\s*mermaid\s*\n([\s\S]*?)\n\2:::(?=\n|$)/g, + (match, prefix, indent, diagramBody) => { + const normalizedBody = diagramBody.replace(/\n+$/, ''); + return `${prefix}${indent}\`\`\`mermaid\n${normalizedBody}\n${indent}\`\`\``; + } + ); + + return result; + } + + function applyMermaidZoom(container) { + const svg = container.querySelector('.mermaid svg'); + if (!svg) return; + + const zoom = parseFloat(container.dataset.zoom || '1'); + + const baseWidth = parseFloat(container.dataset.baseWidth || '0'); + const baseHeight = parseFloat(container.dataset.baseHeight || '0'); + if (!baseWidth || !baseHeight) return; + + svg.style.transform = ''; + svg.style.maxWidth = 'none'; + svg.style.height = 'auto'; + svg.setAttribute('width', String(Math.max(1, Math.round(baseWidth * zoom)))); + svg.setAttribute('height', String(Math.max(1, Math.round(baseHeight * zoom)))); + + if (zoom > 1) { + container.classList.add('mermaid-zoomed'); + } else { + container.classList.remove('mermaid-zoomed'); + } + } + + function fitMermaidToContainer(container) { + const baseWidth = parseFloat(container.dataset.baseWidth || '0'); + if (!baseWidth) return; + + // Reserve a small gutter for borders/scrollbars so fit does not immediately overflow. + const availableWidth = Math.max(1, container.clientWidth - 24); + const fitZoom = Math.max(0.4, Math.min(3, availableWidth / baseWidth)); + container.dataset.zoom = String(fitZoom); + container.dataset.zoomMode = 'fit'; + applyMermaidZoom(container); + } + + async function saveMermaidAsPng(container, index) { + const svg = container.querySelector('.mermaid svg'); + if (!svg) { + alert('Mermaid diagram is not ready yet.'); + return; + } + + try { + const svgClone = svg.cloneNode(true); + svgClone.removeAttribute('style'); + svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svgClone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + + const rect = svg.getBoundingClientRect(); + const viewBox = svg.viewBox && svg.viewBox.baseVal; + const width = Math.max(1, Math.ceil((viewBox && viewBox.width) || rect.width)); + const height = Math.max(1, Math.ceil((viewBox && viewBox.height) || rect.height)); + + if (!svgClone.getAttribute('viewBox')) { + svgClone.setAttribute('viewBox', `0 0 ${width} ${height}`); + } + svgClone.setAttribute('width', String(width)); + svgClone.setAttribute('height', String(height)); + + const svgString = new XMLSerializer().serializeToString(svgClone); + const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`; + + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + image.src = svgDataUrl; + }); + + const scale = 2; + const canvas = document.createElement('canvas'); + canvas.width = width * scale; + canvas.height = height * scale; + + const context = canvas.getContext('2d'); + const theme = document.documentElement.getAttribute('data-theme'); + context.fillStyle = theme === 'dark' ? '#0d1117' : '#ffffff'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + + // Prefer toDataURL + anchor download for better compatibility with file:// origins. + const pngDataUrl = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.href = pngDataUrl; + link.download = `mermaid-diagram-${index + 1}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error('Failed to export Mermaid PNG:', error); + alert('Failed to export Mermaid as PNG.'); + } + } + + function enhanceMermaidDiagrams(rootElement) { + const containers = rootElement.querySelectorAll('.mermaid-container'); + + containers.forEach((container, index) => { + const mermaidNode = container.querySelector('.mermaid'); + const svg = container.querySelector('.mermaid svg'); + if (!mermaidNode || !svg) return; + + if (!container.dataset.zoom) { + container.dataset.zoom = '1'; + } + + if (!container.dataset.baseWidth || !container.dataset.baseHeight) { + const viewBox = svg.viewBox && svg.viewBox.baseVal; + const rect = svg.getBoundingClientRect(); + const baseWidth = (viewBox && viewBox.width) || rect.width || 1; + const baseHeight = (viewBox && viewBox.height) || rect.height || 1; + + container.dataset.baseWidth = String(baseWidth); + container.dataset.baseHeight = String(baseHeight); + } + + if (!container.classList.contains('mermaid-enhanced')) { + container.classList.add('mermaid-enhanced'); + + const controls = document.createElement('div'); + controls.className = 'mermaid-controls'; + controls.innerHTML = ` + + + + + + + `; + + controls.addEventListener('click', async function (event) { + const button = event.target.closest('button[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + const currentZoom = parseFloat(container.dataset.zoom || '1'); + + if (action === 'zoom-in') { + container.dataset.zoom = String(Math.min(3, currentZoom + 0.2)); + container.dataset.zoomMode = 'manual'; + applyMermaidZoom(container); + } else if (action === 'zoom-out') { + container.dataset.zoom = String(Math.max(0.4, currentZoom - 0.2)); + container.dataset.zoomMode = 'manual'; + applyMermaidZoom(container); + } else if (action === 'fit') { + fitMermaidToContainer(container); + } else if (action === 'zoom-reset') { + container.dataset.zoom = '1'; + container.dataset.zoomMode = 'manual'; + applyMermaidZoom(container); + } else if (action === 'fullscreen') { + try { + if (document.fullscreenElement === container) { + await document.exitFullscreen(); + } else { + await container.requestFullscreen(); + } + } catch (error) { + console.warn('Fullscreen not available:', error); + } + } else if (action === 'save-png') { + saveMermaidAsPng(container, index); + } + }); + + const adjustZoom = (delta) => { + const currentZoom = parseFloat(container.dataset.zoom || '1'); + const nextZoom = Math.max(0.4, Math.min(3, currentZoom + delta)); + container.dataset.zoom = String(nextZoom); + container.dataset.zoomMode = 'manual'; + applyMermaidZoom(container); + }; + + container.addEventListener('wheel', function (event) { + // Use Ctrl/Cmd + wheel to zoom diagram without changing normal scroll behavior. + if (!(event.ctrlKey || event.metaKey)) return; + + event.preventDefault(); + const delta = event.deltaY < 0 ? 0.1 : -0.1; + adjustZoom(delta); + }, { passive: false }); + + // Extra mouse-button controls: + // - Side mouse buttons: back(3)=zoom out, forward(4)=zoom in + // - Middle click: zoom in + // - Shift + right click: zoom out + container.addEventListener('mousedown', function (event) { + if (event.button === 3) { + event.preventDefault(); + adjustZoom(-0.2); + } else if (event.button === 4) { + event.preventDefault(); + adjustZoom(0.2); + } + }); + + container.addEventListener('auxclick', function (event) { + if (event.button === 1) { + event.preventDefault(); + adjustZoom(0.2); + } + }); + + container.addEventListener('contextmenu', function (event) { + if (event.shiftKey) { + event.preventDefault(); + adjustZoom(-0.2); + } + }); + + container.addEventListener('pointerdown', function (event) { + if (event.button !== 0) return; + + container.dataset.dragging = 'true'; + container.dataset.dragStartX = String(event.clientX); + container.dataset.dragStartY = String(event.clientY); + container.dataset.dragScrollLeft = String(container.scrollLeft); + container.dataset.dragScrollTop = String(container.scrollTop); + container.classList.add('mermaid-dragging'); + event.preventDefault(); + }); + + container.addEventListener('pointermove', function (event) { + if (container.dataset.dragging !== 'true') return; + + const startX = parseFloat(container.dataset.dragStartX || '0'); + const startY = parseFloat(container.dataset.dragStartY || '0'); + const startLeft = parseFloat(container.dataset.dragScrollLeft || '0'); + const startTop = parseFloat(container.dataset.dragScrollTop || '0'); + + container.scrollLeft = startLeft - (event.clientX - startX); + container.scrollTop = startTop - (event.clientY - startY); + }); + + const stopDragging = () => { + if (container.dataset.dragging !== 'true') return; + container.dataset.dragging = 'false'; + container.classList.remove('mermaid-dragging'); + }; + + container.addEventListener('pointerup', stopDragging); + container.addEventListener('pointerleave', stopDragging); + container.addEventListener('pointercancel', stopDragging); + + container.insertBefore(controls, mermaidNode); + } + + applyMermaidZoom(container); + }); + } + + const SANITIZE_CONFIG = { + ADD_TAGS: ['mjx-container'], + ADD_ATTR: ['id', 'class'] + }; + + const SANITIZE_CONFIG_PDF = { + ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath'], + ADD_ATTR: ['id', 'class', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start'] + }; + + const DEBUG_PDF_EXPORT = false; + function debugPdfExport(...args) { + if (DEBUG_PDF_EXPORT) { + console.log(...args); + } + } + const sampleMarkdown = `# Welcome to Markdown Viewer ## ✨ Key Features @@ -147,15 +554,16 @@ document.addEventListener("DOMContentLoaded", function () { ## 💻 Code with Syntax Highlighting \`\`\`javascript - function renderMarkdown() { + async function renderMarkdown() { const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html); markdownPreview.innerHTML = sanitizedHtml; - // Syntax highlighting is handled automatically - // during the parsing phase by the marked renderer. - // Themes are applied instantly via CSS variables. + // Apply syntax highlighting to code blocks + markdownPreview.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block); + }); } \`\`\` @@ -284,7 +692,7 @@ Create bullet points: Add a [link](https://github.com/ThisIs-Developer/Markdown-Viewer) to important resources. Embed an image: -![Markdown Logo](https://markdownviewer.pages.dev/assets/icon.jpg) +![Markdown Logo](https://example.com/logo.png) ### **Blockquotes** @@ -299,483 +707,22 @@ This is a fully client-side application. Your content never leaves your browser markdownEditor.value = sampleMarkdown; - // ======================================== - // DOCUMENT TABS & SESSION MANAGEMENT - // ======================================== - - const STORAGE_KEY = 'markdownViewerTabs'; - const ACTIVE_TAB_KEY = 'markdownViewerActiveTab'; - const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter'; - let tabs = []; - let activeTabId = null; - let draggedTabId = null; - let saveTabStateTimeout = null; - let untitledCounter = 0; - - function loadTabsFromStorage() { + async function renderMarkdown() { try { - return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; - } catch (e) { - return []; - } - } - - function saveTabsToStorage(tabsArr) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(tabsArr)); - } catch (e) { - console.warn('Failed to save tabs to localStorage:', e); - } - } - - function loadActiveTabId() { - return localStorage.getItem(ACTIVE_TAB_KEY); - } - - function saveActiveTabId(id) { - localStorage.setItem(ACTIVE_TAB_KEY, id); - } - - function loadUntitledCounter() { - return parseInt(localStorage.getItem(UNTITLED_COUNTER_KEY) || '0', 10); - } - - function saveUntitledCounter(val) { - localStorage.setItem(UNTITLED_COUNTER_KEY, String(val)); - } - - function nextUntitledTitle() { - untitledCounter += 1; - saveUntitledCounter(untitledCounter); - return 'Untitled ' + untitledCounter; - } - - function createTab(content, title, viewMode) { - if (content === undefined) content = ''; - if (title === undefined) title = null; - if (viewMode === undefined) viewMode = 'split'; - return { - id: 'tab_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8), - title: title || 'Untitled', - content: content, - scrollPos: 0, - viewMode: viewMode, - createdAt: Date.now() - }; - } - - function renderTabBar(tabsArr, currentActiveTabId) { - const tabList = document.getElementById('tab-list'); - if (!tabList) return; - tabList.innerHTML = ''; - tabsArr.forEach(function(tab) { - const item = document.createElement('div'); - item.className = 'tab-item' + (tab.id === currentActiveTabId ? ' active' : ''); - item.setAttribute('data-tab-id', tab.id); - item.setAttribute('role', 'tab'); - item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false'); - item.setAttribute('draggable', 'true'); - - const titleSpan = document.createElement('span'); - titleSpan.className = 'tab-title'; - titleSpan.textContent = tab.title || 'Untitled'; - titleSpan.title = tab.title || 'Untitled'; - - // Three-dot menu button - const menuBtn = document.createElement('button'); - menuBtn.className = 'tab-menu-btn'; - menuBtn.setAttribute('aria-label', 'File options'); - menuBtn.title = 'File options'; - menuBtn.innerHTML = '⋯'; - - // Dropdown - const dropdown = document.createElement('div'); - dropdown.className = 'tab-menu-dropdown'; - dropdown.innerHTML = - '' + - '' + - ''; - - menuBtn.appendChild(dropdown); - - menuBtn.addEventListener('click', function(e) { - e.stopPropagation(); - // Close all other open dropdowns first - document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) { - if (btn !== menuBtn) btn.classList.remove('open'); - }); - menuBtn.classList.toggle('open'); - // Position the dropdown relative to the viewport so it escapes the - // overflow scroll container on .tab-list - if (menuBtn.classList.contains('open')) { - var rect = menuBtn.getBoundingClientRect(); - dropdown.style.top = (rect.bottom + 4) + 'px'; - dropdown.style.right = (window.innerWidth - rect.right) + 'px'; - dropdown.style.left = 'auto'; - } - }); - - dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) { - actionBtn.addEventListener('click', function(e) { - e.stopPropagation(); - menuBtn.classList.remove('open'); - const action = actionBtn.getAttribute('data-action'); - if (action === 'rename') renameTab(tab.id); - else if (action === 'duplicate') duplicateTab(tab.id); - else if (action === 'delete') deleteTab(tab.id); - }); - }); - - item.appendChild(titleSpan); - item.appendChild(menuBtn); - - item.addEventListener('click', function() { - switchTab(tab.id); - }); - - item.addEventListener('dragstart', function() { - draggedTabId = tab.id; - setTimeout(function() { item.classList.add('dragging'); }, 0); - }); - - item.addEventListener('dragend', function() { - item.classList.remove('dragging'); - draggedTabId = null; - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - item.classList.add('drag-over'); - }); - - item.addEventListener('dragleave', function() { - item.classList.remove('drag-over'); - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - item.classList.remove('drag-over'); - if (!draggedTabId || draggedTabId === tab.id) return; - const fromIdx = tabs.findIndex(function(t) { return t.id === draggedTabId; }); - const toIdx = tabs.findIndex(function(t) { return t.id === tab.id; }); - if (fromIdx === -1 || toIdx === -1) return; - const moved = tabs.splice(fromIdx, 1)[0]; - tabs.splice(toIdx, 0, moved); - saveTabsToStorage(tabs); - renderTabBar(tabs, activeTabId); - }); - - tabList.appendChild(item); - }); - - // "+ Create" button at end of tab list - const newBtn = document.createElement('button'); - newBtn.className = 'tab-new-btn'; - newBtn.title = 'New Tab (Ctrl+T)'; - newBtn.setAttribute('aria-label', 'Open new tab'); - newBtn.innerHTML = ''; - newBtn.addEventListener('click', function() { newTab(); }); - tabList.appendChild(newBtn); - - // Auto-scroll active tab into view - const activeItem = tabList.querySelector('.tab-item.active'); - if (activeItem) { - activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - - renderMobileTabList(tabsArr, currentActiveTabId); - } - - function renderMobileTabList(tabsArr, currentActiveTabId) { - const mobileTabList = document.getElementById('mobile-tab-list'); - if (!mobileTabList) return; - mobileTabList.innerHTML = ''; - tabsArr.forEach(function(tab) { - const item = document.createElement('div'); - item.className = 'mobile-tab-item' + (tab.id === currentActiveTabId ? ' active' : ''); - item.setAttribute('role', 'tab'); - item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false'); - item.setAttribute('data-tab-id', tab.id); - - const titleSpan = document.createElement('span'); - titleSpan.className = 'mobile-tab-title'; - titleSpan.textContent = tab.title || 'Untitled'; - titleSpan.title = tab.title || 'Untitled'; - - // Three-dot menu button (same as desktop) - const menuBtn = document.createElement('button'); - menuBtn.className = 'tab-menu-btn'; - menuBtn.setAttribute('aria-label', 'File options'); - menuBtn.title = 'File options'; - menuBtn.innerHTML = '⋯'; - - // Dropdown (same as desktop) - const dropdown = document.createElement('div'); - dropdown.className = 'tab-menu-dropdown'; - dropdown.innerHTML = - '' + - '' + - ''; - - menuBtn.appendChild(dropdown); - - menuBtn.addEventListener('click', function(e) { - e.stopPropagation(); - document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) { - if (btn !== menuBtn) btn.classList.remove('open'); - }); - menuBtn.classList.toggle('open'); - if (menuBtn.classList.contains('open')) { - const rect = menuBtn.getBoundingClientRect(); - dropdown.style.top = (rect.bottom + 4) + 'px'; - dropdown.style.right = (window.innerWidth - rect.right) + 'px'; - dropdown.style.left = 'auto'; - } - }); + const markdown = markdownEditor.value; + const html = marked.parse(preprocessMarkdown(markdown)); + const sanitizedHtml = DOMPurify.sanitize(html, SANITIZE_CONFIG); + markdownPreview.innerHTML = sanitizedHtml; - dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) { - actionBtn.addEventListener('click', function(e) { - e.stopPropagation(); - menuBtn.classList.remove('open'); - const action = actionBtn.getAttribute('data-action'); - if (action === 'rename') { - closeMobileMenu(); - renameTab(tab.id); - } else if (action === 'duplicate') { - duplicateTab(tab.id); - closeMobileMenu(); - } else if (action === 'delete') { - deleteTab(tab.id); + markdownPreview.querySelectorAll("pre code").forEach((block) => { + try { + if (!block.classList.contains('mermaid')) { + hljs.highlightElement(block); } - }); - }); - - item.appendChild(titleSpan); - item.appendChild(menuBtn); - - item.addEventListener('click', function() { - switchTab(tab.id); - closeMobileMenu(); - }); - - mobileTabList.appendChild(item); - }); - } - - // Close any open tab dropdown when clicking elsewhere in the document - document.addEventListener('click', function() { - document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) { - btn.classList.remove('open'); - }); - }); - - function saveCurrentTabState() { - const tab = tabs.find(function(t) { return t.id === activeTabId; }); - if (!tab) return; - tab.content = markdownEditor.value; - tab.scrollPos = markdownEditor.scrollTop; - tab.viewMode = currentViewMode || 'split'; - saveTabsToStorage(tabs); - } - - function restoreViewMode(mode) { - currentViewMode = null; - setViewMode(mode || 'split'); - } - - function switchTab(tabId) { - if (tabId === activeTabId) return; - saveCurrentTabState(); - activeTabId = tabId; - saveActiveTabId(activeTabId); - const tab = tabs.find(function(t) { return t.id === tabId; }); - if (!tab) return; - markdownEditor.value = tab.content; - restoreViewMode(tab.viewMode); - renderMarkdown(); - requestAnimationFrame(function() { - markdownEditor.scrollTop = tab.scrollPos || 0; - }); - renderTabBar(tabs, activeTabId); - } - - function newTab(content, title) { - if (content === undefined) content = ''; - if (tabs.length >= 20) { - alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); - return; - } - if (!title) title = nextUntitledTitle(); - const tab = createTab(content, title); - tabs.push(tab); - switchTab(tab.id); - markdownEditor.focus(); - } - - function closeTab(tabId) { - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; - tabs.splice(idx, 1); - if (tabs.length === 0) { - // Auto-create new "Untitled" when last tab is deleted - const newT = createTab('', nextUntitledTitle()); - tabs.push(newT); - activeTabId = newT.id; - saveActiveTabId(activeTabId); - markdownEditor.value = ''; - restoreViewMode('split'); - renderMarkdown(); - } else if (activeTabId === tabId) { - const newIdx = Math.max(0, idx - 1); - activeTabId = tabs[newIdx].id; - saveActiveTabId(activeTabId); - const newActiveTab = tabs[newIdx]; - markdownEditor.value = newActiveTab.content; - restoreViewMode(newActiveTab.viewMode); - renderMarkdown(); - requestAnimationFrame(function() { - markdownEditor.scrollTop = newActiveTab.scrollPos || 0; - }); - } - saveTabsToStorage(tabs); - renderTabBar(tabs, activeTabId); - } - - function deleteTab(tabId) { - closeTab(tabId); - } - - function renameTab(tabId) { - const tab = tabs.find(function(t) { return t.id === tabId; }); - if (!tab) return; - const modal = document.getElementById('rename-modal'); - const input = document.getElementById('rename-modal-input'); - const confirmBtn = document.getElementById('rename-modal-confirm'); - const cancelBtn = document.getElementById('rename-modal-cancel'); - if (!modal || !input) return; - input.value = tab.title; - modal.style.display = 'flex'; - input.focus(); - input.select(); - - function doRename() { - const newName = input.value.trim(); - if (newName) { - tab.title = newName; - saveTabsToStorage(tabs); - renderTabBar(tabs, activeTabId); - } - modal.style.display = 'none'; - cleanup(); - } - - function cleanup() { - confirmBtn.removeEventListener('click', doRename); - cancelBtn.removeEventListener('click', doCancel); - input.removeEventListener('keydown', onKey); - } - - function doCancel() { - modal.style.display = 'none'; - cleanup(); - } - - function onKey(e) { - if (e.key === 'Enter') doRename(); - else if (e.key === 'Escape') doCancel(); - } - - confirmBtn.addEventListener('click', doRename); - cancelBtn.addEventListener('click', doCancel); - input.addEventListener('keydown', onKey); - } - - function duplicateTab(tabId) { - const tab = tabs.find(function(t) { return t.id === tabId; }); - if (!tab) return; - if (tabs.length >= 20) { - alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); - return; - } - saveCurrentTabState(); - const dupTitle = tab.title + ' (copy)'; - const dup = createTab(tab.content, dupTitle, tab.viewMode); - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - tabs.splice(idx + 1, 0, dup); - switchTab(dup.id); - } - - function resetAllTabs() { - const modal = document.getElementById('reset-confirm-modal'); - const confirmBtn = document.getElementById('reset-modal-confirm'); - const cancelBtn = document.getElementById('reset-modal-cancel'); - if (!modal) return; - modal.style.display = 'flex'; - - function doReset() { - modal.style.display = 'none'; - cleanup(); - tabs = []; - untitledCounter = 0; - saveUntitledCounter(0); - const welcome = createTab(sampleMarkdown, 'Welcome to Markdown'); - tabs.push(welcome); - activeTabId = welcome.id; - saveActiveTabId(activeTabId); - saveTabsToStorage(tabs); - markdownEditor.value = sampleMarkdown; - restoreViewMode('split'); - renderMarkdown(); - renderTabBar(tabs, activeTabId); - } - - function doCancel() { - modal.style.display = 'none'; - cleanup(); - } - - function cleanup() { - confirmBtn.removeEventListener('click', doReset); - cancelBtn.removeEventListener('click', doCancel); - } - - confirmBtn.addEventListener('click', doReset); - cancelBtn.addEventListener('click', doCancel); - } - - function initTabs() { - untitledCounter = loadUntitledCounter(); - tabs = loadTabsFromStorage(); - activeTabId = loadActiveTabId(); - if (tabs.length === 0) { - const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); - } else if (!tabs.find(function(t) { return t.id === activeTabId; })) { - activeTabId = tabs[0].id; - saveActiveTabId(activeTabId); - } - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); - markdownEditor.value = activeTab.content; - restoreViewMode(activeTab.viewMode); - renderMarkdown(); - requestAnimationFrame(function() { - markdownEditor.scrollTop = activeTab.scrollPos || 0; - }); - renderTabBar(tabs, activeTabId); - } - - function renderMarkdown() { - try { - const markdown = markdownEditor.value; - const html = marked.parse(markdown); - const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style'] + } catch (e) { + console.warn("Syntax highlighting failed for a code block:", e); + } }); - markdownPreview.innerHTML = sanitizedHtml; processEmojis(markdownPreview); @@ -785,16 +732,17 @@ This is a fully client-side application. Your content never leaves your browser try { const mermaidNodes = markdownPreview.querySelectorAll('.mermaid'); if (mermaidNodes.length > 0) { - Promise.resolve(mermaid.init(undefined, mermaidNodes)) - .then(() => addMermaidToolbars()) - .catch((e) => { - console.warn("Mermaid rendering failed:", e); - addMermaidToolbars(); - }); + await mermaid.run({ + nodes: mermaidNodes, + suppressErrors: true + }); } } catch (e) { console.warn("Mermaid rendering failed:", e); } + + enhanceMermaidDiagrams(markdownPreview); + invalidateSyncAnchors(); if (window.MathJax) { try { @@ -809,423 +757,145 @@ This is a fully client-side application. Your content never leaves your browser updateDocumentStats(); } catch (e) { console.error("Markdown rendering failed:", e); - markdownPreview.innerHTML = `
    - Error rendering markdown: ${e.message} -
    -
    ${markdownEditor.value}
    `; + markdownPreview.innerHTML = ""; + + const errorAlert = document.createElement('div'); + errorAlert.className = 'alert alert-danger'; + const errorTitle = document.createElement('strong'); + errorTitle.textContent = 'Error rendering markdown:'; + const errorText = document.createTextNode(` ${e.message}`); + errorAlert.appendChild(errorTitle); + errorAlert.appendChild(errorText); + + const markdownSource = document.createElement('pre'); + markdownSource.textContent = markdownEditor.value; + + markdownPreview.appendChild(errorAlert); + markdownPreview.appendChild(markdownSource); } } function importMarkdownFile(file) { const reader = new FileReader(); reader.onload = function(e) { - newTab(e.target.result, file.name.replace(/\.md$/i, '')); + markdownEditor.value = e.target.result; + currentFileName = file.name || "document.md"; + currentFileHandle = null; + renderMarkdown(); dropzone.style.display = "none"; }; reader.readAsText(file); } - function isMarkdownPath(path) { - return /\.(md|markdown)$/i.test(path || ""); - } - const MAX_GITHUB_FILES_SHOWN = 30; - const GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS = 800; - let lastGitHubImportRequestAt = 0; - const selectedGitHubImportPaths = new Set(); - let availableGitHubImportPaths = []; - - function getFileName(path) { - return (path || "").split("/").pop() || "document.md"; - } - - function buildRawGitHubUrl(owner, repo, ref, filePath) { - const encodedPath = filePath - .split("/") - .map((part) => encodeURIComponent(part)) - .join("/"); - return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/${encodedPath}`; - } - - async function fetchGitHubJson(url) { - const now = Date.now(); - const waitTime = GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS - (now - lastGitHubImportRequestAt); - if (waitTime > 0) { - await new Promise((resolve) => setTimeout(resolve, waitTime)); - } - lastGitHubImportRequestAt = Date.now(); - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json" - } - }); - if (!response.ok) { - throw new Error(`GitHub API request failed (${response.status})`); - } - return response.json(); - } - - async function fetchTextContent(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch file (${response.status})`); - } - return response.text(); - } - - function parseGitHubImportUrl(input) { - let parsedUrl; - try { - parsedUrl = new URL((input || "").trim()); - } catch (_) { - return null; - } - - const host = parsedUrl.hostname.replace(/^www\./, ""); - const segments = parsedUrl.pathname.split("/").filter(Boolean); - - if (host === "raw.githubusercontent.com") { - if (segments.length < 5) return null; - const [owner, repo, ref, ...rest] = segments; - const filePath = rest.join("/"); - return { owner, repo, ref, type: "file", filePath }; - } - - if (host !== "github.com" || segments.length < 2) return null; - - const owner = segments[0]; - const repo = segments[1].replace(/\.git$/i, ""); - if (segments.length === 2) { - return { owner, repo, type: "repo" }; - } - - const mode = segments[2]; - if (mode === "blob" && segments.length >= 5) { - return { - owner, - repo, - type: "file", - ref: segments[3], - filePath: segments.slice(4).join("/") - }; - } - - if (mode === "tree" && segments.length >= 4) { - return { - owner, - repo, - type: "tree", - ref: segments[3], - basePath: segments.slice(4).join("/") - }; - } - - return { owner, repo, type: "repo" }; - } - - async function getDefaultBranch(owner, repo) { - const repoInfo = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); - return repoInfo.default_branch; - } - - async function listMarkdownFiles(owner, repo, ref, basePath) { - const treeResponse = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`); - const normalizedBasePath = (basePath || "").replace(/^\/+|\/+$/g, ""); - - return (treeResponse.tree || []) - .filter((entry) => entry.type === "blob" && isMarkdownPath(entry.path)) - .filter((entry) => !normalizedBasePath || entry.path === normalizedBasePath || entry.path.startsWith(normalizedBasePath + "/")) - .map((entry) => entry.path) - .sort((a, b) => a.localeCompare(b)); - } - - function buildMarkdownFileTree(paths) { - const root = { folders: {}, files: [] }; - (paths || []).forEach((path) => { - const segments = (path || "").split("/").filter(Boolean); - if (!segments.length) return; - const fileName = segments.pop(); - let node = root; - segments.forEach((segment) => { - if (!node.folders[segment]) { - node.folders[segment] = { folders: {}, files: [] }; - } - node = node.folders[segment]; - }); - node.files.push({ name: fileName, path }); - }); - return root; - } - - function updateGitHubImportSelectedCount() { - if (!githubImportSelectedCount) return; - const count = selectedGitHubImportPaths.size; - githubImportSelectedCount.textContent = `${count} selected`; - } - - function updateGitHubSelectAllButtonLabel() { - if (!githubImportSelectAllBtn) return; - const total = availableGitHubImportPaths.length; - const allSelected = total > 0 && selectedGitHubImportPaths.size === total; - githubImportSelectAllBtn.textContent = allSelected ? "Clear All" : "Select All"; - } - - function syncGitHubSelectionToButtons() { - if (!githubImportTree) return; - Array.from(githubImportTree.querySelectorAll(".github-tree-file-btn")).forEach((btn) => { - const isSelected = selectedGitHubImportPaths.has(btn.dataset.path); - btn.classList.toggle("is-selected", isSelected); - btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); - }); - } - - function setGitHubSelectedPaths(paths) { - selectedGitHubImportPaths.clear(); - (paths || []).forEach((path) => selectedGitHubImportPaths.add(path)); - updateGitHubImportSelectedCount(); - syncGitHubSelectionToButtons(); - updateGitHubSelectAllButtonLabel(); - } - - function toggleGitHubSelectedPath(path) { - if (!path) return; - if (selectedGitHubImportPaths.has(path)) { - selectedGitHubImportPaths.delete(path); - } else { - selectedGitHubImportPaths.add(path); - } - updateGitHubImportSelectedCount(); - syncGitHubSelectionToButtons(); - updateGitHubSelectAllButtonLabel(); - } - - function renderGitHubImportTree(paths) { - if (!githubImportTree || !githubImportFileSelect) return; - githubImportTree.innerHTML = ""; - const tree = buildMarkdownFileTree(paths); - - const createTreeBranch = function(node, parentPath) { - const list = document.createElement("ul"); - const folderNames = Object.keys(node.folders).sort((a, b) => a.localeCompare(b)); - folderNames.forEach((folderName) => { - const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName; - const item = document.createElement("li"); - const folderLabel = document.createElement("span"); - folderLabel.className = "github-tree-folder-label"; - folderLabel.textContent = `📁 ${folderName}`; - item.appendChild(folderLabel); - item.appendChild(createTreeBranch(node.folders[folderName], folderPath)); - list.appendChild(item); - }); - - node.files - .sort((a, b) => a.path.localeCompare(b.path)) - .forEach((file) => { - const fileItem = document.createElement("li"); - const fileButton = document.createElement("button"); - fileButton.type = "button"; - fileButton.className = "github-tree-file-btn"; - fileButton.dataset.path = file.path; - fileButton.setAttribute("aria-pressed", "false"); - fileButton.textContent = `📄 ${file.name}`; - fileButton.addEventListener("click", function() { - toggleGitHubSelectedPath(file.path); - }); - fileItem.appendChild(fileButton); - list.appendChild(fileItem); + async function openMarkdownFile() { + // Use the File System Access API when available for a native open dialog. + if (window.showOpenFilePicker) { + try { + const [fileHandle] = await window.showOpenFilePicker({ + types: [{ + description: 'Markdown Files', + accept: { + 'text/markdown': ['.md', '.markdown'], + 'text/plain': ['.txt'] + } + }], + excludeAcceptAllOption: false, + multiple: false }); - return list; - }; - - githubImportTree.appendChild(createTreeBranch(tree, "")); - syncGitHubSelectionToButtons(); - } - - function setGitHubImportLoading(isLoading) { - if (!githubImportSubmitBtn) return; - if (isLoading) { - githubImportSubmitBtn.dataset.loadingText = githubImportSubmitBtn.textContent; - githubImportSubmitBtn.textContent = "Importing..."; - } else if (githubImportSubmitBtn.dataset.loadingText) { - githubImportSubmitBtn.textContent = githubImportSubmitBtn.dataset.loadingText; - delete githubImportSubmitBtn.dataset.loadingText; - } - } - - function setGitHubImportMessage(message, options = {}) { - if (!githubImportError) return; - const { isError = true } = options; - githubImportError.classList.toggle("is-info", !isError); - if (!message) { - githubImportError.textContent = ""; - githubImportError.style.display = "none"; - return; - } - githubImportError.textContent = message; - githubImportError.style.display = "block"; - } + if (!fileHandle) return; - function resetGitHubImportModal() { - if (!githubImportUrlInput || !githubImportFileSelect || !githubImportSubmitBtn) return; - if (githubImportTitle) { - githubImportTitle.textContent = "Import Markdown from GitHub"; - } - githubImportUrlInput.value = ""; - githubImportUrlInput.style.display = "block"; - githubImportUrlInput.disabled = false; - githubImportFileSelect.innerHTML = ""; - githubImportFileSelect.style.display = "none"; - githubImportFileSelect.disabled = false; - if (githubImportSelectionToolbar) { - githubImportSelectionToolbar.style.display = "none"; - } - availableGitHubImportPaths = []; - setGitHubSelectedPaths([]); - if (githubImportTree) { - githubImportTree.innerHTML = ""; - githubImportTree.style.display = "none"; - } - githubImportSubmitBtn.dataset.step = "url"; - delete githubImportSubmitBtn.dataset.owner; - delete githubImportSubmitBtn.dataset.repo; - delete githubImportSubmitBtn.dataset.ref; - githubImportSubmitBtn.textContent = "Import"; - setGitHubImportMessage(""); - } - - function openGitHubImportModal() { - if (!githubImportModal || !githubImportUrlInput || !githubImportSubmitBtn) return; - resetGitHubImportModal(); - githubImportModal.style.display = "flex"; - githubImportUrlInput.focus(); - } - - function closeGitHubImportModal() { - if (!githubImportModal) return; - githubImportModal.style.display = "none"; - resetGitHubImportModal(); - } - - async function handleGitHubImportSubmit() { - if (!githubImportSubmitBtn || !githubImportUrlInput || !githubImportFileSelect) return; - const setGitHubImportDialogDisabled = (disabled) => { - githubImportSubmitBtn.disabled = disabled; - if (githubImportCancelBtn) { - githubImportCancelBtn.disabled = disabled; - } - if (githubImportSelectAllBtn) { - githubImportSelectAllBtn.disabled = disabled; - } - }; - const step = githubImportSubmitBtn.dataset.step || "url"; - if (step === "select") { - const selectedPaths = Array.from(selectedGitHubImportPaths); - const owner = githubImportSubmitBtn.dataset.owner; - const repo = githubImportSubmitBtn.dataset.repo; - const ref = githubImportSubmitBtn.dataset.ref; - if (!owner || !repo || !ref || !selectedPaths.length) { - setGitHubImportMessage("Please select at least one file to import."); + const file = await fileHandle.getFile(); + const content = await file.text(); + markdownEditor.value = content; + currentFileName = file.name || "document.md"; + currentFileHandle = fileHandle; + renderMarkdown(); return; - } - setGitHubImportLoading(true); - setGitHubImportDialogDisabled(true); - try { - for (const selectedPath of selectedPaths) { - const markdown = await fetchTextContent(buildRawGitHubUrl(owner, repo, ref, selectedPath)); - newTab(markdown, getFileName(selectedPath).replace(/\.(md|markdown)$/i, "")); + } catch (e) { + // AbortError means the user closed the picker intentionally. + if (e && e.name !== 'AbortError') { + console.warn("Native open dialog failed, using fallback:", e); } - closeGitHubImportModal(); - } catch (error) { - console.error("GitHub import failed:", error); - setGitHubImportMessage("GitHub import failed: " + error.message); - } finally { - setGitHubImportDialogDisabled(false); - setGitHubImportLoading(false); } - return; } - const urlInput = githubImportUrlInput.value.trim(); - if (!urlInput) { - setGitHubImportMessage("Please enter a GitHub URL."); - return; - } + fileInput.click(); + } - const parsed = parseGitHubImportUrl(urlInput); - if (!parsed || !parsed.owner || !parsed.repo) { - setGitHubImportMessage("Please enter a valid GitHub URL."); - return; - } + async function saveMarkdownFile() { + const markdownText = markdownEditor.value; - setGitHubImportMessage(""); - setGitHubImportLoading(true); - setGitHubImportDialogDisabled(true); - try { - if (parsed.type === "file") { - if (!isMarkdownPath(parsed.filePath)) { - throw new Error("The provided URL does not point to a Markdown file."); - } - const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, parsed.ref, parsed.filePath)); - newTab(markdown, getFileName(parsed.filePath).replace(/\.(md|markdown)$/i, "")); - closeGitHubImportModal(); - return; - } + if (window.showSaveFilePicker) { + try { + const fileHandle = currentFileHandle || await window.showSaveFilePicker({ + suggestedName: currentFileName, + types: [{ + description: 'Markdown Files', + accept: { + 'text/markdown': ['.md'], + 'text/plain': ['.txt'] + } + }] + }); - const ref = parsed.ref || await getDefaultBranch(parsed.owner, parsed.repo); - const files = await listMarkdownFiles(parsed.owner, parsed.repo, ref, parsed.basePath || ""); + const writable = await fileHandle.createWritable(); + await writable.write(markdownText); + await writable.close(); - if (!files.length) { - setGitHubImportMessage("No Markdown files were found at that GitHub location."); + currentFileHandle = fileHandle; return; + } catch (e) { + if (e && e.name !== 'AbortError') { + console.warn("Native save dialog failed, using fallback:", e); + } else { + return; + } } + } - const shownFiles = files.slice(0, MAX_GITHUB_FILES_SHOWN); - if (files.length === 1) { - const targetPath = files[0]; - const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, ref, targetPath)); - newTab(markdown, getFileName(targetPath).replace(/\.(md|markdown)$/i, "")); - closeGitHubImportModal(); - return; - } + const blob = new Blob([markdownText], { + type: "text/markdown;charset=utf-8", + }); + saveAs(blob, currentFileName || "document.md"); + } - githubImportFileSelect.innerHTML = ""; - githubImportUrlInput.style.display = "none"; - githubImportFileSelect.style.display = "none"; - if (githubImportSelectionToolbar) { - githubImportSelectionToolbar.style.display = "flex"; - } - if (githubImportTree) { - githubImportTree.style.display = "block"; - } - shownFiles.forEach((filePath) => { - const option = document.createElement("option"); - option.value = filePath; - option.textContent = filePath; - githubImportFileSelect.appendChild(option); - }); - availableGitHubImportPaths = shownFiles.slice(); - setGitHubSelectedPaths(shownFiles[0] ? [shownFiles[0]] : []); - renderGitHubImportTree(shownFiles); - if (files.length > MAX_GITHUB_FILES_SHOWN) { - setGitHubImportMessage(`Showing first ${MAX_GITHUB_FILES_SHOWN} of ${files.length} Markdown files.`, { isError: false }); - } else { - setGitHubImportMessage(""); - } - if (githubImportTitle) { - githubImportTitle.textContent = "Select Markdown file(s) to import"; - } - githubImportSubmitBtn.dataset.step = "select"; - githubImportSubmitBtn.dataset.owner = parsed.owner; - githubImportSubmitBtn.dataset.repo = parsed.repo; - githubImportSubmitBtn.dataset.ref = ref; - githubImportSubmitBtn.textContent = "Import Selected"; - } catch (error) { - console.error("GitHub import failed:", error); - setGitHubImportMessage("GitHub import failed: " + error.message); - } finally { - setGitHubImportDialogDisabled(false); - setGitHubImportLoading(false); + function exportMarkdownFile() { + const blob = new Blob([markdownEditor.value], { + type: "text/markdown;charset=utf-8", + }); + saveAs(blob, "document.md"); + } + + function insertTextAtCursor(text, selectStartOffset = null, selectEndOffset = null) { + const start = markdownEditor.selectionStart; + const end = markdownEditor.selectionEnd; + const currentValue = markdownEditor.value; + + markdownEditor.value = currentValue.substring(0, start) + text + currentValue.substring(end); + + if (selectStartOffset !== null && selectEndOffset !== null) { + markdownEditor.selectionStart = start + selectStartOffset; + markdownEditor.selectionEnd = start + selectEndOffset; + } else { + const caret = start + text.length; + markdownEditor.selectionStart = caret; + markdownEditor.selectionEnd = caret; } + + markdownEditor.focus(); + markdownEditor.dispatchEvent(new Event('input')); + } + + function insertAdoTocSnippet() { + insertTextAtCursor('[[_TOC_]]\n\n'); + } + + function insertAdoNoteSnippet() { + const snippet = '> [!NOTE]\n> Add your note here.\n\n'; + const placeholder = 'Add your note here.'; + const startOffset = snippet.indexOf(placeholder); + insertTextAtCursor(snippet, startOffset, startOffset + placeholder.length); } function processEmojis(element) { @@ -1280,7 +950,7 @@ This is a fully client-side application. Your content never leaves your browser if (hasEmoji) { result += text.substring(lastIndex); const span = document.createElement('span'); - span.innerHTML = result; + span.textContent = result; textNode.parentNode.replaceChild(span, textNode); } }); @@ -1304,6 +974,129 @@ This is a fully client-side application. Your content never leaves your browser readingTimeElement.textContent = readingTimeMinutes; } + // ── Anchor-based scroll & click sync ───────────────────────────────────── + // Anchors pair each heading's pixel position in the editor with its rendered + // pixel position in the preview, then piecewise-interpolate between them. + // The cache is invalidated after every render and on window resize so it + // always reflects the current DOM layout. + + let syncAnchorsCache = null; + + function invalidateSyncAnchors() { + syncAnchorsCache = null; + } + + // Creates a hidden mirror div matching the textarea's styles and returns the + // accumulated scrollHeight (= top-of-line offset) for each requested line index. + function measureEditorLineOffsets(lineIndices) { + if (lineIndices.length === 0) return []; + + const mirror = document.createElement('div'); + const cs = window.getComputedStyle(editorPane); + [ + 'fontFamily','fontSize','fontWeight','fontStyle','fontVariant', + 'lineHeight','letterSpacing','wordSpacing','textIndent', + 'paddingTop','paddingRight','paddingBottom','paddingLeft', + 'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth', + 'boxSizing' + ].forEach(p => { mirror.style[p] = cs[p]; }); + + mirror.style.width = editorPane.clientWidth + 'px'; + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '-9999px'; + mirror.style.left = '-9999px'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'break-word'; + mirror.style.overflow = 'hidden'; + mirror.style.height = 'auto'; + + document.body.appendChild(mirror); + + const lines = markdownEditor.value.split('\n'); + const results = []; + + for (const idx of lineIndices) { + const textBefore = lines.slice(0, idx).join('\n'); + // Trailing newline ensures the mirror's height ends at the start of line idx. + mirror.textContent = textBefore ? textBefore + '\n' : ''; + results.push(mirror.scrollHeight); + } + + document.body.removeChild(mirror); + return results; + } + + // Absolute pixel offset of `el` within the previewPane scroll content. + function previewAbsoluteTop(el) { + const rect = el.getBoundingClientRect(); + const paneRect = previewPane.getBoundingClientRect(); + return previewPane.scrollTop + (rect.top - paneRect.top); + } + + function buildSyncAnchors() { + if (syncAnchorsCache) return syncAnchorsCache; + + const lines = markdownEditor.value.split('\n'); + const editorScrollMax = editorPane.scrollHeight - editorPane.clientHeight; + const previewScrollMax = previewPane.scrollHeight - previewPane.clientHeight; + + if (editorScrollMax < 1 || previewScrollMax < 1) { + syncAnchorsCache = [{ editorPx: 0, previewPx: 0 }]; + return syncAnchorsCache; + } + + // Collect 0-based line indices of headings in source order. + const headingLineIndices = []; + for (let i = 0; i < lines.length; i++) { + if (/^#{1,6}\s/.test(lines[i])) headingLineIndices.push(i); + } + + const previewHeadings = Array.from( + markdownPreview.querySelectorAll('h1,h2,h3,h4,h5,h6') + ); + + const anchors = [{ editorPx: 0, previewPx: 0 }]; + const count = Math.min(headingLineIndices.length, previewHeadings.length); + + if (count > 0) { + const editorOffsets = measureEditorLineOffsets(headingLineIndices.slice(0, count)); + + for (let i = 0; i < count; i++) { + const editorPx = Math.min(editorOffsets[i], editorScrollMax); + const previewPx = Math.min(previewAbsoluteTop(previewHeadings[i]), previewScrollMax); + const last = anchors[anchors.length - 1]; + // Keep anchors strictly monotone on the editor axis. + if (editorPx > last.editorPx && previewPx >= last.previewPx) { + anchors.push({ editorPx, previewPx }); + } + } + } + + anchors.push({ editorPx: editorScrollMax, previewPx: previewScrollMax }); + syncAnchorsCache = anchors; + return anchors; + } + + // Piecewise linear interpolation along the anchor chain. + function piecewiseMap(anchors, fromKey, toKey, value) { + if (anchors.length === 0) return 0; + if (value <= anchors[0][fromKey]) return anchors[0][toKey]; + const last = anchors[anchors.length - 1]; + if (value >= last[fromKey]) return last[toKey]; + + for (let i = 0; i < anchors.length - 1; i++) { + const a = anchors[i], b = anchors[i + 1]; + if (value >= a[fromKey] && value <= b[fromKey]) { + const span = b[fromKey] - a[fromKey]; + const r = span > 0 ? (value - a[fromKey]) / span : 0; + return a[toKey] + r * (b[toKey] - a[toKey]); + } + } + return last[toKey]; + } + + // ── Scroll sync ─────────────────────────────────────────────────────────── function syncEditorToPreview() { if (!syncScrollingEnabled || isPreviewScrolling) return; @@ -1311,20 +1104,14 @@ This is a fully client-side application. Your content never leaves your browser clearTimeout(scrollSyncTimeout); scrollSyncTimeout = setTimeout(() => { - const editorScrollRatio = - editorPane.scrollTop / - (editorPane.scrollHeight - editorPane.clientHeight); - const previewScrollPosition = - (previewPane.scrollHeight - previewPane.clientHeight) * - editorScrollRatio; - - if (!isNaN(previewScrollPosition) && isFinite(previewScrollPosition)) { - previewPane.scrollTop = previewScrollPosition; - } + const anchors = buildSyncAnchors(); + const target = piecewiseMap(anchors, 'editorPx', 'previewPx', editorPane.scrollTop); + const previewScrollMax = previewPane.scrollHeight - previewPane.clientHeight; - setTimeout(() => { - isEditorScrolling = false; - }, 50); + if (isFinite(target)) { + previewPane.scrollTop = Math.max(0, Math.min(target, previewScrollMax)); + } + setTimeout(() => { isEditorScrolling = false; }, 50); }, SCROLL_SYNC_DELAY); } @@ -1335,35 +1122,72 @@ This is a fully client-side application. Your content never leaves your browser clearTimeout(scrollSyncTimeout); scrollSyncTimeout = setTimeout(() => { - const previewScrollRatio = - previewPane.scrollTop / - (previewPane.scrollHeight - previewPane.clientHeight); - const editorScrollPosition = - (editorPane.scrollHeight - editorPane.clientHeight) * - previewScrollRatio; - - if (!isNaN(editorScrollPosition) && isFinite(editorScrollPosition)) { - editorPane.scrollTop = editorScrollPosition; - } + const anchors = buildSyncAnchors(); + const target = piecewiseMap(anchors, 'previewPx', 'editorPx', previewPane.scrollTop); + const editorScrollMax = editorPane.scrollHeight - editorPane.clientHeight; - setTimeout(() => { - isPreviewScrolling = false; - }, 50); + if (isFinite(target)) { + editorPane.scrollTop = Math.max(0, Math.min(target, editorScrollMax)); + } + setTimeout(() => { isPreviewScrolling = false; }, 50); }, SCROLL_SYNC_DELAY); } + // ── Click sync ──────────────────────────────────────────────────────────── + // Editor click → scroll preview to the line the cursor landed on. + function syncEditorClickToPreview() { + if (!syncScrollingEnabled) return; + + const textBefore = markdownEditor.value.substring(0, markdownEditor.selectionStart); + const lineIndex = textBefore.split('\n').length - 1; + const offsets = measureEditorLineOffsets([lineIndex]); + const editorPx = offsets[0]; + const anchors = buildSyncAnchors(); + const target = piecewiseMap(anchors, 'editorPx', 'previewPx', editorPx); + const previewScrollMax = previewPane.scrollHeight - previewPane.clientHeight; + + if (isFinite(target)) { + isEditorScrolling = true; + previewPane.scrollTop = Math.max(0, Math.min(target, previewScrollMax)); + setTimeout(() => { isEditorScrolling = false; }, 100); + } + } + + // Preview click → scroll editor to the corresponding position. + function syncPreviewClickToEditor(event) { + if (!syncScrollingEnabled) return; + + const paneRect = previewPane.getBoundingClientRect(); + const clickedPreviewPx = previewPane.scrollTop + (event.clientY - paneRect.top); + const anchors = buildSyncAnchors(); + const target = piecewiseMap(anchors, 'previewPx', 'editorPx', clickedPreviewPx); + const editorScrollMax = editorPane.scrollHeight - editorPane.clientHeight; + + if (isFinite(target)) { + isPreviewScrolling = true; + editorPane.scrollTop = Math.max(0, Math.min(target, editorScrollMax)); + setTimeout(() => { isPreviewScrolling = false; }, 100); + } + } + function toggleSyncScrolling() { syncScrollingEnabled = !syncScrollingEnabled; + const buttons = [ + { el: toggleSyncButton, mobile: false }, + { el: mobileToggleSync, mobile: true } + ].filter(b => b.el); if (syncScrollingEnabled) { - toggleSyncButton.innerHTML = ' Sync Off'; - toggleSyncButton.classList.add("sync-disabled"); - toggleSyncButton.classList.remove("sync-enabled"); - toggleSyncButton.classList.add("border-primary"); + buttons.forEach(({ el, mobile }) => { + el.innerHTML = ` Sync On`; + el.classList.add("sync-enabled", "border-primary"); + el.classList.remove("sync-disabled"); + }); } else { - toggleSyncButton.innerHTML = ' Sync On'; - toggleSyncButton.classList.add("sync-enabled"); - toggleSyncButton.classList.remove("sync-disabled"); - toggleSyncButton.classList.remove("border-primary"); + buttons.forEach(({ el, mobile }) => { + el.innerHTML = ` Sync Off`; + el.classList.add("sync-disabled"); + el.classList.remove("sync-enabled", "border-primary"); + }); } } @@ -1544,22 +1368,16 @@ This is a fully client-side application. Your content never leaves your browser mobileToggleSync.addEventListener("click", () => { toggleSyncScrolling(); - if (syncScrollingEnabled) { - mobileToggleSync.innerHTML = ' Sync Off'; - mobileToggleSync.classList.add("sync-disabled"); - mobileToggleSync.classList.remove("sync-enabled"); - mobileToggleSync.classList.add("border-primary"); - } else { - mobileToggleSync.innerHTML = ' Sync On'; - mobileToggleSync.classList.add("sync-enabled"); - mobileToggleSync.classList.remove("sync-disabled"); - mobileToggleSync.classList.remove("border-primary"); - } }); - mobileImportBtn.addEventListener("click", () => fileInput.click()); - mobileImportGithubBtn.addEventListener("click", () => { + mobileOpenBtn.addEventListener("click", () => openMarkdownFile()); + mobileSaveBtn.addEventListener("click", () => saveMarkdownFile()); + mobileInsertAdoTocBtn.addEventListener("click", () => { + insertAdoTocSnippet(); + closeMobileMenu(); + }); + mobileInsertAdoNoteBtn.addEventListener("click", () => { + insertAdoNoteSnippet(); closeMobileMenu(); - openGitHubImportModal(); }); mobileExportMd.addEventListener("click", () => exportMd.click()); mobileExportHtml.addEventListener("click", () => exportHtml.click()); @@ -1569,26 +1387,13 @@ This is a fully client-side application. Your content never leaves your browser themeToggle.click(); mobileThemeToggle.innerHTML = themeToggle.innerHTML + " Toggle Dark Mode"; }); - - const mobileNewTabBtn = document.getElementById("mobile-new-tab-btn"); - if (mobileNewTabBtn) { - mobileNewTabBtn.addEventListener("click", function() { - newTab(); - closeMobileMenu(); - }); - } - - const mobileTabResetBtn = document.getElementById("mobile-tab-reset-btn"); - if (mobileTabResetBtn) { - mobileTabResetBtn.addEventListener("click", function() { - closeMobileMenu(); - resetAllTabs(); - }); - } - initTabs(); + renderMarkdown(); updateMobileStats(); + // Initialize view mode - Story 1.1 + contentContainer.classList.add('view-split'); + // Initialize resizer - Story 1.3 initResizer(); @@ -1597,7 +1402,6 @@ This is a fully client-side application. Your content never leaves your browser btn.addEventListener('click', function() { const mode = this.getAttribute('data-mode'); setViewMode(mode); - saveCurrentTabState(); }); }); @@ -1606,16 +1410,11 @@ This is a fully client-side application. Your content never leaves your browser btn.addEventListener('click', function() { const mode = this.getAttribute('data-mode'); setViewMode(mode); - saveCurrentTabState(); closeMobileMenu(); }); }); - markdownEditor.addEventListener("input", function() { - debouncedRender(); - clearTimeout(saveTabStateTimeout); - saveTabStateTimeout = setTimeout(saveCurrentTabState, 500); - }); + markdownEditor.addEventListener("input", debouncedRender); // Tab key handler to insert indentation instead of moving focus markdownEditor.addEventListener("keydown", function(e) { @@ -1643,6 +1442,14 @@ This is a fully client-side application. Your content never leaves your browser editorPane.addEventListener("scroll", syncEditorToPreview); previewPane.addEventListener("scroll", syncPreviewToEditor); toggleSyncButton.addEventListener("click", toggleSyncScrolling); + + // Click-to-sync: clicking in either pane scrolls the other to match. + editorPane.addEventListener("click", syncEditorClickToPreview); + editorPane.addEventListener("keyup", syncEditorClickToPreview); + previewPane.addEventListener("click", syncPreviewClickToEditor); + + // Invalidate anchor cache when window is resized (line widths change). + window.addEventListener("resize", invalidateSyncAnchors); themeToggle.addEventListener("click", function () { const theme = document.documentElement.getAttribute("data-theme") === "dark" @@ -1659,47 +1466,21 @@ This is a fully client-side application. Your content never leaves your browser renderMarkdown(); }); - if (importFromFileButton) { - importFromFileButton.addEventListener("click", function (e) { - e.preventDefault(); - fileInput.click(); - }); - } + openButton.addEventListener("click", function () { + openMarkdownFile(); + }); - if (importFromGithubButton) { - importFromGithubButton.addEventListener("click", function (e) { - e.preventDefault(); - openGitHubImportModal(); - }); - } + saveButton.addEventListener("click", function () { + saveMarkdownFile(); + }); - if (githubImportSubmitBtn) { - githubImportSubmitBtn.addEventListener("click", handleGitHubImportSubmit); - } - if (githubImportCancelBtn) { - githubImportCancelBtn.addEventListener("click", closeGitHubImportModal); - } - const handleGitHubImportInputKeydown = function(e) { - if (e.key === "Enter") { - e.preventDefault(); - handleGitHubImportSubmit(); - } else if (e.key === "Escape") { - closeGitHubImportModal(); - } - }; - if (githubImportUrlInput) { - githubImportUrlInput.addEventListener("keydown", handleGitHubImportInputKeydown); - } - if (githubImportFileSelect) { - githubImportFileSelect.addEventListener("keydown", handleGitHubImportInputKeydown); - } - if (githubImportSelectAllBtn) { - githubImportSelectAllBtn.addEventListener("click", function() { - const allPaths = availableGitHubImportPaths.slice(); - const shouldSelectAll = selectedGitHubImportPaths.size !== allPaths.length; - setGitHubSelectedPaths(shouldSelectAll ? allPaths : []); - }); - } + insertAdoTocButton.addEventListener("click", function () { + insertAdoTocSnippet(); + }); + + insertAdoNoteButton.addEventListener("click", function () { + insertAdoNoteSnippet(); + }); fileInput.addEventListener("change", function (e) { const file = e.target.files[0]; @@ -1709,26 +1490,41 @@ This is a fully client-side application. Your content never leaves your browser this.value = ""; }); - exportMd.addEventListener("click", function () { + exportMd.addEventListener("click", function (e) { + e.preventDefault(); try { - const blob = new Blob([markdownEditor.value], { - type: "text/markdown;charset=utf-8", - }); - saveAs(blob, "document.md"); + exportMarkdownFile(); } catch (e) { console.error("Export failed:", e); alert("Export failed: " + e.message); } }); - exportHtml.addEventListener("click", function () { + document.addEventListener("keydown", function(e) { + if (!(e.ctrlKey || e.metaKey)) return; + + const key = e.key.toLowerCase(); + if (key === 'o') { + e.preventDefault(); + openMarkdownFile(); + } else if (key === 's') { + e.preventDefault(); + saveMarkdownFile(); + } else if (e.altKey && key === 't') { + e.preventDefault(); + insertAdoTocSnippet(); + } else if (e.altKey && key === 'n') { + e.preventDefault(); + insertAdoNoteSnippet(); + } + }); + + exportHtml.addEventListener("click", function (e) { + e.preventDefault(); try { const markdown = markdownEditor.value; - const html = marked.parse(markdown); - const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style'] - }); + const html = marked.parse(preprocessMarkdown(markdown)); + const sanitizedHtml = DOMPurify.sanitize(html, SANITIZE_CONFIG); const isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark"; const cssTheme = isDarkTheme @@ -1741,6 +1537,9 @@ This is a fully client-side application. Your content never leaves your browser Markdown Export +