From bda0167cd4f499584ba69f5c0bc3cb2adee7b356 Mon Sep 17 00:00:00 2001 From: Tiago Ferreira Date: Fri, 19 Dec 2025 13:10:19 -0300 Subject: [PATCH] feat: add darkmode option --- .github/copilot-instructions.md | 97 +++ .../{index-DXVBtf7Z.js => index-_bLmAPh3.js} | 56 +- dist/css/style.css | 141 +++- dist/index.html | 83 ++- index.html | 81 ++- public/css/style.css | 141 +++- src/main.js | 632 ++++++++++-------- 7 files changed, 826 insertions(+), 405 deletions(-) create mode 100644 .github/copilot-instructions.md rename dist/assets/{index-DXVBtf7Z.js => index-_bLmAPh3.js} (50%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..96adbfd --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,97 @@ +# Markdown Live Preview - AI Coding Agent Instructions + +## Project Overview + +Single-page web application for live Markdown editing and preview using Monaco Editor, marked.js parser, and DOMPurify sanitization. Deployed to Firebase Hosting via Vite bundler. + +## Architecture + +### Core Components + +- **[src/main.js](../src/main.js)**: Single-file application with modular functions + - Monaco Editor initialization (lines 88-111) + - Markdown conversion pipeline: `marked.parse()` → `DOMPurify.sanitize()` (lines 135-141) + - State persistence via `storehouse-js` localStorage wrapper (lines 241-257) + - Split-pane divider with proportional resizing (lines 259-339) + +### Key Dependencies + +- `monaco-editor`: CDN ESM import (`https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm`) +- `storehouse-js`: Custom fork from GitHub (`github:tanabe/Storehouse-js`) +- `marked` + `DOMPurify`: Markdown parsing and XSS protection pipeline + +## Development Workflow + +### Commands (prefer Makefile over npm) + +```bash +make setup # npm install +make dev # Vite dev server (HMR enabled) +make build # Production build to dist/ +make deploy # Firebase deploy (requires firebase-tools) +``` + +### Build System + +- **Vite**: No configuration file present; uses defaults +- **Output**: `dist/` directory (configured in [firebase.json](../firebase.json)) +- **Entry point**: [index.html](../index.html) with module script ` + - + -
-
- -
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
+
diff --git a/index.html b/index.html index afbee53..e1e60f6 100644 --- a/index.html +++ b/index.html @@ -1,55 +1,76 @@ - + - - - - - - - - + + + + + + + + Markdown Live Preview - + -
-
- -
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
+
diff --git a/public/css/style.css b/public/css/style.css index 6187a31..f674b4a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,4 +1,5 @@ -html, body { +html, +body { color: #333; overflow-x: hidden; margin: 0; @@ -13,10 +14,16 @@ html, body { body { display: flex; flex-direction: column; - height:100vh; + height: 100vh; } -h1, h2, h3, h4, h5, h6, p { +h1, +h2, +h3, +h4, +h5, +h6, +p { font-size: 1em; margin: 0; padding: 0; @@ -29,8 +36,7 @@ p { a:link, a:visited, -a:hover -a:active { +a:hover a:active { color: #333; text-decoration: none; } @@ -50,7 +56,10 @@ header { color: #fff; } -header a:link, header a:hover, header a:visited, header a:active { +header a:link, +header a:hover, +header a:visited, +header a:active { color: #fff; } @@ -59,33 +68,41 @@ header a:hover { } #menu-items { - display: flex; + display: flex; } #reset-button { - margin-left: 16px; + margin-left: 16px; } #copy-button { - margin-left: 16px; + margin-left: 16px; } #sync-button { - margin-left: 16px; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + margin-left: 16px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#theme-button { + margin-left: 16px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } header input[type="checkbox"] { - vertical-align: middle; - margin-right: 4px; - margin-top: 0px; + vertical-align: middle; + margin-right: 4px; + margin-top: 0px; } #github { - padding-right: 32px; + padding-right: 32px; } #github img { @@ -169,4 +186,90 @@ footer { .split-divider.active { background: #666; -} \ No newline at end of file +} + +/* Dark mode styles */ +body.dark-mode { + background-color: #1e1e1e; + color: #d4d4d4; +} + +body.dark-mode a:link, +body.dark-mode a:visited, +body.dark-mode a:hover, +body.dark-mode a:active { + color: #d4d4d4; +} + +body.dark-mode #container { + background-color: #1e1e1e; + border-bottom: 1px solid #3e3e42; +} + +body.dark-mode footer { + background-color: #1e1e1e; +} + +body.dark-mode .split-divider { + background: #3e3e42; +} + +body.dark-mode .split-divider.hover { + background: #5a5a5f; +} + +body.dark-mode .split-divider.active { + background: #707070; +} + +/* Dark mode markdown output styles */ +#output.dark-mode { + background-color: #1e1e1e; + color: #d4d4d4; +} + +#output.dark-mode h1, +#output.dark-mode h2, +#output.dark-mode h3, +#output.dark-mode h4, +#output.dark-mode h5, +#output.dark-mode h6 { + color: #d4d4d4; + border-bottom-color: #3e3e42; +} + +#output.dark-mode a { + color: #58a6ff; +} + +#output.dark-mode code { + background-color: rgba(110, 118, 129, 0.4); + color: #d4d4d4; +} + +#output.dark-mode pre { + background-color: #161b22; +} + +#output.dark-mode blockquote { + color: #8b949e; + border-left-color: #3e3e42; +} + +#output.dark-mode table tr { + background-color: #1e1e1e; + border-top-color: #3e3e42; +} + +#output.dark-mode table tr:nth-child(2n) { + background-color: #161b22; +} + +#output.dark-mode table th, +#output.dark-mode table td { + border-color: #3e3e42; +} + +#output.dark-mode img { + background-color: transparent; +} diff --git a/src/main.js b/src/main.js index 2aa4626..db239ad 100644 --- a/src/main.js +++ b/src/main.js @@ -1,19 +1,22 @@ -import Storehouse from 'storehouse-js'; -import * as monaco from 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; -import 'github-markdown-css/github-markdown-light.css'; +import Storehouse from "storehouse-js"; +import * as monaco from "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm"; +import { marked } from "marked"; +import DOMPurify from "dompurify"; +import "github-markdown-css/github-markdown-light.css"; const init = () => { - let hasEdited = false; - let scrollBarSync = false; - - const localStorageNamespace = 'com.markdownlivepreview'; - const localStorageKey = 'last_state'; - const localStorageScrollBarKey = 'scroll_bar_settings'; - const confirmationMessage = 'Are you sure you want to reset? Your changes will be lost.'; - // default template - const defaultInput = `# Markdown syntax guide + let hasEdited = false; + let scrollBarSync = false; + let isDarkMode = false; + + const localStorageNamespace = "com.markdownlivepreview"; + const localStorageKey = "last_state"; + const localStorageScrollBarKey = "scroll_bar_settings"; + const localStorageThemeKey = "theme_settings"; + const confirmationMessage = + "Are you sure you want to reset? Your changes will be lost."; + // default template + const defaultInput = `# Markdown syntax guide ## Headers @@ -84,275 +87,348 @@ ${"`"}${"`"}${"`"} This web site is using ${"`"}markedjs/marked${"`"}. `; - self.MonacoEnvironment = { - getWorker(_, label) { - return new Proxy({}, { get: () => () => { } }); - } - } - - let setupEditor = () => { - let editor = monaco.editor.create(document.querySelector('#editor'), { - fontSize: 14, - language: 'markdown', - minimap: { enabled: false }, - scrollBeyondLastLine: false, - automaticLayout: true, - scrollbar: { - vertical: 'visible', - horizontal: 'visible' - }, - wordWrap: 'on', - hover: { enabled: false }, - quickSuggestions: false, - suggestOnTriggerCharacters: false, - folding: false - }); - - editor.onDidChangeModelContent(() => { - let changed = editor.getValue() != defaultInput; - if (changed) { - hasEdited = true; - } - let value = editor.getValue(); - convert(value); - saveLastContent(value); - }); - - editor.onDidScrollChange((e) => { - if (!scrollBarSync) { - return; - } - - const scrollTop = e.scrollTop; - const scrollHeight = e.scrollHeight; - const height = editor.getLayoutInfo().height; - - const maxScrollTop = scrollHeight - height; - const scrollRatio = scrollTop / maxScrollTop; - - let previewElement = document.querySelector('#preview'); - let targetY = (previewElement.scrollHeight - previewElement.clientHeight) * scrollRatio; - previewElement.scrollTo(0, targetY); - }); - - return editor; - }; - - // Render markdown text as html - let convert = (markdown) => { - let options = { - headerIds: false, - mangle: false - }; - let html = marked.parse(markdown, options); - let sanitized = DOMPurify.sanitize(html); - document.querySelector('#output').innerHTML = sanitized; - }; - - // Reset input text - let reset = () => { - let changed = editor.getValue() != defaultInput; - if (hasEdited || changed) { - var confirmed = window.confirm(confirmationMessage); - if (!confirmed) { - return; - } - } - presetValue(defaultInput); - document.querySelectorAll('.column').forEach((element) => { - element.scrollTo({ top: 0 }); - }); - }; - - let presetValue = (value) => { - editor.setValue(value); - editor.revealPosition({ lineNumber: 1, column: 1 }); - editor.focus(); - hasEdited = false; - }; - - // ----- sync scroll position ----- - - let initScrollBarSync = (settings) => { - let checkbox = document.querySelector('#sync-scroll-checkbox'); - checkbox.checked = settings; - scrollBarSync = settings; - - checkbox.addEventListener('change', (event) => { - let checked = event.currentTarget.checked; - scrollBarSync = checked; - saveScrollBarSettings(checked); - }); - }; - - let enableScrollBarSync = () => { - scrollBarSync = true; - }; - - let disableScrollBarSync = () => { - scrollBarSync = false; + self.MonacoEnvironment = { + getWorker(_, label) { + return new Proxy({}, { get: () => () => {} }); + }, + }; + + let setupEditor = () => { + let editor = monaco.editor.create(document.querySelector("#editor"), { + fontSize: 14, + language: "markdown", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + scrollbar: { + vertical: "visible", + horizontal: "visible", + }, + wordWrap: "on", + hover: { enabled: false }, + quickSuggestions: false, + suggestOnTriggerCharacters: false, + folding: false, + }); + + editor.onDidChangeModelContent(() => { + let changed = editor.getValue() != defaultInput; + if (changed) { + hasEdited = true; + } + let value = editor.getValue(); + convert(value); + saveLastContent(value); + }); + + editor.onDidScrollChange((e) => { + if (!scrollBarSync) { + return; + } + + const scrollTop = e.scrollTop; + const scrollHeight = e.scrollHeight; + const height = editor.getLayoutInfo().height; + + const maxScrollTop = scrollHeight - height; + const scrollRatio = scrollTop / maxScrollTop; + + let previewElement = document.querySelector("#preview"); + let targetY = + (previewElement.scrollHeight - previewElement.clientHeight) * + scrollRatio; + previewElement.scrollTo(0, targetY); + }); + + return editor; + }; + + // Render markdown text as html + let convert = (markdown) => { + let options = { + headerIds: false, + mangle: false, }; - - // ----- clipboard utils ----- - - let copyToClipboard = (text, successHandler, errorHandler) => { - navigator.clipboard.writeText(text).then( - () => { - successHandler(); - }, - - () => { - errorHandler(); - } + let html = marked.parse(markdown, options); + let sanitized = DOMPurify.sanitize(html); + document.querySelector("#output").innerHTML = sanitized; + }; + + // Reset input text + let reset = () => { + let changed = editor.getValue() != defaultInput; + if (hasEdited || changed) { + var confirmed = window.confirm(confirmationMessage); + if (!confirmed) { + return; + } + } + presetValue(defaultInput); + document.querySelectorAll(".column").forEach((element) => { + element.scrollTo({ top: 0 }); + }); + }; + + let presetValue = (value) => { + editor.setValue(value); + editor.revealPosition({ lineNumber: 1, column: 1 }); + editor.focus(); + hasEdited = false; + }; + + // ----- sync scroll position ----- + + let initScrollBarSync = (settings) => { + let checkbox = document.querySelector("#sync-scroll-checkbox"); + checkbox.checked = settings; + scrollBarSync = settings; + + checkbox.addEventListener("change", (event) => { + let checked = event.currentTarget.checked; + scrollBarSync = checked; + saveScrollBarSettings(checked); + }); + }; + + let enableScrollBarSync = () => { + scrollBarSync = true; + }; + + let disableScrollBarSync = () => { + scrollBarSync = false; + }; + + // ----- clipboard utils ----- + + let copyToClipboard = (text, successHandler, errorHandler) => { + navigator.clipboard.writeText(text).then( + () => { + successHandler(); + }, + + () => { + errorHandler(); + } + ); + }; + + let notifyCopied = () => { + let labelElement = document.querySelector("#copy-button a"); + labelElement.innerHTML = "Copied!"; + setTimeout(() => { + labelElement.innerHTML = "Copy"; + }, 1000); + }; + + // ----- setup ----- + + // setup navigation actions + let setupResetButton = () => { + document + .querySelector("#reset-button") + .addEventListener("click", (event) => { + event.preventDefault(); + reset(); + }); + }; + + let setupCopyButton = (editor) => { + document + .querySelector("#copy-button") + .addEventListener("click", (event) => { + event.preventDefault(); + let value = editor.getValue(); + copyToClipboard( + value, + () => { + notifyCopied(); + }, + () => { + // nothing to do + } ); - }; - - let notifyCopied = () => { - let labelElement = document.querySelector("#copy-button a"); - labelElement.innerHTML = "Copied!"; - setTimeout(() => { - labelElement.innerHTML = "Copy"; - }, 1000) - }; - - // ----- setup ----- - - // setup navigation actions - let setupResetButton = () => { - document.querySelector("#reset-button").addEventListener('click', (event) => { - event.preventDefault(); - reset(); - }); - }; - - let setupCopyButton = (editor) => { - document.querySelector("#copy-button").addEventListener('click', (event) => { - event.preventDefault(); - let value = editor.getValue(); - copyToClipboard(value, () => { - notifyCopied(); - }, - () => { - // nothing to do - }); - }); - }; - - // ----- local state ----- - - let loadLastContent = () => { - let lastContent = Storehouse.getItem(localStorageNamespace, localStorageKey); - return lastContent; - }; - - let saveLastContent = (content) => { - let expiredAt = new Date(2099, 1, 1); - Storehouse.setItem(localStorageNamespace, localStorageKey, content, expiredAt); - }; - - let loadScrollBarSettings = () => { - let lastContent = Storehouse.getItem(localStorageNamespace, localStorageScrollBarKey); - return lastContent; - }; - - let saveScrollBarSettings = (settings) => { - let expiredAt = new Date(2099, 1, 1); - Storehouse.setItem(localStorageNamespace, localStorageScrollBarKey, settings, expiredAt); - }; - - let setupDivider = () => { - let lastLeftRatio = 0.5; - const divider = document.getElementById('split-divider'); - const leftPane = document.getElementById('edit'); - const rightPane = document.getElementById('preview'); - const container = document.getElementById('container'); - - let isDragging = false; - - divider.addEventListener('mouseenter', () => { - divider.classList.add('hover'); - }); - - divider.addEventListener('mouseleave', () => { - if (!isDragging) { - divider.classList.remove('hover'); - } - }); - - divider.addEventListener('mousedown', () => { - isDragging = true; - divider.classList.add('active'); - document.body.style.cursor = 'col-resize'; - }); - - divider.addEventListener('dblclick', () => { - const containerRect = container.getBoundingClientRect(); - const totalWidth = containerRect.width; - const dividerWidth = divider.offsetWidth; - const halfWidth = (totalWidth - dividerWidth) / 2; - - leftPane.style.width = halfWidth + 'px'; - rightPane.style.width = halfWidth + 'px'; - }); - - document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - document.body.style.userSelect = 'none'; - const containerRect = container.getBoundingClientRect(); - const totalWidth = containerRect.width; - const offsetX = e.clientX - containerRect.left; - const dividerWidth = divider.offsetWidth; - - // Prevent overlap or out-of-bounds - const minWidth = 100; - const maxWidth = totalWidth - minWidth - dividerWidth; - const leftWidth = Math.max(minWidth, Math.min(offsetX, maxWidth)); - leftPane.style.width = leftWidth + 'px'; - rightPane.style.width = (totalWidth - leftWidth - dividerWidth) + 'px'; - lastLeftRatio = leftWidth / (totalWidth - dividerWidth); - }); - - document.addEventListener('mouseup', () => { - if (isDragging) { - isDragging = false; - divider.classList.remove('active'); - divider.classList.remove('hover'); - document.body.style.cursor = 'default'; - document.body.style.userSelect = ''; - } - }); - - window.addEventListener('resize', () => { - const containerRect = container.getBoundingClientRect(); - const totalWidth = containerRect.width; - const dividerWidth = divider.offsetWidth; - const availableWidth = totalWidth - dividerWidth; - - const newLeft = availableWidth * lastLeftRatio; - const newRight = availableWidth * (1 - lastLeftRatio); - - leftPane.style.width = newLeft + 'px'; - rightPane.style.width = newRight + 'px'; - }); - }; - - // ----- entry point ----- - let lastContent = loadLastContent(); - let editor = setupEditor(); - if (lastContent) { - presetValue(lastContent); + }); + }; + + // ----- local state ----- + + let loadLastContent = () => { + let lastContent = Storehouse.getItem( + localStorageNamespace, + localStorageKey + ); + return lastContent; + }; + + let saveLastContent = (content) => { + let expiredAt = new Date(2099, 1, 1); + Storehouse.setItem( + localStorageNamespace, + localStorageKey, + content, + expiredAt + ); + }; + + let loadScrollBarSettings = () => { + let lastContent = Storehouse.getItem( + localStorageNamespace, + localStorageScrollBarKey + ); + return lastContent; + }; + + let saveScrollBarSettings = (settings) => { + let expiredAt = new Date(2099, 1, 1); + Storehouse.setItem( + localStorageNamespace, + localStorageScrollBarKey, + settings, + expiredAt + ); + }; + + let loadThemeSettings = () => { + let themeSettings = Storehouse.getItem( + localStorageNamespace, + localStorageThemeKey + ); + return themeSettings; + }; + + let saveThemeSettings = (settings) => { + let expiredAt = new Date(2099, 1, 1); + Storehouse.setItem( + localStorageNamespace, + localStorageThemeKey, + settings, + expiredAt + ); + }; + + // ----- theme management ----- + + let applyTheme = (dark) => { + isDarkMode = dark; + if (dark) { + document.body.classList.add("dark-mode"); + document.querySelector("#output").classList.add("dark-mode"); + monaco.editor.setTheme("vs-dark"); } else { - presetValue(defaultInput); + document.body.classList.remove("dark-mode"); + document.querySelector("#output").classList.remove("dark-mode"); + monaco.editor.setTheme("vs"); } - setupResetButton(); - setupCopyButton(editor); - - let scrollBarSettings = loadScrollBarSettings() || false; - initScrollBarSync(scrollBarSettings); - - setupDivider(); + }; + + let initThemeToggle = (settings) => { + let checkbox = document.querySelector("#theme-toggle-checkbox"); + checkbox.checked = settings; + applyTheme(settings); + + checkbox.addEventListener("change", (event) => { + let checked = event.currentTarget.checked; + applyTheme(checked); + saveThemeSettings(checked); + }); + }; + + let setupDivider = () => { + let lastLeftRatio = 0.5; + const divider = document.getElementById("split-divider"); + const leftPane = document.getElementById("edit"); + const rightPane = document.getElementById("preview"); + const container = document.getElementById("container"); + + let isDragging = false; + + divider.addEventListener("mouseenter", () => { + divider.classList.add("hover"); + }); + + divider.addEventListener("mouseleave", () => { + if (!isDragging) { + divider.classList.remove("hover"); + } + }); + + divider.addEventListener("mousedown", () => { + isDragging = true; + divider.classList.add("active"); + document.body.style.cursor = "col-resize"; + }); + + divider.addEventListener("dblclick", () => { + const containerRect = container.getBoundingClientRect(); + const totalWidth = containerRect.width; + const dividerWidth = divider.offsetWidth; + const halfWidth = (totalWidth - dividerWidth) / 2; + + leftPane.style.width = halfWidth + "px"; + rightPane.style.width = halfWidth + "px"; + }); + + document.addEventListener("mousemove", (e) => { + if (!isDragging) return; + document.body.style.userSelect = "none"; + const containerRect = container.getBoundingClientRect(); + const totalWidth = containerRect.width; + const offsetX = e.clientX - containerRect.left; + const dividerWidth = divider.offsetWidth; + + // Prevent overlap or out-of-bounds + const minWidth = 100; + const maxWidth = totalWidth - minWidth - dividerWidth; + const leftWidth = Math.max(minWidth, Math.min(offsetX, maxWidth)); + leftPane.style.width = leftWidth + "px"; + rightPane.style.width = totalWidth - leftWidth - dividerWidth + "px"; + lastLeftRatio = leftWidth / (totalWidth - dividerWidth); + }); + + document.addEventListener("mouseup", () => { + if (isDragging) { + isDragging = false; + divider.classList.remove("active"); + divider.classList.remove("hover"); + document.body.style.cursor = "default"; + document.body.style.userSelect = ""; + } + }); + + window.addEventListener("resize", () => { + const containerRect = container.getBoundingClientRect(); + const totalWidth = containerRect.width; + const dividerWidth = divider.offsetWidth; + const availableWidth = totalWidth - dividerWidth; + + const newLeft = availableWidth * lastLeftRatio; + const newRight = availableWidth * (1 - lastLeftRatio); + + leftPane.style.width = newLeft + "px"; + rightPane.style.width = newRight + "px"; + }); + }; + + // ----- entry point ----- + let lastContent = loadLastContent(); + let editor = setupEditor(); + if (lastContent) { + presetValue(lastContent); + } else { + presetValue(defaultInput); + } + setupResetButton(); + setupCopyButton(editor); + + let scrollBarSettings = loadScrollBarSettings() || false; + initScrollBarSync(scrollBarSettings); + + let themeSettings = loadThemeSettings() || false; + initThemeToggle(themeSettings); + + setupDivider(); }; window.addEventListener("load", () => { - init(); + init(); });