From ed2ac4d7ea2e09f977a9f412a1e4b4d45ad8de41 Mon Sep 17 00:00:00 2001 From: Phani Date: Wed, 25 Mar 2026 00:50:01 +0530 Subject: [PATCH 01/11] 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 + - - -
    - ${sanitizedHtml} -
    - -`; - const blob = new Blob([fullHtml], { type: "text/html;charset=utf-8" }); - saveAs(blob, "document.html"); - } catch (e) { - console.error("HTML export failed:", e); - alert("HTML export failed: " + e.message); - } - }); - - // ============================================ - // Page-Break Detection Functions (Story 1.1) - // ============================================ - - // Page configuration constants for A4 PDF export - const PAGE_CONFIG = { - a4Width: 210, // mm - a4Height: 297, // mm - margin: 15, // mm each side - contentWidth: 180, // 210 - 30 (margins) - contentHeight: 267, // 297 - 30 (margins) - windowWidth: 1000, // html2canvas config - scale: 2 // html2canvas scale factor - }; - - /** - * Task 1: Identifies all graphic elements that may need page-break handling - * @param {HTMLElement} container - The container element to search within - * @returns {Array} Array of {element, type} objects - */ - function identifyGraphicElements(container) { - const graphics = []; - - // Query for images - container.querySelectorAll('img').forEach(el => { - graphics.push({ element: el, type: 'img' }); - }); - - // Query for SVGs (Mermaid diagrams) - container.querySelectorAll('svg').forEach(el => { - graphics.push({ element: el, type: 'svg' }); - }); - - // Query for pre elements (code blocks) - container.querySelectorAll('pre').forEach(el => { - graphics.push({ element: el, type: 'pre' }); - }); - - // Query for tables - container.querySelectorAll('table').forEach(el => { - graphics.push({ element: el, type: 'table' }); - }); - - return graphics; - } - - /** - * Task 2: Calculates element positions relative to the container - * @param {Array} elements - Array of {element, type} objects - * @param {HTMLElement} container - The container element - * @returns {Array} Array with position data added - */ - function calculateElementPositions(elements, container) { - const containerRect = container.getBoundingClientRect(); - - return elements.map(item => { - const rect = item.element.getBoundingClientRect(); - const top = rect.top - containerRect.top; - const height = rect.height; - const bottom = top + height; - - return { - element: item.element, - type: item.type, - top: top, - height: height, - bottom: bottom - }; - }); - } - - /** - * Task 3: Calculates page boundary positions - * @param {number} totalHeight - Total height of content in pixels - * @param {number} elementWidth - Actual width of the rendered element in pixels - * @param {Object} pageConfig - Page configuration object - * @returns {Array} Array of y-coordinates where pages end - */ - function calculatePageBoundaries(totalHeight, elementWidth, pageConfig) { - // Calculate pixel height per page based on the element's actual width - // This must match how PDF pagination will split the canvas - // The aspect ratio of content area determines page height relative to width - const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; - const pageHeightPx = elementWidth * aspectRatio; - - const boundaries = []; - let y = pageHeightPx; - - while (y < totalHeight) { - boundaries.push(y); - y += pageHeightPx; - } - - return { boundaries, pageHeightPx }; - } - - /** - * Task 4: Detects which elements would be split across page boundaries - * @param {Array} elements - Array of elements with position data - * @param {Array} pageBoundaries - Array of page break y-coordinates - * @returns {Array} Array of split elements with additional split info - */ - function detectSplitElements(elements, pageBoundaries) { - // Handle edge case: empty elements array - if (!elements || elements.length === 0) { - return []; - } - - // Handle edge case: no page boundaries (single page) - if (!pageBoundaries || pageBoundaries.length === 0) { - return []; - } - - const splitElements = []; - - for (const item of elements) { - // Find which page the element starts on - let startPage = 0; - for (let i = 0; i < pageBoundaries.length; i++) { - if (item.top >= pageBoundaries[i]) { - startPage = i + 1; - } else { - break; - } - } - - // Find which page the element ends on - let endPage = 0; - for (let i = 0; i < pageBoundaries.length; i++) { - if (item.bottom > pageBoundaries[i]) { - endPage = i + 1; - } else { - break; - } - } - - // Element is split if it spans multiple pages - if (endPage > startPage) { - // Calculate overflow amount (how much crosses into next page) - const boundaryY = pageBoundaries[startPage] || pageBoundaries[0]; - const overflowAmount = item.bottom - boundaryY; - - splitElements.push({ - element: item.element, - type: item.type, - top: item.top, - height: item.height, - splitPageIndex: startPage, - overflowAmount: overflowAmount - }); - } - } - - return splitElements; - } - - /** - * Task 5: Main entry point for analyzing graphics for page breaks - * @param {HTMLElement} tempElement - The rendered content container - * @returns {Object} Analysis result with totalElements, splitElements, pageCount - */ - function analyzeGraphicsForPageBreaks(tempElement) { - try { - // Step 1: Identify all graphic elements - const graphics = identifyGraphicElements(tempElement); - console.log('Step 1 - Graphics found:', graphics.length, graphics.map(g => g.type)); - - // Step 2: Calculate positions for each element - const elementsWithPositions = calculateElementPositions(graphics, tempElement); - console.log('Step 2 - Element positions:', elementsWithPositions.map(e => ({ - type: e.type, - top: Math.round(e.top), - height: Math.round(e.height), - bottom: Math.round(e.bottom) - }))); - - // Step 3: Calculate page boundaries using the element's ACTUAL width - const totalHeight = tempElement.scrollHeight; - const elementWidth = tempElement.offsetWidth; - const { boundaries: pageBoundaries, pageHeightPx } = calculatePageBoundaries( - totalHeight, - elementWidth, - PAGE_CONFIG - ); - - console.log('Step 3 - Page boundaries:', { - elementWidth, - totalHeight, - pageHeightPx: Math.round(pageHeightPx), - boundaries: pageBoundaries.map(b => Math.round(b)) - }); - - // Step 4: Detect split elements - const splitElements = detectSplitElements(elementsWithPositions, pageBoundaries); - console.log('Step 4 - Split elements detected:', splitElements.length); - - // Calculate page count - const pageCount = pageBoundaries.length + 1; - - return { - totalElements: graphics.length, - splitElements: splitElements, - pageCount: pageCount, - pageBoundaries: pageBoundaries, - pageHeightPx: pageHeightPx - }; - } catch (error) { - console.error('Page-break analysis failed:', error); - return { - totalElements: 0, - splitElements: [], - pageCount: 1, - pageBoundaries: [], - pageHeightPx: 0 - }; - } - } - - // ============================================ - // End Page-Break Detection Functions - // ============================================ - - // ============================================ - // Page-Break Insertion Functions (Story 1.2) - // ============================================ - - // Threshold for whitespace optimization (30% of page height) - const PAGE_BREAK_THRESHOLD = 0.3; - - /** - * Task 3: Categorizes split elements by whether they fit on a single page - * @param {Array} splitElements - Array of split elements from detection - * @param {number} pageHeightPx - Page height in pixels - * @returns {Object} { fittingElements, oversizedElements } - */ - function categorizeBySize(splitElements, pageHeightPx) { - const fittingElements = []; - const oversizedElements = []; - - for (const item of splitElements) { - if (item.height <= pageHeightPx) { - fittingElements.push(item); - } else { - oversizedElements.push(item); - } - } - - return { fittingElements, oversizedElements }; - } - - /** - * Task 1: Inserts page breaks by adjusting margins for fitting elements - * @param {Array} fittingElements - Elements that fit on a single page - * @param {number} pageHeightPx - Page height in pixels - */ - function insertPageBreaks(fittingElements, pageHeightPx) { - for (const item of fittingElements) { - // Calculate where the current page ends - const currentPageBottom = (item.splitPageIndex + 1) * pageHeightPx; - - // Calculate remaining space on current page - const remainingSpace = currentPageBottom - item.top; - const remainingRatio = remainingSpace / pageHeightPx; - - console.log('Processing split element:', { - type: item.type, - top: Math.round(item.top), - height: Math.round(item.height), - splitPageIndex: item.splitPageIndex, - currentPageBottom: Math.round(currentPageBottom), - remainingSpace: Math.round(remainingSpace), - remainingRatio: remainingRatio.toFixed(2) - }); - - // Task 4: Whitespace optimization - // If remaining space is more than threshold and element almost fits, skip - // (Will be handled by Story 1.3 scaling instead) - if (remainingRatio > PAGE_BREAK_THRESHOLD) { - const scaledHeight = item.height * 0.9; // 90% scale - if (scaledHeight <= remainingSpace) { - console.log(' -> Skipping (can fit with 90% scaling)'); - continue; - } - } - - // Calculate margin needed to push element to next page - const marginNeeded = currentPageBottom - item.top + 5; // 5px buffer - - console.log(' -> Applying marginTop:', marginNeeded, 'px'); - - // Determine which element to apply margin to - // For SVG elements (Mermaid diagrams), apply to parent container for proper layout - let targetElement = item.element; - if (item.type === 'svg' && item.element.parentElement) { - targetElement = item.element.parentElement; - console.log(' -> Using parent element:', targetElement.tagName, targetElement.className); - } - - // Apply margin to push element to next page - const currentMargin = parseFloat(targetElement.style.marginTop) || 0; - targetElement.style.marginTop = `${currentMargin + marginNeeded}px`; - - console.log(' -> Element after margin:', targetElement.tagName, 'marginTop =', targetElement.style.marginTop); - } - } - - /** - * Task 2: Applies page breaks with cascading adjustment handling - * @param {HTMLElement} tempElement - The rendered content container - * @param {Object} pageConfig - Page configuration object (unused, kept for API compatibility) - * @param {number} maxIterations - Maximum iterations to prevent infinite loops - * @returns {Object} Final analysis result - */ - function applyPageBreaksWithCascade(tempElement, pageConfig, maxIterations = 10) { - let iteration = 0; - let analysis; - let previousSplitCount = -1; - - do { - // Re-analyze after each adjustment - analysis = analyzeGraphicsForPageBreaks(tempElement); - - // Use pageHeightPx from analysis (calculated from actual element width) - const pageHeightPx = analysis.pageHeightPx; - - // Categorize elements by size - const { fittingElements, oversizedElements } = categorizeBySize( - analysis.splitElements, - pageHeightPx - ); - - // Store oversized elements for Story 1.3 - analysis.oversizedElements = oversizedElements; - - // If no fitting elements need adjustment, we're done - if (fittingElements.length === 0) { - break; - } - - // Check if we're making progress (prevent infinite loops) - if (fittingElements.length === previousSplitCount) { - console.warn('Page-break adjustment not making progress, stopping'); - break; - } - previousSplitCount = fittingElements.length; - - // Apply page breaks to fitting elements - insertPageBreaks(fittingElements, pageHeightPx); - iteration++; - - } while (iteration < maxIterations); - - if (iteration >= maxIterations) { - console.warn('Page-break stabilization reached max iterations:', maxIterations); - } - - console.log('Page-break cascade complete:', { - iterations: iteration, - finalSplitCount: analysis.splitElements.length, - oversizedCount: analysis.oversizedElements ? analysis.oversizedElements.length : 0 - }); - - return analysis; - } - - // ============================================ - // End Page-Break Insertion Functions - // ============================================ - - // ============================================ - // Oversized Graphics Scaling Functions (Story 1.3) - // ============================================ - - // Minimum scale factor to maintain readability (50%) - const MIN_SCALE_FACTOR = 0.5; - - /** - * Task 1 & 2: Calculates scale factor with minimum enforcement - * @param {number} elementHeight - Original height of element in pixels - * @param {number} availableHeight - Available page height in pixels - * @param {number} buffer - Small buffer to prevent edge overflow - * @returns {Object} { scaleFactor, wasClampedToMin } - */ - function calculateScaleFactor(elementHeight, availableHeight, buffer = 5) { - const targetHeight = availableHeight - buffer; - let scaleFactor = targetHeight / elementHeight; - let wasClampedToMin = false; - - // Enforce minimum scale for readability - if (scaleFactor < MIN_SCALE_FACTOR) { - console.warn( - `Warning: Large graphic requires ${(scaleFactor * 100).toFixed(0)}% scaling. ` + - `Clamping to minimum ${MIN_SCALE_FACTOR * 100}%. Content may be cut off.` - ); - scaleFactor = MIN_SCALE_FACTOR; - wasClampedToMin = true; - } - - return { scaleFactor, wasClampedToMin }; - } - - /** - * Task 3: Applies CSS transform scaling to an element - * @param {HTMLElement} element - The element to scale - * @param {number} scaleFactor - Scale factor (0.5 = 50%) - * @param {string} elementType - Type of element (svg, pre, img, table) - */ - function applyGraphicScaling(element, scaleFactor, elementType) { - // Get original dimensions before transform - const originalHeight = element.offsetHeight; - - // Task 4: Handle SVG elements (Mermaid diagrams) - if (elementType === 'svg') { - // Remove max-width constraint that may interfere - element.style.maxWidth = 'none'; - } - - // Apply CSS transform - element.style.transform = `scale(${scaleFactor})`; - element.style.transformOrigin = 'top left'; - - // Calculate margin adjustment to collapse visual space - const scaledHeight = originalHeight * scaleFactor; - const marginAdjustment = originalHeight - scaledHeight; - - // Apply negative margin to pull subsequent content up - element.style.marginBottom = `-${marginAdjustment}px`; - } - - /** - * Task 6: Handles all oversized elements by applying appropriate scaling - * @param {Array} oversizedElements - Array of oversized element data - * @param {number} pageHeightPx - Page height in pixels - */ - function handleOversizedElements(oversizedElements, pageHeightPx) { - if (!oversizedElements || oversizedElements.length === 0) { - return; - } - - let scaledCount = 0; - let clampedCount = 0; - - for (const item of oversizedElements) { - // Calculate required scale factor - const { scaleFactor, wasClampedToMin } = calculateScaleFactor( - item.height, - pageHeightPx - ); - - // Apply scaling to the element - applyGraphicScaling(item.element, scaleFactor, item.type); - - scaledCount++; - if (wasClampedToMin) { - clampedCount++; - } - } - - console.log('Oversized graphics scaling complete:', { - totalScaled: scaledCount, - clampedToMinimum: clampedCount - }); - } - - // ============================================ - // End Oversized Graphics Scaling Functions - // ============================================ - - exportPdf.addEventListener("click", async function () { - try { - const originalText = exportPdf.innerHTML; - exportPdf.innerHTML = ' Generating...'; - exportPdf.disabled = true; - - const progressContainer = document.createElement('div'); - progressContainer.style.position = 'fixed'; - progressContainer.style.top = '50%'; - progressContainer.style.left = '50%'; - progressContainer.style.transform = 'translate(-50%, -50%)'; - progressContainer.style.padding = '15px 20px'; - progressContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; - progressContainer.style.color = 'white'; - progressContainer.style.borderRadius = '5px'; - progressContainer.style.zIndex = '9999'; - progressContainer.style.textAlign = 'center'; - - const statusText = document.createElement('div'); - statusText.textContent = 'Generating PDF...'; - progressContainer.appendChild(statusText); - document.body.appendChild(progressContainer); - - const markdown = markdownEditor.value; - const html = marked.parse(markdown); - const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath'], - ADD_ATTR: ['id', 'class', 'style', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start'] - }); - - const tempElement = document.createElement("div"); - tempElement.className = "markdown-body pdf-export"; - tempElement.innerHTML = sanitizedHtml; - tempElement.style.padding = "20px"; - tempElement.style.width = "210mm"; - tempElement.style.margin = "0 auto"; - tempElement.style.fontSize = "14px"; - tempElement.style.position = "fixed"; - tempElement.style.left = "-9999px"; - tempElement.style.top = "0"; - - const currentTheme = document.documentElement.getAttribute("data-theme"); - tempElement.style.backgroundColor = currentTheme === "dark" ? "#0d1117" : "#ffffff"; - tempElement.style.color = currentTheme === "dark" ? "#c9d1d9" : "#24292e"; - - document.body.appendChild(tempElement); - - await new Promise(resolve => setTimeout(resolve, 200)); - - try { - await mermaid.run({ - nodes: tempElement.querySelectorAll('.mermaid'), - suppressErrors: true - }); - } catch (mermaidError) { - console.warn("Mermaid rendering issue:", mermaidError); - } - - if (window.MathJax) { - try { - await MathJax.typesetPromise([tempElement]); - } catch (mathJaxError) { - console.warn("MathJax rendering issue:", mathJaxError); - } - - // Hide MathJax assistive elements that cause duplicate text in PDF - // These are screen reader elements that html2canvas captures as visible - // Use multiple CSS properties to ensure html2canvas doesn't render them - const assistiveElements = tempElement.querySelectorAll('mjx-assistive-mml'); - assistiveElements.forEach(el => { - el.style.display = 'none'; - el.style.visibility = 'hidden'; - el.style.position = 'absolute'; - el.style.width = '0'; - el.style.height = '0'; - el.style.overflow = 'hidden'; - el.remove(); // Remove entirely from DOM - }); - - // Also hide any MathJax script elements that might contain source - const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]'); - mathScripts.forEach(el => el.remove()); - } - - await new Promise(resolve => setTimeout(resolve, 500)); - - // Analyze and apply page-breaks for graphics (Story 1.1 + 1.2) - const pageBreakAnalysis = applyPageBreaksWithCascade(tempElement, PAGE_CONFIG); - - // Scale oversized graphics that can't fit on a single page (Story 1.3) - if (pageBreakAnalysis.oversizedElements && pageBreakAnalysis.pageHeightPx) { - handleOversizedElements(pageBreakAnalysis.oversizedElements, pageBreakAnalysis.pageHeightPx); - } - - const pdfOptions = { - orientation: 'portrait', - unit: 'mm', - format: 'a4', - compress: true, - hotfixes: ["px_scaling"] - }; - - const pdf = new jspdf.jsPDF(pdfOptions); - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 15; - const contentWidth = pageWidth - (margin * 2); - - const canvas = await html2canvas(tempElement, { - scale: 2, - useCORS: true, - allowTaint: true, - logging: false, - windowWidth: 1000, - windowHeight: tempElement.scrollHeight - }); - - const scaleFactor = canvas.width / contentWidth; - const imgHeight = canvas.height / scaleFactor; - const pagesCount = Math.ceil(imgHeight / (pageHeight - margin * 2)); - - for (let page = 0; page < pagesCount; page++) { - if (page > 0) pdf.addPage(); - - const sourceY = page * (pageHeight - margin * 2) * scaleFactor; - const sourceHeight = Math.min(canvas.height - sourceY, (pageHeight - margin * 2) * scaleFactor); - const destHeight = sourceHeight / scaleFactor; - - const pageCanvas = document.createElement('canvas'); - pageCanvas.width = canvas.width; - pageCanvas.height = sourceHeight; - - const ctx = pageCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight); - - const imgData = pageCanvas.toDataURL('image/png'); - pdf.addImage(imgData, 'PNG', margin, margin, contentWidth, destHeight); - } - - pdf.save("document.pdf"); - - statusText.textContent = 'Download successful!'; - setTimeout(() => { - document.body.removeChild(progressContainer); - }, 1500); - - document.body.removeChild(tempElement); - exportPdf.innerHTML = originalText; - exportPdf.disabled = false; - - } catch (error) { - console.error("PDF export failed:", error); - alert("PDF export failed: " + error.message); - exportPdf.innerHTML = ' Export'; - exportPdf.disabled = false; - - const progressContainer = document.querySelector('div[style*="Preparing PDF"]'); - if (progressContainer) { - document.body.removeChild(progressContainer); - } - } - }); - - copyMarkdownButton.addEventListener("click", function () { - try { - const markdownText = markdownEditor.value; - copyToClipboard(markdownText); - } catch (e) { - console.error("Copy failed:", e); - alert("Failed to copy Markdown: " + e.message); - } - }); - - async function copyToClipboard(text) { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - showCopiedMessage(); - } else { - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.opacity = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - const successful = document.execCommand("copy"); - document.body.removeChild(textArea); - if (successful) { - showCopiedMessage(); - } else { - throw new Error("Copy command was unsuccessful"); - } - } - } catch (err) { - console.error("Copy failed:", err); - alert("Failed to copy HTML: " + err.message); - } - } - - function showCopiedMessage() { - const originalText = copyMarkdownButton.innerHTML; - copyMarkdownButton.innerHTML = ' Copied!'; - - setTimeout(() => { - copyMarkdownButton.innerHTML = originalText; - }, 2000); - } - - // ============================================ - // Share via URL (pako compression + base64url) - // ============================================ - - const MAX_SHARE_URL_LENGTH = 32000; - - function encodeMarkdownForShare(text) { - const compressed = pako.deflate(new TextEncoder().encode(text)); - const chunkSize = 0x8000; - let binary = ''; - for (let i = 0; i < compressed.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize)); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - } - - function decodeMarkdownFromShare(encoded) { - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); - return new TextDecoder().decode(pako.inflate(bytes)); - } - - function copyShareUrl(btn) { - const markdownText = markdownEditor.value; - let encoded; - try { - encoded = encodeMarkdownForShare(markdownText); - } catch (e) { - console.error("Share encoding failed:", e); - alert("Failed to encode content for sharing: " + e.message); - return; - } - - const shareUrl = window.location.origin + window.location.pathname + '#share=' + encoded; - const tooLarge = shareUrl.length > MAX_SHARE_URL_LENGTH; - - const originalHTML = btn.innerHTML; - const copiedHTML = ' Copied!'; - - function onCopied() { - if (!tooLarge) { - window.location.hash = 'share=' + encoded; - } - btn.innerHTML = copiedHTML; - setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); - } - - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(shareUrl).then(onCopied).catch(() => { - // clipboard.writeText failed; nothing further to do in secure context - }); - } else { - try { - const tempInput = document.createElement("textarea"); - tempInput.value = shareUrl; - document.body.appendChild(tempInput); - tempInput.select(); - document.execCommand("copy"); - document.body.removeChild(tempInput); - onCopied(); - } catch (_) { - // copy failed silently - } - } - } - - shareButton.addEventListener("click", function () { copyShareUrl(shareButton); }); - mobileShareButton.addEventListener("click", function () { copyShareUrl(mobileShareButton); }); - - function loadFromShareHash() { - if (typeof pako === 'undefined') return; - const hash = window.location.hash; - if (!hash.startsWith('#share=')) return; - const encoded = hash.slice('#share='.length); - if (!encoded) return; - try { - const decoded = decodeMarkdownFromShare(encoded); - markdownEditor.value = decoded; - renderMarkdown(); - saveCurrentTabState(); - } catch (e) { - console.error("Failed to load shared content:", e); - alert("The shared URL could not be decoded. It may be corrupted or incomplete."); - } - } - - loadFromShareHash(); - - const dropEvents = ["dragenter", "dragover", "dragleave", "drop"]; - - dropEvents.forEach((eventName) => { - dropzone.addEventListener(eventName, preventDefaults, false); - document.body.addEventListener(eventName, preventDefaults, false); - }); - - function preventDefaults(e) { - e.preventDefault(); - e.stopPropagation(); - } - - ["dragenter", "dragover"].forEach((eventName) => { - dropzone.addEventListener(eventName, highlight, false); - }); - - ["dragleave", "drop"].forEach((eventName) => { - dropzone.addEventListener(eventName, unhighlight, false); - }); - - function highlight() { - dropzone.classList.add("active"); - } - - function unhighlight() { - dropzone.classList.remove("active"); - } - - dropzone.addEventListener("drop", handleDrop, false); - dropzone.addEventListener("click", function (e) { - if (e.target !== closeDropzoneBtn && !closeDropzoneBtn.contains(e.target)) { - fileInput.click(); - } - }); - closeDropzoneBtn.addEventListener("click", function(e) { - e.stopPropagation(); - dropzone.style.display = "none"; - }); - - function handleDrop(e) { - const dt = e.dataTransfer; - const files = dt.files; - if (files.length) { - const file = files[0]; - const isMarkdownFile = - file.type === "text/markdown" || - file.name.endsWith(".md") || - file.name.endsWith(".markdown"); - if (isMarkdownFile) { - importMarkdownFile(file); - } else { - alert("Please upload a Markdown file (.md or .markdown)"); - } - } - } - - document.addEventListener("keydown", function (e) { - if ((e.ctrlKey || e.metaKey) && e.key === "s") { - e.preventDefault(); - exportMd.click(); - } - if ((e.ctrlKey || e.metaKey) && e.key === "c") { - const activeEl = document.activeElement; - const isTextControl = activeEl && (activeEl.tagName === "TEXTAREA" || activeEl.tagName === "INPUT"); - const hasSelection = window.getSelection && window.getSelection().toString().trim().length > 0; - if (!isTextControl && !hasSelection) { - e.preventDefault(); - copyMarkdownButton.click(); - } - } - // Story 1.2: Only allow sync toggle shortcut when in split view - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "S") { - e.preventDefault(); - if (currentViewMode === 'split') { - toggleSyncScrolling(); - } - } - // New tab - if ((e.ctrlKey || e.metaKey) && e.key === "t") { - e.preventDefault(); - newTab(); - } - // Close tab - if ((e.ctrlKey || e.metaKey) && e.key === "w") { - e.preventDefault(); - closeTab(activeTabId); - } - // Close Mermaid zoom modal with Escape - if (e.key === "Escape") { - closeMermaidModal(); - } - }); - - document.getElementById('tab-reset-btn').addEventListener('click', function() { - resetAllTabs(); - }); - - // ======================================== - // MERMAID DIAGRAM TOOLBAR - // ======================================== - - /** - * Serialises an SVG element to a data URL suitable for use as an image source. - * Inline styles and dimensions are preserved so the PNG matches the rendered diagram. - */ - function svgToDataUrl(svgEl) { - const clone = svgEl.cloneNode(true); - // Ensure explicit width/height so the canvas has the right dimensions - const bbox = svgEl.getBoundingClientRect(); - if (!clone.getAttribute('width')) clone.setAttribute('width', Math.round(bbox.width)); - if (!clone.getAttribute('height')) clone.setAttribute('height', Math.round(bbox.height)); - const serialized = new XMLSerializer().serializeToString(clone); - return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(serialized); - } - - /** - * Renders an SVG element onto a canvas and resolves with the canvas. - */ - function svgToCanvas(svgEl) { - return new Promise((resolve, reject) => { - const bbox = svgEl.getBoundingClientRect(); - const scale = window.devicePixelRatio || 1; - const width = Math.max(Math.round(bbox.width), 1); - const height = Math.max(Math.round(bbox.height), 1); - - const canvas = document.createElement('canvas'); - canvas.width = width * scale; - canvas.height = height * scale; - const ctx = canvas.getContext('2d'); - ctx.scale(scale, scale); - - // Fill background matching current theme using the CSS variable value - const bgColor = getComputedStyle(document.documentElement) - .getPropertyValue('--bg-color').trim() || '#ffffff'; - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, width, height); - - const img = new Image(); - img.onload = () => { ctx.drawImage(img, 0, 0, width, height); resolve(canvas); }; - img.onerror = reject; - img.src = svgToDataUrl(svgEl); - }); - } - - /** Downloads the diagram in the given container as a PNG file. */ - async function downloadMermaidPng(container, btn) { - const svgEl = container.querySelector('svg'); - if (!svgEl) return; - const original = btn.innerHTML; - btn.innerHTML = ''; - try { - const canvas = await svgToCanvas(svgEl); - canvas.toBlob(blob => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `diagram-${Date.now()}.png`; - a.click(); - URL.revokeObjectURL(url); - btn.innerHTML = ''; - setTimeout(() => { btn.innerHTML = original; }, 1500); - }, 'image/png'); - } catch (e) { - console.error('Mermaid PNG export failed:', e); - btn.innerHTML = original; - } - } - - /** Copies the diagram in the given container as a PNG image to the clipboard. */ - async function copyMermaidImage(container, btn) { - const svgEl = container.querySelector('svg'); - if (!svgEl) return; - const original = btn.innerHTML; - btn.innerHTML = ''; - try { - const canvas = await svgToCanvas(svgEl); - canvas.toBlob(async blob => { - try { - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': blob }) - ]); - btn.innerHTML = ' Copied!'; - } catch (clipErr) { - console.error('Clipboard write failed:', clipErr); - btn.innerHTML = ''; - } - setTimeout(() => { btn.innerHTML = original; }, 1800); - }, 'image/png'); - } catch (e) { - console.error('Mermaid copy failed:', e); - btn.innerHTML = original; - } - } - - /** Downloads the SVG source of a diagram. */ - function downloadMermaidSvg(container, btn) { - const svgEl = container.querySelector('svg'); - if (!svgEl) return; - const clone = svgEl.cloneNode(true); - const serialized = new XMLSerializer().serializeToString(clone); - const blob = new Blob([serialized], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `diagram-${Date.now()}.svg`; - a.click(); - URL.revokeObjectURL(url); - const original = btn.innerHTML; - btn.innerHTML = ''; - setTimeout(() => { btn.innerHTML = original; }, 1500); - } - - // ---- Zoom modal state ---- - let modalZoomScale = 1; - let modalPanX = 0; - let modalPanY = 0; - let modalIsDragging = false; - let modalDragStart = { x: 0, y: 0 }; - let modalCurrentSvgEl = null; - - const mermaidZoomModal = document.getElementById('mermaid-zoom-modal'); - const mermaidModalDiagram = document.getElementById('mermaid-modal-diagram'); - - function applyModalTransform() { - if (modalCurrentSvgEl) { - modalCurrentSvgEl.style.transform = - `translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoomScale})`; - } - } - - function closeMermaidModal() { - if (!mermaidZoomModal.classList.contains('active')) return; - mermaidZoomModal.classList.remove('active'); - mermaidModalDiagram.innerHTML = ''; - modalCurrentSvgEl = null; - modalZoomScale = 1; - modalPanX = 0; - modalPanY = 0; - } - - /** Opens the zoom modal with the SVG from the given container. */ - function openMermaidZoomModal(container) { - const svgEl = container.querySelector('svg'); - if (!svgEl) return; - - mermaidModalDiagram.innerHTML = ''; - modalZoomScale = 1; - modalPanX = 0; - modalPanY = 0; - - const svgClone = svgEl.cloneNode(true); - // Remove fixed dimensions so it sizes naturally inside the modal - svgClone.removeAttribute('width'); - svgClone.removeAttribute('height'); - svgClone.style.width = 'auto'; - svgClone.style.height = 'auto'; - svgClone.style.maxWidth = '80vw'; - svgClone.style.maxHeight = '60vh'; - svgClone.style.transformOrigin = 'center'; - mermaidModalDiagram.appendChild(svgClone); - modalCurrentSvgEl = svgClone; - - mermaidZoomModal.classList.add('active'); - } - - // Modal close button - document.getElementById('mermaid-modal-close').addEventListener('click', closeMermaidModal); - // Click backdrop to close - mermaidZoomModal.addEventListener('click', function(e) { - if (e.target === mermaidZoomModal) closeMermaidModal(); - }); - - // Zoom controls - document.getElementById('mermaid-modal-zoom-in').addEventListener('click', () => { - modalZoomScale = Math.min(modalZoomScale + 0.25, 10); - applyModalTransform(); - }); - document.getElementById('mermaid-modal-zoom-out').addEventListener('click', () => { - modalZoomScale = Math.max(modalZoomScale - 0.25, 0.1); - applyModalTransform(); - }); - document.getElementById('mermaid-modal-zoom-reset').addEventListener('click', () => { - modalZoomScale = 1; modalPanX = 0; modalPanY = 0; - applyModalTransform(); - }); - - // Mouse-wheel zoom inside modal - mermaidModalDiagram.addEventListener('wheel', function(e) { - e.preventDefault(); - const delta = e.deltaY < 0 ? 0.15 : -0.15; - modalZoomScale = Math.min(Math.max(modalZoomScale + delta, 0.1), 10); - applyModalTransform(); - }, { passive: false }); - - // Drag to pan inside modal - mermaidModalDiagram.addEventListener('mousedown', function(e) { - modalIsDragging = true; - modalDragStart = { x: e.clientX - modalPanX, y: e.clientY - modalPanY }; - mermaidModalDiagram.classList.add('dragging'); - }); - document.addEventListener('mousemove', function(e) { - if (!modalIsDragging) return; - modalPanX = e.clientX - modalDragStart.x; - modalPanY = e.clientY - modalDragStart.y; - applyModalTransform(); - }); - document.addEventListener('mouseup', function() { - if (modalIsDragging) { - modalIsDragging = false; - mermaidModalDiagram.classList.remove('dragging'); - } - }); - - // Modal download buttons (operate on the currently displayed SVG) - document.getElementById('mermaid-modal-download-png').addEventListener('click', async function() { - if (!modalCurrentSvgEl) return; - const btn = this; - const original = btn.innerHTML; - btn.innerHTML = ''; - try { - // Use the original SVG (with dimensions) for proper PNG rendering - const canvas = await svgToCanvas(modalCurrentSvgEl); - canvas.toBlob(blob => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `diagram-${Date.now()}.png`; a.click(); - URL.revokeObjectURL(url); - btn.innerHTML = ''; - setTimeout(() => { btn.innerHTML = original; }, 1500); - }, 'image/png'); - } catch (e) { - console.error('Modal PNG export failed:', e); - btn.innerHTML = original; - } - }); - - document.getElementById('mermaid-modal-copy').addEventListener('click', async function() { - if (!modalCurrentSvgEl) return; - const btn = this; - const original = btn.innerHTML; - btn.innerHTML = ''; - try { - const canvas = await svgToCanvas(modalCurrentSvgEl); - canvas.toBlob(async blob => { - try { - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': blob }) - ]); - btn.innerHTML = ' Copied!'; - } catch (clipErr) { - console.error('Clipboard write failed:', clipErr); - btn.innerHTML = ''; - } - setTimeout(() => { btn.innerHTML = original; }, 1800); - }, 'image/png'); - } catch (e) { - console.error('Modal copy failed:', e); - btn.innerHTML = original; - } - }); - - document.getElementById('mermaid-modal-download-svg').addEventListener('click', function() { - if (!modalCurrentSvgEl) return; - const serialized = new XMLSerializer().serializeToString(modalCurrentSvgEl); - const blob = new Blob([serialized], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `diagram-${Date.now()}.svg`; a.click(); - URL.revokeObjectURL(url); - }); - - /** - * Adds the hover toolbar to every rendered Mermaid container. - * Safe to call multiple times – existing toolbars are not duplicated. - */ - function addMermaidToolbars() { - markdownPreview.querySelectorAll('.mermaid-container').forEach(container => { - if (container.querySelector('.mermaid-toolbar')) return; // already added - const svgEl = container.querySelector('svg'); - if (!svgEl) return; // diagram not yet rendered - - const toolbar = document.createElement('div'); - toolbar.className = 'mermaid-toolbar'; - toolbar.setAttribute('aria-label', 'Diagram actions'); - - const btnZoom = document.createElement('button'); - btnZoom.className = 'mermaid-toolbar-btn'; - btnZoom.title = 'Zoom diagram'; - btnZoom.setAttribute('aria-label', 'Zoom diagram'); - btnZoom.innerHTML = ''; - btnZoom.addEventListener('click', () => openMermaidZoomModal(container)); - - const btnPng = document.createElement('button'); - btnPng.className = 'mermaid-toolbar-btn'; - btnPng.title = 'Download PNG'; - btnPng.setAttribute('aria-label', 'Download PNG'); - btnPng.innerHTML = ' PNG'; - btnPng.addEventListener('click', () => downloadMermaidPng(container, btnPng)); - - const btnCopy = document.createElement('button'); - btnCopy.className = 'mermaid-toolbar-btn'; - btnCopy.title = 'Copy image to clipboard'; - btnCopy.setAttribute('aria-label', 'Copy image to clipboard'); - btnCopy.innerHTML = ' Copy'; - btnCopy.addEventListener('click', () => copyMermaidImage(container, btnCopy)); - - const btnSvg = document.createElement('button'); - btnSvg.className = 'mermaid-toolbar-btn'; - btnSvg.title = 'Download SVG'; - btnSvg.setAttribute('aria-label', 'Download SVG'); - btnSvg.innerHTML = ' SVG'; - btnSvg.addEventListener('click', () => downloadMermaidSvg(container, btnSvg)); - - toolbar.appendChild(btnZoom); - toolbar.appendChild(btnCopy); - toolbar.appendChild(btnPng); - toolbar.appendChild(btnSvg); - container.appendChild(toolbar); - }); - } -}); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css deleted file mode 100644 index e62cf8e..0000000 --- a/desktop-app/resources/styles.css +++ /dev/null @@ -1,1708 +0,0 @@ -:root { - --bg-color: #ffffff; - --editor-bg: #f6f8fa; - --preview-bg: #ffffff; /* Preview background for light mode */ - --text-color: #24292e; - --preview-text-color: #24292e; /* Text color for preview in light mode */ - --border-color: #e1e4e8; - --header-bg: #f6f8fa; - --button-bg: #f6f8fa; - --button-hover: #e1e4e8; - --button-active: #d1d5da; - --dropzone-bg: rgba(255, 255, 255, 0.8); - --scrollbar-thumb: #c1c1c1; - --scrollbar-track: #f1f1f1; - --accent-color: #0366d6; - --table-bg: #ffffff; /* Table background for light mode */ - --code-bg: #f6f8fa; /* Code block background for light mode */ -} - -[data-theme="dark"] { - --bg-color: #0d1117; - --editor-bg: #161b22; - --preview-bg: #0d1117; /* Preview background for dark mode */ - --text-color: #c9d1d9; - --preview-text-color: #c9d1d9; /* Text color for preview in dark mode */ - --border-color: #30363d; - --header-bg: #161b22; - --button-bg: #21262d; - --button-hover: #30363d; - --button-active: #3b434b; - --dropzone-bg: rgba(13, 17, 23, 0.8); - --scrollbar-thumb: #484f58; - --scrollbar-track: #21262d; - --accent-color: #58a6ff; - --table-bg: #161b22; /* Table background for dark mode */ - --code-bg: #161b22; /* Code block background for dark mode */ -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background-color: var(--bg-color); - color: var(--text-color); - transition: background-color 0.3s ease, color 0.3s ease; - min-height: 100vh; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; -} - -.app-header { - background-color: var(--header-bg); - border-bottom: 1px solid var(--border-color); - padding: 1rem; - transition: background-color 0.3s ease; - position: relative; - z-index: 100; - flex-shrink: 0; -} - -.app-container { - height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.content-container { - display: flex; - flex: 1; - overflow: hidden; -} - -.editor-pane, .preview-pane { - flex: 1; - padding: 20px; - overflow-y: auto; - position: relative; - transition: background-color 0.3s ease; -} - -.editor-pane { - background-color: var(--editor-bg); - border-right: 1px solid var(--border-color); - padding-right: 0px; -} - -.preview-pane { - background-color: var(--preview-bg); /* Using the new variable for preview background */ -} - -/* Custom scrollbar */ -.editor-pane::-webkit-scrollbar, -.preview-pane::-webkit-scrollbar, -#markdown-editor::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.editor-pane::-webkit-scrollbar-track, -.preview-pane::-webkit-scrollbar-track, -#markdown-editor::-webkit-scrollbar-track { - background: var(--scrollbar-track); -} - -.editor-pane::-webkit-scrollbar-thumb, -.preview-pane::-webkit-scrollbar-thumb, -#markdown-editor::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 4px; -} - -.editor-pane::-webkit-scrollbar-thumb:hover, -.preview-pane::-webkit-scrollbar-thumb:hover, -#markdown-editor::-webkit-scrollbar-thumb:hover { - background: var(--button-active); -} - -#markdown-editor { - width: 100%; - height: 100%; - border: none; - background-color: var(--editor-bg); - color: var(--text-color); - resize: none; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - font-size: 14px; - line-height: 1.5; - padding: 10px; - transition: background-color 0.3s ease, color 0.3s ease; - overflow-y: auto; -} - -#markdown-editor:focus { - outline: none; -} - -.preview-pane { - padding: 20px; -} - -.markdown-body { - padding: 20px; - width: 100%; - background-color: var(--preview-bg); /* Ensuring the markdown content matches preview background */ - color: var(--preview-text-color); /* Using specific text color for preview content */ -} - -/* Style tables in light mode */ -.markdown-body table { - background-color: var(--table-bg); - border-color: var(--border-color); -} - -.markdown-body table tr { - background-color: var(--table-bg); - border-top: 1px solid var(--border-color); -} - -.markdown-body table tr:nth-child(2n) { - background-color: var(--bg-color); -} - -/* Style code blocks in light mode */ -.markdown-body pre { - background-color: var(--code-bg); - border-radius: 6px; -} - -.markdown-body code { - background-color: var(--code-bg); - border-radius: 3px; - padding: 0.2em 0.4em; -} - -.toolbar { - display: flex; - gap: 8px; -} - -.tool-button { - background-color: var(--button-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - border-radius: 6px; - padding: 6px 12px; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s ease; -} - -.tool-button:hover { - background-color: var(--button-hover); -} - -.tool-button:active { - background-color: var(--button-active); -} - -.tool-button i { - font-size: 16px; -} - -.file-input { - display: none; -} - -.dropzone { - border: 2px dashed var(--border-color); - border-radius: 6px; - padding: 20px; - text-align: center; - margin-bottom: 20px; - cursor: pointer; - transition: all 0.3s ease; - background-color: var(--dropzone-bg); -} - -.dropzone.active { - border-color: var(--accent-color); - background-color: rgba(var(--accent-color), 0.05); -} - -.dropzone p { - transition: transform 0.2s ease; -} - -.dropzone:hover p { - transform: scale(1.02); -} - -/* Dropdown improvements */ -.dropdown-menu { - background-color: var(--bg-color); - border-color: var(--border-color); -} - -.dropdown-item { - color: var(--text-color); -} - -.dropdown-item:hover, .dropdown-item:focus { - background-color: var(--button-hover); - color: var(--text-color); -} - -/* Responsive design for mobile */ -@media (max-width: 1080px) { - .content-container { - flex-direction: column; - } - - .editor-pane, .preview-pane { - flex: none; - height: 50%; - border-right: none; - } - - .editor-pane { - border-bottom: 1px solid var(--border-color); - } - - .toolbar { - flex-wrap: wrap; - justify-content: center; - gap: 1rem; - } -} - -/* Loading indicators */ -.loading { - opacity: 0.6; - pointer-events: none; -} - -/* Focus outline for accessibility */ -button:focus, -a:focus { - outline: 2px solid var(--accent-color); - outline-offset: 2px; -} - -/* Animation for copied message */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -/* Tooltip styles */ -.tooltip { - position: absolute; - background: var(--button-bg); - border: 1px solid var(--border-color); - padding: 5px 8px; - border-radius: 4px; - font-size: 12px; - z-index: 1000; - animation: fadeIn 0.2s ease; -} - -/* Styles for GitHub markdown preview light mode */ -.markdown-body { - color-scheme: light; - --color-prettylights-syntax-comment: #6a737d; - --color-prettylights-syntax-constant: #005cc5; - --color-prettylights-syntax-entity: #6f42c1; - --color-prettylights-syntax-storage-modifier-import: #24292e; - --color-prettylights-syntax-entity-tag: #22863a; - --color-prettylights-syntax-keyword: #d73a49; - --color-prettylights-syntax-string: #032f62; - --color-prettylights-syntax-variable: #e36209; - --color-prettylights-syntax-brackethighlighter-unmatched: #b31d28; - --color-prettylights-syntax-invalid-illegal-text: #fafbfc; - --color-prettylights-syntax-invalid-illegal-bg: #b31d28; - --color-prettylights-syntax-carriage-return-text: #fafbfc; - --color-prettylights-syntax-carriage-return-bg: #d73a49; - --color-prettylights-syntax-string-regexp: #22863a; - --color-prettylights-syntax-markup-list: #735c0f; - --color-prettylights-syntax-markup-heading: #005cc5; - --color-prettylights-syntax-markup-italic: #24292e; - --color-prettylights-syntax-markup-bold: #24292e; - --color-prettylights-syntax-markup-deleted-text: #b31d28; - --color-prettylights-syntax-markup-deleted-bg: #ffeef0; - --color-prettylights-syntax-markup-inserted-text: #22863a; - --color-prettylights-syntax-markup-inserted-bg: #f0fff4; - --color-prettylights-syntax-markup-changed-text: #e36209; - --color-prettylights-syntax-markup-changed-bg: #ffebda; - --color-prettylights-syntax-markup-ignored-text: #f6f8fa; - --color-prettylights-syntax-markup-ignored-bg: #005cc5; - --color-prettylights-syntax-meta-diff-range: #6f42c1; - --color-prettylights-syntax-brackethighlighter-angle: #586069; - --color-prettylights-syntax-sublimelinter-gutter-mark: #e1e4e8; - --color-prettylights-syntax-constant-other-reference-link: #032f62; - --color-fg-default: #24292e; - --color-fg-muted: #586069; - --color-fg-subtle: #6a737d; - --color-canvas-default: #ffffff; - --color-canvas-subtle: #f6f8fa; - --color-border-default: #e1e4e8; - --color-border-muted: #eaecef; - --color-neutral-muted: rgba(175,184,193,0.2); - --color-accent-fg: #0366d6; - --color-accent-emphasis: #0366d6; - --color-attention-subtle: #fff5b1; - --color-danger-fg: #d73a49; -} - -/* Styles for GitHub markdown preview dark mode */ -[data-theme="dark"] .markdown-body { - color-scheme: dark; - --color-prettylights-syntax-comment: #8b949e; - --color-prettylights-syntax-constant: #79c0ff; - --color-prettylights-syntax-entity: #d2a8ff; - --color-prettylights-syntax-storage-modifier-import: #c9d1d9; - --color-prettylights-syntax-entity-tag: #7ee787; - --color-prettylights-syntax-keyword: #ff7b72; - --color-prettylights-syntax-string: #a5d6ff; - --color-prettylights-syntax-variable: #ffa657; - --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; - --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; - --color-prettylights-syntax-invalid-illegal-bg: #8e1519; - --color-prettylights-syntax-carriage-return-text: #f0f6fc; - --color-prettylights-syntax-carriage-return-bg: #b62324; - --color-prettylights-syntax-string-regexp: #7ee787; - --color-prettylights-syntax-markup-list: #f2cc60; - --color-prettylights-syntax-markup-heading: #1f6feb; - --color-prettylights-syntax-markup-italic: #c9d1d9; - --color-prettylights-syntax-markup-bold: #c9d1d9; - --color-prettylights-syntax-markup-deleted-text: #ffdcd7; - --color-prettylights-syntax-markup-deleted-bg: #67060c; - --color-prettylights-syntax-markup-inserted-text: #aff5b4; - --color-prettylights-syntax-markup-inserted-bg: #033a16; - --color-prettylights-syntax-markup-changed-text: #ffdfb6; - --color-prettylights-syntax-markup-changed-bg: #5a1e02; - --color-prettylights-syntax-markup-ignored-text: #c9d1d9; - --color-prettylights-syntax-markup-ignored-bg: #1158c7; - --color-prettylights-syntax-meta-diff-range: #d2a8ff; - --color-prettylights-syntax-brackethighlighter-angle: #8b949e; - --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; - --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; - --color-fg-default: #c9d1d9; - --color-fg-muted: #8b949e; - --color-fg-subtle: #484f58; - --color-canvas-default: #0d1117; - --color-canvas-subtle: #161b22; - --color-border-default: #30363d; - --color-border-muted: #21262d; - --color-neutral-muted: rgba(110,118,129,0.4); - --color-accent-fg: #58a6ff; - --color-accent-emphasis: #1f6feb; - --color-attention-subtle: rgba(187,128,9,0.15); - --color-danger-fg: #f85149; -} - -/* Override specific styles for dark mode tables and code */ -[data-theme="dark"] .markdown-body table tr { - background-color: var(--table-bg); -} - -[data-theme="dark"] .markdown-body table tr:nth-child(2n) { - background-color: #1c2128; /* Slightly lighter than base dark background */ -} - -[data-theme="dark"] .markdown-body pre { - background-color: var(--code-bg); -} - -[data-theme="dark"] .markdown-body code { - background-color: var(--code-bg); -} - -/* Syntax Highlighting Mapping to GitHub Variables */ -.hljs { - color: var(--color-fg-default); -} -.hljs-doctag, -.hljs-keyword, -.hljs-meta .hljs-keyword, -.hljs-template-tag, -.hljs-template-variable, -.hljs-type, -.hljs-variable.language_ { - color: var(--color-prettylights-syntax-keyword); -} -.hljs-title, -.hljs-title.class_, -.hljs-title.class_.inherited__, -.hljs-title.function_ { - color: var(--color-prettylights-syntax-entity); -} -.hljs-attr, -.hljs-attribute, -.hljs-literal, -.hljs-meta, -.hljs-number, -.hljs-operator, -.hljs-variable, -.hljs-selector-attr, -.hljs-selector-class, -.hljs-selector-id { - color: var(--color-prettylights-syntax-constant); -} -.hljs-regexp, -.hljs-string, -.hljs-meta .hljs-string { - color: var(--color-prettylights-syntax-string); -} -.hljs-built_in, -.hljs-symbol { - color: var(--color-prettylights-syntax-variable); -} -.hljs-comment, -.hljs-code, -.hljs-formula { - color: var(--color-prettylights-syntax-comment); -} -.hljs-name, -.hljs-quote, -.hljs-selector-tag, -.hljs-selector-pseudo { - color: var(--color-prettylights-syntax-entity-tag); -} -.hljs-subst { - color: var(--color-fg-default); -} -.hljs-section { - color: var(--color-prettylights-syntax-markup-heading); - font-weight: bold; -} -.hljs-bullet { - color: var(--color-prettylights-syntax-constant); -} -.hljs-emphasis { - color: var(--color-fg-default); - font-style: italic; -} -.hljs-strong { - color: var(--color-fg-default); - font-weight: bold; -} -.hljs-addition { - color: var(--color-prettylights-syntax-markup-inserted-text); - background-color: var(--color-prettylights-syntax-markup-inserted-bg); -} -.hljs-deletion { - color: var(--color-prettylights-syntax-markup-deleted-text); - background-color: var(--color-prettylights-syntax-markup-deleted-bg); -} - -.stats-container { - font-size: 0.9rem; - color: var(--text-color); -} - -.stat-item { - align-items: center; -} - -.stat-item i { - font-size: 1rem; - opacity: 0.8; -} - -.dropzone { - border: 2px dashed var(--border-color); - border-radius: 6px; - padding: 20px; - text-align: center; - margin-bottom: 10px; - cursor: pointer; - transition: all 0.3s ease; - background-color: var(--dropzone-bg); - position: relative; -} - -.dropzone.active { - border-color: var(--accent-color); - background-color: rgba(var(--accent-color), 0.05); -} - -.dropzone p { - transition: transform 0.2s ease; -} - -.dropzone:hover { - border: var(--accent-color) 2px dashed; -} - -.dropzone:hover p { - transform: scale(1.02); -} - -.close-btn { - position: absolute; - top: 5px; - right: 5px; - background: none; - border: none; - color: var(--text-color); - font-size: 1rem; - cursor: pointer; - padding: 5px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - width: 28px; - height: 28px; - opacity: 0.6; - transition: all 0.2s ease; - background-color: var(--button-bg); - border: 1px solid var(--border-color); -} - -.close-btn:hover { - opacity: 1; - background-color: var(--color-danger-fg); -} - -.editor-pane { - overflow: hidden; -} - -/* Mobile Menu Styles */ -.mobile-menu { - display: none; - position: relative; - z-index: 1001; -} - -@media (max-width: 1080px) { - .mobile-menu { - display: block; - } -} - -/* slide‑in panel */ -.mobile-menu-panel { - position: fixed; - top: 0; - right: -300px; - width: 280px; - height: 100vh; - background-color: var(--bg-color); - box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2); - transition: right 0.3s ease; - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - z-index: 1002; -} - -.mobile-menu-panel.active { - right: 0; -} - -/* translucent overlay behind panel */ -.mobile-menu-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100vh; - background-color: rgba(0, 0, 0, 0.5); - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; - z-index: 1000; -} - -.mobile-menu-overlay.active { - display: block; - opacity: 1; - visibility: visible; -} - -/* header inside mobile menu */ -.mobile-menu-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.mobile-menu-header h5 { - margin: 0; - font-size: 1.25rem; - color: var(--text-color); -} - -/* stats section in mobile menu */ -.mobile-stats-container { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.75rem; - margin-bottom: 1rem; -} - -.mobile-stats-container .stat-item { - font-size: 0.9rem; - color: var(--text-color); - display: flex; - align-items: center; -} - -.mobile-stats-container .stat-item i { - margin-right: 0.5em; - opacity: 0.8; -} - -/* menu buttons list */ -.mobile-menu-items { - display: flex; - flex-direction: column; - gap: 0.5rem; - flex-grow: 1; -} - -/* each menu item */ -.mobile-menu-item { - background-color: var(--button-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - border-radius: 6px; - padding: 0.6rem 1rem; - font-size: 1rem; - text-align: left; - display: flex; - align-items: center; - gap: 0.5rem; - transition: background-color 0.2s ease; - cursor: pointer; -} - -.mobile-menu-item:hover { - background-color: var(--button-hover); -} - -.mobile-menu-item:active { - background-color: var(--button-active); -} - -/* close button override */ -#close-mobile-menu.tool-button { - padding: 0.25rem 0.5rem; - font-size: 1rem; -} - -/* ensure dropzone doesn’t cover menu */ -.mobile-menu-panel .dropzone { - margin-bottom: 0; -} - -/* Mobile document tabs section */ -.mobile-tabs-section { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.75rem; -} - -.mobile-tabs-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.5rem; -} - -.mobile-tabs-label { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-color); - opacity: 0.8; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.mobile-new-tab-btn { - background: none; - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--text-color); - padding: 2px 7px; - font-size: 0.9rem; - cursor: pointer; - display: flex; - align-items: center; - transition: background-color 0.15s ease; -} - -.mobile-new-tab-btn:hover { - background-color: var(--button-hover); -} - -.mobile-tab-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 180px; - overflow-y: auto; -} - -.mobile-tab-item { - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--button-bg); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 0.45rem 0.75rem; - font-size: 0.9rem; - color: var(--text-color); - cursor: pointer; - transition: background-color 0.15s ease; - gap: 0.5rem; -} - -.mobile-tab-item:hover { - background-color: var(--button-hover); -} - -.mobile-tab-item.active { - border-color: var(--accent-color); - color: var(--accent-color); - background-color: var(--bg-color); -} - -.mobile-tab-title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.mobile-tab-item .tab-menu-btn { - opacity: 0.6; -} - -.mobile-tab-item:hover .tab-menu-btn, -.mobile-tab-item.active .tab-menu-btn { - opacity: 0.8; -} - -#mobile-tab-reset-btn { - margin-left: 0; - height: auto; - padding: 0.45rem 0.75rem; - justify-content: center; - font-size: 0.9rem; -} - -/* hide desktop-only stats and toolbar on mobile */ -@media (max-width: 767px) { - .stats-container.d-none.d-md-flex, - .toolbar.d-none.d-md-flex { - display: none !important; - } -} - -.github-link { - color: var(--text-color); - text-decoration: none; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s ease, color 0.2s ease; - margin-right: 2rem; -} - -.github-link:hover { - color: var(--accent-color); - transform: scale(1.1); -} - -.github-link i { - font-size: 1.5rem; -} - -/* ======================================== - VIEW MODE CONTROLS - Story 1.1 - ======================================== */ - -/* Header layout for three sections */ -.header-container { - position: relative; -} - -.header-left { - flex: 1; - justify-content: flex-start; -} - -.header-right { - flex: 1; - justify-content: flex-end; -} - -/* View Mode Button Group */ -.view-mode-group { - display: flex; - gap: 0; - position: absolute; - left: 50%; - transform: translateX(-50%); -} - -.view-mode-btn { - background-color: var(--button-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - padding: 6px 12px; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s ease; -} - -.view-mode-btn:first-child { - border-radius: 6px 0 0 6px; -} - -.view-mode-btn:last-child { - border-radius: 0 6px 6px 0; -} - -.view-mode-btn:not(:last-child) { - border-right: none; -} - -.view-mode-btn:hover { - background-color: var(--button-hover); -} - -.view-mode-btn.active { - background-color: var(--button-bg); - border-color: var(--accent-color); - color: var(--accent-color); - border-width: 2px; - padding: 5px 11px; /* Adjust for thicker border */ -} - -.view-mode-btn.active:not(:last-child) { - border-right: 2px solid var(--accent-color); -} - -.view-mode-btn i { - font-size: 16px; -} - -/* Pane View States */ -.content-container.view-editor-only .preview-pane { - display: none; -} - -.content-container.view-editor-only .editor-pane { - flex: 1; - border-right: none; -} - -.content-container.view-preview-only .editor-pane { - display: none; -} - -.content-container.view-preview-only .preview-pane { - flex: 1; -} - -.content-container.view-split .editor-pane, -.content-container.view-split .preview-pane { - flex: 1; -} - -/* Responsive adjustments for view mode buttons */ -@media (max-width: 1079px) { - .view-mode-group { - position: static; - transform: none; - } -} - -@media (max-width: 767px) { - .view-mode-group { - display: none; - } -} - -/* ======================================== - RESIZE DIVIDER - Story 1.3 - ======================================== */ - -.resize-divider { - width: 8px; - background-color: transparent; - cursor: col-resize; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - position: relative; - z-index: 10; - transition: background-color 0.2s ease; -} - -.resize-divider:hover { - background-color: var(--button-hover); -} - -.resize-divider.dragging { - background-color: var(--accent-color); -} - -.resize-divider-handle { - width: 2px; - height: 40px; - background-color: var(--border-color); - border-radius: 2px; - transition: background-color 0.2s ease, width 0.2s ease; -} - -.resize-divider:hover .resize-divider-handle, -.resize-divider.dragging .resize-divider-handle { - background-color: var(--accent-color); - width: 3px; -} - -/* Hide divider in single-pane modes */ -.content-container.view-editor-only .resize-divider, -.content-container.view-preview-only .resize-divider { - display: none; -} - -/* Hide divider on tablet and mobile (no drag resize) */ -@media (max-width: 1079px) { - .resize-divider { - display: none; - } -} - -/* Prevent text selection during drag */ -.resizing { - user-select: none; - cursor: col-resize !important; -} - -.resizing * { - cursor: col-resize !important; -} - -/* ======================================== - MOBILE VIEW MODE CONTROLS - Story 1.4 - ======================================== */ - -.mobile-view-mode-group { - display: flex; - gap: 0; - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.75rem; -} - -.mobile-view-mode-btn { - flex: 1; - background-color: var(--button-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - padding: 8px 12px; - font-size: 14px; - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - transition: all 0.2s ease; -} - -.mobile-view-mode-btn:first-child { - border-radius: 6px 0 0 6px; -} - -.mobile-view-mode-btn:last-child { - border-radius: 0 6px 6px 0; -} - -.mobile-view-mode-btn:not(:last-child) { - border-right: none; -} - -.mobile-view-mode-btn:hover, -.mobile-view-mode-btn:active { - background-color: var(--button-hover); -} - -.mobile-view-mode-btn.active { - background-color: var(--button-bg); - border-color: var(--accent-color); - color: var(--accent-color); - border-width: 2px; - padding: 7px 11px; -} - -.mobile-view-mode-btn.active:not(:last-child) { - border-right: 2px solid var(--accent-color); -} - -.mobile-view-mode-btn i { - font-size: 18px; -} - -.mobile-view-mode-btn span { - font-size: 12px; -} - -/* ======================================== - RESPONSIVE VIEW MODE FIXES - Story 1.5 - ======================================== */ - -/* On tablet/mobile, ensure single-pane modes take full height */ -@media (max-width: 1079px) { - .content-container.view-editor-only .editor-pane, - .content-container.view-preview-only .preview-pane { - height: 100%; - } - - .content-container.view-split .editor-pane, - .content-container.view-split .preview-pane { - height: 50%; - } -} - -/* ======================================== - PDF EXPORT TABLE FIX - Rowspan/Colspan - ======================================== */ - -/* Fix for html2canvas not properly rendering rowspan/colspan cells. - Apply backgrounds to cells instead of rows to prevent row backgrounds - from painting over rowspan cells during canvas capture. */ -.pdf-export table tr { - background-color: transparent !important; -} - -.pdf-export table th, -.pdf-export table td { - background-color: var(--table-bg, #ffffff); - position: relative; -} - -.pdf-export table tr:nth-child(2n) th, -.pdf-export table tr:nth-child(2n) td { - background-color: var(--bg-color, #f6f8fa); -} - -/* Ensure rowspan cells render correctly */ -.pdf-export table th[rowspan], -.pdf-export table td[rowspan] { - vertical-align: middle; - background-color: var(--table-bg, #ffffff) !important; -} - -/* Ensure colspan cells render correctly */ -.pdf-export table th[colspan], -.pdf-export table td[colspan] { - text-align: center; -} - -/* Dark mode PDF export table fix */ -[data-theme="dark"] .pdf-export table th, -[data-theme="dark"] .pdf-export table td { - background-color: var(--table-bg, #161b22); -} - -[data-theme="dark"] .pdf-export table tr:nth-child(2n) th, -[data-theme="dark"] .pdf-export table tr:nth-child(2n) td { - background-color: #1c2128; -} - -[data-theme="dark"] .pdf-export table th[rowspan], -[data-theme="dark"] .pdf-export table td[rowspan] { - background-color: var(--table-bg, #161b22) !important; -} - -/* ======================================== - MERMAID DIAGRAM TOOLBAR - ======================================== */ - -.mermaid-container { - position: relative; -} - -.mermaid-toolbar { - position: absolute; - top: 8px; - right: 8px; - display: flex; - gap: 4px; - opacity: 0; - transition: opacity 0.2s ease; - z-index: 10; -} - -.mermaid-container:hover .mermaid-toolbar { - opacity: 1; -} - -.mermaid-toolbar-btn { - background-color: var(--button-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - border-radius: 4px; - padding: 4px 7px; - font-size: 13px; - cursor: pointer; - display: flex; - align-items: center; - gap: 3px; - transition: background-color 0.2s ease, color 0.2s ease; - white-space: nowrap; -} - -.mermaid-toolbar-btn:hover { - background-color: var(--button-hover); - color: var(--accent-color); -} - -.mermaid-toolbar-btn:active { - background-color: var(--button-active); -} - -.mermaid-toolbar-btn i { - font-size: 14px; -} - -/* ======================================== - MERMAID ZOOM MODAL - ======================================== */ - -#mermaid-zoom-modal { - display: none; - position: fixed; - inset: 0; - z-index: 2000; - background-color: rgba(0, 0, 0, 0.75); - align-items: center; - justify-content: center; -} - -#mermaid-zoom-modal.active { - display: flex; -} - -.mermaid-modal-content { - background-color: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 16px; - width: 85vw; - height: 85vh; - max-width: 85vw; - max-height: 85vh; - display: flex; - flex-direction: column; - gap: 12px; -} - -@media (max-width: 576px) { - .mermaid-modal-content { - width: 95vw; - height: 90vh; - max-width: 95vw; - max-height: 90vh; - padding: 10px; - } -} - -.mermaid-modal-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.mermaid-modal-header span { - font-weight: 600; - font-size: 15px; - color: var(--text-color); -} - -.mermaid-modal-close { - background: none; - border: none; - color: var(--text-color); - font-size: 1.2rem; - cursor: pointer; - padding: 2px 6px; - border-radius: 4px; - display: flex; - align-items: center; - transition: background-color 0.2s ease; -} - -.mermaid-modal-close:hover { - background-color: var(--button-hover); -} - -.mermaid-modal-diagram { - overflow: auto; - flex: 1; - display: flex; - align-items: center; - justify-content: center; - min-height: 200px; - cursor: grab; -} - -.mermaid-modal-diagram.dragging { - cursor: grabbing; -} - -.mermaid-modal-diagram svg { - transform-origin: center; - transition: transform 0.1s ease; - max-width: none; -} - -.mermaid-modal-controls { - display: flex; - justify-content: center; - gap: 8px; - flex-wrap: wrap; -} - -.mermaid-modal-controls .mermaid-toolbar-btn { - opacity: 1; -} - -/* ======================================== - DOCUMENT TABS & SESSION MANAGEMENT - ======================================== */ - -.tab-bar { - display: flex; - align-items: center; - background-color: var(--header-bg); - border-bottom: 1px solid var(--border-color); - height: 36px; - overflow: visible; /* ← was: overflow: hidden */ - flex-shrink: 0; - padding: 0 4px; - gap: 0; - user-select: none; - position: relative; - z-index: 10; -} - -.tab-list { - display: flex; - align-items: flex-end; - overflow-x: auto; - overflow-y: visible; /* ← was: overflow-y: hidden */ - flex: 1; - height: 100%; - scrollbar-width: none; - -ms-overflow-style: none; -} - -.tab-list::-webkit-scrollbar { - display: none; -} - -.tab-item { - display: flex; - align-items: center; - gap: 6px; - height: 30px; - padding: 0 10px 0 12px; - min-width: 100px; - max-width: 180px; - background-color: var(--button-bg); - border: 1px solid var(--border-color); - border-bottom: none; - border-radius: 6px 6px 0 0; - cursor: pointer; - font-size: 13px; - color: var(--text-color); - white-space: nowrap; - /* overflow: hidden; <-- REMOVE THIS */ - position: relative; - transition: background-color 0.15s ease, color 0.15s ease; - flex-shrink: 0; - margin-right: 2px; - opacity: 0.7; -} - -.tab-item:hover { - background-color: var(--button-hover); - opacity: 0.9; -} - -.tab-item.active { - background-color: var(--bg-color); - border-color: var(--border-color); - color: var(--accent-color); - border-bottom: 1px solid var(--bg-color); - opacity: 1; - z-index: 2; -} - -.tab-item.unsaved::after { - content: ''; - display: inline-block; - width: 6px; - height: 6px; - background-color: var(--accent-color); - border-radius: 50%; - flex-shrink: 0; - margin-left: 2px; -} - -.tab-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - min-width: 0; -} - -.tab-close-btn { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: 3px; - background: none; - border: none; - color: var(--text-color); - cursor: pointer; - padding: 0; - font-size: 11px; - opacity: 0; - flex-shrink: 0; - transition: background-color 0.15s ease, opacity 0.15s ease; -} - -.tab-item:hover .tab-close-btn, -.tab-item.active .tab-close-btn { - opacity: 0.6; -} - -.tab-close-btn:hover { - background-color: var(--button-active); - opacity: 1 !important; - color: var(--color-danger-fg, #d73a49); -} - -.tab-new-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 5px; - background: none; - border: 1px solid transparent; - color: var(--text-color); - cursor: pointer; - font-size: 16px; - flex-shrink: 0; - margin-left: 4px; - transition: background-color 0.15s ease, border-color 0.15s ease; -} - -.tab-new-btn:hover { - background-color: var(--button-hover); - border-color: var(--border-color); -} - -/* Drag-and-drop visual feedback */ -.tab-item.dragging { - opacity: 0.4; -} - -.tab-item.drag-over { - border-left: 2px solid var(--accent-color); -} - -/* Tab enter animation */ -@keyframes tabSlideIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 0.7; transform: translateY(0); } -} - -.tab-item { - animation: tabSlideIn 0.12s ease forwards; -} - -.tab-item.active { - animation: none; -} - -/* Hide tab bar on very small screens — single-file use */ -@media (max-width: 480px) { - .tab-bar { - display: none; - } -} - -/* ======================================== - THREE-DOT TAB MENU - ======================================== */ - -.tab-menu-btn { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 3px; - background: none; - border: none; - color: var(--text-color); - cursor: pointer; - padding: 0; - font-size: 14px; - font-weight: bold; - letter-spacing: 1px; - opacity: 0.65; - flex-shrink: 0; - transition: background-color 0.15s ease, opacity 0.15s ease; - position: relative; -} - -.tab-item:hover .tab-menu-btn, -.tab-item.active .tab-menu-btn { - opacity: 0.65; -} - -.tab-menu-btn:hover { - background-color: var(--button-active); - opacity: 1 !important; -} - -.tab-menu-dropdown { - display: none; - position: fixed; - min-width: 130px; - background-color: var(--header-bg); - border: 1px solid var(--border-color); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - z-index: 99999; - overflow: hidden; - flex-direction: column; -} - -.tab-menu-btn.open .tab-menu-dropdown { - display: flex; -} - -.tab-menu-item { - display: flex; - align-items: center; - gap: 7px; - padding: 7px 12px; - background: none; - border: none; - color: var(--text-color); - font-size: 12px; - cursor: pointer; - text-align: left; - transition: background-color 0.12s ease; - white-space: nowrap; -} - -.tab-menu-item:hover { - background-color: var(--button-hover); -} - -.tab-menu-item-danger { - color: var(--color-danger-fg, #d73a49); -} - -.tab-menu-item-danger:hover { - background-color: rgba(215, 58, 73, 0.1); -} - -/* ======================================== - RESET BUTTON - ======================================== */ - -.tab-reset-btn { - display: flex; - align-items: center; - gap: 4px; - height: 26px; - padding: 0 10px; - border-radius: 5px; - background: none; - border: 1px solid var(--border-color); - color: var(--text-color); - cursor: pointer; - font-size: 12px; - flex-shrink: 0; - margin-left: 6px; - transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; -} - -.tab-reset-btn:hover { - background-color: rgba(215, 58, 73, 0.1); - border-color: var(--color-danger-fg, #d73a49); - color: var(--color-danger-fg, #d73a49); -} - -/* ======================================== - RESET & RENAME CONFIRMATION MODALS - ======================================== */ - -.reset-modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.45); - z-index: 2000; - display: flex; - align-items: center; - justify-content: center; -} - -.reset-modal-box { - background: var(--header-bg); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 24px 28px; - min-width: 280px; - max-width: 360px; - box-shadow: 0 8px 32px rgba(0,0,0,0.25); - display: flex; - flex-direction: column; - gap: 16px; -} - -.reset-modal-message { - margin: 0; - font-size: 14px; - color: var(--text-color); - font-weight: 500; - text-align: center; -} - -.reset-modal-actions { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -.reset-modal-btn { - padding: 6px 16px; - border-radius: 6px; - border: 1px solid var(--border-color); - background: var(--button-bg); - color: var(--text-color); - font-size: 13px; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.reset-modal-btn:hover { - background-color: var(--button-hover); -} - -.reset-modal-confirm { - background-color: var(--color-danger-fg, #d73a49); - border-color: var(--color-danger-fg, #d73a49); - color: #fff; -} - -.reset-modal-confirm:hover { - background-color: #b02a37; - border-color: #b02a37; -} - -/* ======================================== - RENAME MODAL INPUT - ======================================== */ - -.rename-modal-input { - width: 100%; - padding: 7px 10px; - border-radius: 6px; - border: 1px solid var(--border-color); - background: var(--bg-color); - color: var(--text-color); - font-size: 13px; - outline: none; - box-sizing: border-box; -} - -.rename-modal-input:focus { - border-color: var(--accent-color); -} - -.github-import-error { - margin: 0; - font-size: 12px; - color: var(--color-danger-fg, #d73a49); - text-align: left; - line-height: 1.5; -} - -.github-import-error.is-info { - color: var(--text-secondary, #57606a); -} - -#github-import-modal .reset-modal-box { - width: min(720px, 92vw); - min-width: 320px; - padding: 28px 32px; - gap: 18px; -} - -#github-import-modal .reset-modal-message { - font-size: 18px; - line-height: 1.35; - text-align: left; -} - -#github-import-url, -#github-import-file-select { - min-height: 46px; - padding: 10px 12px; - font-size: 15px; -} - -#github-import-modal .reset-modal-actions { - gap: 12px; -} - -#github-import-modal .reset-modal-btn { - min-height: 42px; - padding: 9px 18px; - font-size: 14px; -} - -@media (max-width: 576px) { - #github-import-modal .reset-modal-box { - width: 95vw; - max-width: 95vw; - min-width: 0; - padding: 20px; - gap: 14px; - } - - #github-import-modal .reset-modal-message { - font-size: 16px; - } - - #github-import-modal .reset-modal-actions { - flex-direction: column-reverse; - } - - #github-import-modal .reset-modal-btn { - width: 100%; - } -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5deae0d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,643 @@ +{ + "name": "markdown-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "markdown-viewer", + "version": "1.0.0", + "devDependencies": { + "http-server": "^14.1.1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..afd6442 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "markdown-viewer", + "version": "1.0.0", + "private": true, + "description": "Markdown Viewer web app with root npm scripts", + "scripts": { + "dev": "http-server . -p 5173 -c-1 -o /index.html", + "start": "npm run dev", + "desktop:install": "npm --prefix desktop-app install", + "desktop:dev": "npm --prefix desktop-app run dev", + "desktop:build": "npm --prefix desktop-app run build" + }, + "devDependencies": { + "http-server": "^14.1.1" + } +} From ab964e611464f7caf12c25308469da3b99a91635 Mon Sep 17 00:00:00 2001 From: Phani Date: Wed, 8 Apr 2026 23:35:37 +0530 Subject: [PATCH 04/11] refactor: move web app into web/ for Vercel deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/index.html, web/script.js, web/styles.css, web/assets/ — web app source - web/vercel.json — Vercel static hosting config with security headers - desktop-app/prepare.js — updated ROOT_DIR to point to web/ - package.json — updated dev script to serve from web/ Deploy on Vercel: set Root Directory to 'web' in project settings. --- desktop-app/prepare.js | 14 +++++------ package.json | 2 +- ...ck and Beige Simple Coming Soon Banner.png | Bin {assets => web/assets}/code.png | Bin {assets => web/assets}/github.png | Bin {assets => web/assets}/icon.jpg | Bin {assets => web/assets}/live-peview.gif | Bin {assets => web/assets}/mathexp.png | Bin {assets => web/assets}/mermaid.png | Bin {assets => web/assets}/table.png | Bin index.html => web/index.html | 0 script.js => web/script.js | 0 styles.css => web/styles.css | 0 web/vercel.json | 23 ++++++++++++++++++ 14 files changed, 31 insertions(+), 8 deletions(-) rename {assets => web/assets}/Black and Beige Simple Coming Soon Banner.png (100%) rename {assets => web/assets}/code.png (100%) rename {assets => web/assets}/github.png (100%) rename {assets => web/assets}/icon.jpg (100%) rename {assets => web/assets}/live-peview.gif (100%) rename {assets => web/assets}/mathexp.png (100%) rename {assets => web/assets}/mermaid.png (100%) rename {assets => web/assets}/table.png (100%) rename index.html => web/index.html (100%) rename script.js => web/script.js (100%) rename styles.css => web/styles.css (100%) create mode 100644 web/vercel.json diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index f709069..bbb1df0 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -15,7 +15,7 @@ const fs = require("fs"); const path = require("path"); -const ROOT_DIR = path.resolve(__dirname, ".."); +const ROOT_DIR = path.resolve(__dirname, "../web"); const RESOURCES_DIR = path.resolve(__dirname, "resources"); /** @section Copy shared files */ @@ -36,25 +36,25 @@ function copyDirSync(src, dest) { } } -/** script.js → resources/js/script.js */ +/** web/script.js → resources/js/script.js */ const jsDest = path.join(RESOURCES_DIR, "js"); fs.mkdirSync(jsDest, { recursive: true }); fs.copyFileSync( path.join(ROOT_DIR, "script.js"), path.join(jsDest, "script.js"), ); -console.log("✓ Copied script.js → resources/js/script.js"); +console.log("✓ Copied web/script.js → resources/js/script.js"); -/** styles.css → resources/styles.css */ +/** web/styles.css → resources/styles.css */ fs.copyFileSync( path.join(ROOT_DIR, "styles.css"), path.join(RESOURCES_DIR, "styles.css"), ); -console.log("✓ Copied styles.css → resources/styles.css"); +console.log("✓ Copied web/styles.css → resources/styles.css"); -/** assets/ → resources/assets/ */ +/** web/assets/ → resources/assets/ */ copyDirSync(path.join(ROOT_DIR, "assets"), path.join(RESOURCES_DIR, "assets")); -console.log("✓ Copied assets/ → resources/assets/"); +console.log("✓ Copied web/assets/ → resources/assets/"); /** @section Generate index.html with Neutralinojs injections */ diff --git a/package.json b/package.json index afd6442..b64da8e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Markdown Viewer web app with root npm scripts", "scripts": { - "dev": "http-server . -p 5173 -c-1 -o /index.html", + "dev": "http-server web -p 5173 -c-1 -o", "start": "npm run dev", "desktop:install": "npm --prefix desktop-app install", "desktop:dev": "npm --prefix desktop-app run dev", diff --git a/assets/Black and Beige Simple Coming Soon Banner.png b/web/assets/Black and Beige Simple Coming Soon Banner.png similarity index 100% rename from assets/Black and Beige Simple Coming Soon Banner.png rename to web/assets/Black and Beige Simple Coming Soon Banner.png diff --git a/assets/code.png b/web/assets/code.png similarity index 100% rename from assets/code.png rename to web/assets/code.png diff --git a/assets/github.png b/web/assets/github.png similarity index 100% rename from assets/github.png rename to web/assets/github.png diff --git a/assets/icon.jpg b/web/assets/icon.jpg similarity index 100% rename from assets/icon.jpg rename to web/assets/icon.jpg diff --git a/assets/live-peview.gif b/web/assets/live-peview.gif similarity index 100% rename from assets/live-peview.gif rename to web/assets/live-peview.gif diff --git a/assets/mathexp.png b/web/assets/mathexp.png similarity index 100% rename from assets/mathexp.png rename to web/assets/mathexp.png diff --git a/assets/mermaid.png b/web/assets/mermaid.png similarity index 100% rename from assets/mermaid.png rename to web/assets/mermaid.png diff --git a/assets/table.png b/web/assets/table.png similarity index 100% rename from assets/table.png rename to web/assets/table.png diff --git a/index.html b/web/index.html similarity index 100% rename from index.html rename to web/index.html diff --git a/script.js b/web/script.js similarity index 100% rename from script.js rename to web/script.js diff --git a/styles.css b/web/styles.css similarity index 100% rename from styles.css rename to web/styles.css diff --git a/web/vercel.json b/web/vercel.json new file mode 100644 index 0000000..749d123 --- /dev/null +++ b/web/vercel.json @@ -0,0 +1,23 @@ +{ + "version": 2, + "framework": null, + "cleanUrls": true, + "trailingSlash": false, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-XSS-Protection", "value": "1; mode=block" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] +} From fe275760cafe66d8e80b6347456f3fe9731fcb75 Mon Sep 17 00:00:00 2001 From: Phani Date: Wed, 8 Apr 2026 23:54:21 +0530 Subject: [PATCH 05/11] feat: extend Open button with SharePoint and Azure DevOps import - Open button becomes a dropdown: Local File / SharePoint / Azure DevOps Repo - SharePoint: paste any direct file URL, fetches via browser (file must be publicly shared) - Azure DevOps: enter org/project/repo/branch/path + optional PAT (Code Read scope) - PAT transmitted only to dev.azure.com, never stored - Uses ADO REST API v7.1 with \=text for raw file content - Mobile menu has dedicated buttons for all three open options - Both modals show inline error messages and loading spinner while importing - Ctrl+O shortcut still opens local file picker --- web/index.html | 104 ++++++++++++++++++++++++++++-- web/script.js | 168 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 8 deletions(-) diff --git a/web/index.html b/web/index.html index c201da7..2123d51 100644 --- a/web/index.html +++ b/web/index.html @@ -95,9 +95,16 @@

    Markdown Viewer

    - + @@ -176,8 +183,14 @@
    Menu
    Sync On - + +
    + + + + + + diff --git a/web/script.js b/web/script.js index f403d0c..f5b0aeb 100644 --- a/web/script.js +++ b/web/script.js @@ -14,6 +14,9 @@ document.addEventListener("DOMContentLoaded", function () { const markdownPreview = document.getElementById("markdown-preview"); const themeToggle = document.getElementById("theme-toggle"); const openButton = document.getElementById("open-button"); + const openLocalBtn = document.getElementById("open-local"); + const openSharepointBtn = document.getElementById("open-sharepoint"); + const openAdoBtn = document.getElementById("open-ado"); const saveButton = document.getElementById("save-button"); const insertAdoTocButton = document.getElementById("insert-ado-toc"); const insertAdoNoteButton = document.getElementById("insert-ado-note"); @@ -54,7 +57,9 @@ 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 mobileOpenBtn = document.getElementById("mobile-open-button"); + const mobileOpenLocalBtn = document.getElementById("mobile-open-local"); + const mobileOpenSharepointBtn = document.getElementById("mobile-open-sharepoint"); + const mobileOpenAdoBtn = document.getElementById("mobile-open-ado"); const mobileSaveBtn = document.getElementById("mobile-save-button"); const mobileInsertAdoTocBtn = document.getElementById("mobile-insert-ado-toc"); const mobileInsertAdoNoteBtn = document.getElementById("mobile-insert-ado-note"); @@ -824,6 +829,74 @@ This is a fully client-side application. Your content never leaves your browser } async function saveMarkdownFile() { + async function importFromSharePoint(url) { + let resp; + try { + resp = await fetch(url); + } catch (e) { + throw new Error( + "Failed to fetch the file. This is often a CORS restriction. " + + "Ensure the SharePoint file is shared with \u2018Anyone with the link\u2019 and the URL is a direct download link." + ); + } + if (!resp.ok) { + throw new Error( + "SharePoint returned HTTP " + resp.status + ": " + resp.statusText + + ". Check that the URL is correct and the file is publicly accessible." + ); + } + const text = await resp.text(); + const fileName = url.split("/").pop().split("?")[0] || "sharepoint.md"; + markdownEditor.value = text; + currentFileName = fileName; + currentFileHandle = null; + renderMarkdown(); + } + + async function importFromAdo(org, project, repo, branch, filePath, pat) { + if (!filePath.startsWith("/")) filePath = "/" + filePath; + const url = new URL( + "https://dev.azure.com/" + + encodeURIComponent(org) + "/" + + encodeURIComponent(project) + + "/_apis/git/repositories/" + + encodeURIComponent(repo) + "/items" + ); + url.searchParams.set("path", filePath); + url.searchParams.set("versionDescriptor.version", branch); + url.searchParams.set("versionDescriptor.versionType", "branch"); + url.searchParams.set("$format", "text"); + url.searchParams.set("api-version", "7.1"); + const headers = { "Accept": "text/plain" }; + if (pat) { + headers["Authorization"] = "Basic " + btoa(":" + pat); + } + let resp; + try { + resp = await fetch(url.toString(), { headers }); + } catch (e) { + throw new Error( + "Network error contacting Azure DevOps. " + + "Check your connection or that the organization name is correct." + ); + } + if (resp.status === 401 || resp.status === 403) { + throw new Error( + "Authentication failed (HTTP " + resp.status + "). " + + "Provide a valid PAT with Code (Read) scope for private repositories." + ); + } + if (!resp.ok) { + throw new Error("Azure DevOps returned HTTP " + resp.status + ": " + resp.statusText); + } + const text = await resp.text(); + const fileName = filePath.split("/").pop() || "ado.md"; + markdownEditor.value = text; + currentFileName = fileName; + currentFileHandle = null; + renderMarkdown(); + } + const markdownText = markdownEditor.value; if (window.showSaveFilePicker) { @@ -1369,7 +1442,18 @@ This is a fully client-side application. Your content never leaves your browser mobileToggleSync.addEventListener("click", () => { toggleSyncScrolling(); }); - mobileOpenBtn.addEventListener("click", () => openMarkdownFile()); + mobileOpenLocalBtn.addEventListener("click", () => { openMarkdownFile(); closeMobileMenu(); }); + mobileOpenSharepointBtn.addEventListener("click", () => { + sharepointError.classList.add("d-none"); + sharepointUrlInput.value = ""; + closeMobileMenu(); + sharepointImportModal.show(); + }); + mobileOpenAdoBtn.addEventListener("click", () => { + adoError.classList.add("d-none"); + closeMobileMenu(); + adoImportModal.show(); + }); mobileSaveBtn.addEventListener("click", () => saveMarkdownFile()); mobileInsertAdoTocBtn.addEventListener("click", () => { insertAdoTocSnippet(); @@ -1466,10 +1550,88 @@ This is a fully client-side application. Your content never leaves your browser renderMarkdown(); }); - openButton.addEventListener("click", function () { + // Bootstrap Modal instances for import dialogs + const sharepointImportModal = new bootstrap.Modal(document.getElementById("sharepoint-import-modal")); + const sharepointUrlInput = document.getElementById("sharepoint-url"); + const sharepointImportBtn = document.getElementById("sharepoint-import-btn"); + const sharepointError = document.getElementById("sharepoint-error"); + + const adoImportModal = new bootstrap.Modal(document.getElementById("ado-import-modal")); + const adoOrgInput = document.getElementById("ado-org"); + const adoProjectInput = document.getElementById("ado-project"); + const adoRepoInput = document.getElementById("ado-repo"); + const adoBranchInput = document.getElementById("ado-branch"); + const adoPathInput = document.getElementById("ado-path"); + const adoPatInput = document.getElementById("ado-pat"); + const adoError = document.getElementById("ado-error"); + const adoImportBtn = document.getElementById("ado-import-btn"); + + openLocalBtn.addEventListener("click", function (e) { + e.preventDefault(); openMarkdownFile(); }); + openSharepointBtn.addEventListener("click", function (e) { + e.preventDefault(); + sharepointError.classList.add("d-none"); + sharepointUrlInput.value = ""; + sharepointImportModal.show(); + }); + + openAdoBtn.addEventListener("click", function (e) { + e.preventDefault(); + adoError.classList.add("d-none"); + adoImportModal.show(); + }); + + sharepointImportBtn.addEventListener("click", async function () { + const url = sharepointUrlInput.value.trim(); + if (!url) { + sharepointError.textContent = "Please enter a SharePoint file URL."; + sharepointError.classList.remove("d-none"); + return; + } + sharepointImportBtn.disabled = true; + sharepointImportBtn.innerHTML = ' Importing\u2026'; + try { + await importFromSharePoint(url); + sharepointImportModal.hide(); + } catch (e) { + sharepointError.textContent = e.message; + sharepointError.classList.remove("d-none"); + } finally { + sharepointImportBtn.disabled = false; + sharepointImportBtn.innerHTML = ' Import'; + } + }); + + adoImportBtn.addEventListener("click", async function () { + const org = adoOrgInput.value.trim(); + const project = adoProjectInput.value.trim(); + const repo = adoRepoInput.value.trim(); + const branch = adoBranchInput.value.trim() || "main"; + const filePath = adoPathInput.value.trim(); + const pat = adoPatInput.value.trim(); + if (!org || !project || !repo || !filePath) { + adoError.textContent = "Organization, Project, Repository and File Path are required."; + adoError.classList.remove("d-none"); + return; + } + adoImportBtn.disabled = true; + adoImportBtn.innerHTML = ' Importing\u2026'; + try { + await importFromAdo(org, project, repo, branch, filePath, pat); + adoImportModal.hide(); + adoPatInput.value = ""; + } catch (e) { + adoError.textContent = e.message; + adoError.classList.remove("d-none"); + } finally { + adoImportBtn.disabled = false; + adoImportBtn.innerHTML = ' Import'; + } + }); + saveButton.addEventListener("click", function () { saveMarkdownFile(); }); From 277311cccfd03d5d7de5d9802d7d0417e256d62a Mon Sep 17 00:00:00 2001 From: Phani Date: Thu, 9 Apr 2026 00:20:23 +0530 Subject: [PATCH 06/11] feat: harden imports, modernize deps, and fix header alignment - add SharePoint/Azure DevOps import options with modals and validation - enforce HTTPS for SharePoint import and clear ADO PAT after attempts - upgrade frontend CDN dependencies (bootstrap, dompurify, mermaid, marked, mathjax, jsPDF, etc.) - bump Neutralino CLI usage to 11.7.1 - remove TOC/Note desktop header buttons - add Choose File header action and wire to existing file input - refactor header/toolbar/stats layout to prevent overlap and keep controls aligned --- desktop-app/package.json | 8 +-- desktop-app/setup-binaries.js | 2 +- web/index.html | 39 +++++++-------- web/script.js | 50 ++++++++++++------- web/styles.css | 92 ++++++++++++++++++++++++++++++++--- 5 files changed, 141 insertions(+), 50 deletions(-) diff --git a/desktop-app/package.json b/desktop-app/package.json index 6447a2d..5ba9ba3 100644 --- a/desktop-app/package.json +++ b/desktop-app/package.json @@ -19,11 +19,11 @@ "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", + "dev": "npx -y @neutralinojs/neu@11.7.1 run", "prebuild": "npm run setup", - "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" + "build": "npx -y @neutralinojs/neu@11.7.1 build --embed-resources --release", + "build:portable": "npm run setup && npx -y @neutralinojs/neu@11.7.1 build --release", + "build:embedded": "npm run setup && npx -y @neutralinojs/neu@11.7.1 build --embed-resources" }, "dependencies": {} } diff --git a/desktop-app/setup-binaries.js b/desktop-app/setup-binaries.js index ac6816c..38ac8c5 100644 --- a/desktop-app/setup-binaries.js +++ b/desktop-app/setup-binaries.js @@ -23,7 +23,7 @@ const BIN_DIR = path.resolve(__dirname, "bin"); const VERSION_MARKER = path.join(BIN_DIR, ".version"); /** Neu CLI package — same version used across all npm scripts */ -const NEU_CLI = "@neutralinojs/neu@11.7.0"; +const NEU_CLI = "@neutralinojs/neu@11.7.1"; const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); const expectedVersion = config.cli.binaryVersion; diff --git a/web/index.html b/web/index.html index 2123d51..a274ac6 100644 --- a/web/index.html +++ b/web/index.html @@ -30,31 +30,31 @@ Markdown Viewer - - - - - + + + + + - + - - - + + + - - - - + + + +
    -
    +

    Markdown Viewer

    @@ -105,15 +105,12 @@

    Markdown Viewer

  • Azure DevOps Repo
  • + - -
    - + \ No newline at end of file diff --git a/web/script.js b/web/script.js index f5b0aeb..75dd6f5 100644 --- a/web/script.js +++ b/web/script.js @@ -17,6 +17,7 @@ document.addEventListener("DOMContentLoaded", function () { const openLocalBtn = document.getElementById("open-local"); const openSharepointBtn = document.getElementById("open-sharepoint"); const openAdoBtn = document.getElementById("open-ado"); + const chooseFileButton = document.getElementById("choose-file-button"); const saveButton = document.getElementById("save-button"); const insertAdoTocButton = document.getElementById("insert-ado-toc"); const insertAdoNoteButton = document.getElementById("insert-ado-note"); @@ -111,8 +112,6 @@ document.addEventListener("DOMContentLoaded", function () { pedantic: false, smartypants: false, xhtml: false, - headerIds: true, - mangle: false, }; const renderer = new marked.Renderer(); @@ -132,11 +131,6 @@ 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) { @@ -830,6 +824,17 @@ This is a fully client-side application. Your content never leaves your browser async function saveMarkdownFile() { async function importFromSharePoint(url) { + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + throw new Error("Invalid URL format. Please provide a full SharePoint HTTPS URL."); + } + + if (parsedUrl.protocol !== "https:") { + throw new Error("Only HTTPS SharePoint URLs are allowed."); + } + let resp; try { resp = await fetch(url); @@ -846,7 +851,7 @@ This is a fully client-side application. Your content never leaves your browser ); } const text = await resp.text(); - const fileName = url.split("/").pop().split("?")[0] || "sharepoint.md"; + const fileName = parsedUrl.pathname.split("/").pop() || "sharepoint.md"; markdownEditor.value = text; currentFileName = fileName; currentFileHandle = null; @@ -1571,6 +1576,12 @@ This is a fully client-side application. Your content never leaves your browser openMarkdownFile(); }); + if (chooseFileButton) { + chooseFileButton.addEventListener("click", function () { + fileInput.click(); + }); + } + openSharepointBtn.addEventListener("click", function (e) { e.preventDefault(); sharepointError.classList.add("d-none"); @@ -1629,6 +1640,7 @@ This is a fully client-side application. Your content never leaves your browser } finally { adoImportBtn.disabled = false; adoImportBtn.innerHTML = ' Import'; + adoPatInput.value = ""; } }); @@ -1636,13 +1648,17 @@ This is a fully client-side application. Your content never leaves your browser saveMarkdownFile(); }); - insertAdoTocButton.addEventListener("click", function () { - insertAdoTocSnippet(); - }); + if (insertAdoTocButton) { + insertAdoTocButton.addEventListener("click", function () { + insertAdoTocSnippet(); + }); + } - insertAdoNoteButton.addEventListener("click", function () { - insertAdoNoteSnippet(); - }); + if (insertAdoNoteButton) { + insertAdoNoteButton.addEventListener("click", function () { + insertAdoNoteSnippet(); + }); + } fileInput.addEventListener("change", function (e) { const file = e.target.files[0]; @@ -1690,8 +1706,8 @@ This is a fully client-side application. Your content never leaves your browser const isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark"; const cssTheme = isDarkTheme - ? "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown-dark.min.css" - : "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown.min.css"; + ? "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.9.0/github-markdown-dark.min.css" + : "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.9.0/github-markdown.min.css"; const fullHtml = ` @@ -1699,7 +1715,7 @@ This is a fully client-side application. Your content never leaves your browser Markdown Export -