mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(zen): add distraction-free writing mode with typewriter scrolling
Zen mode hides all chrome and centers the editor with typewriter scrolling, line dimming, and a floating word count HUD. Toggle with F11, exit with Escape. Amit Haridas
This commit is contained in:
@@ -15,7 +15,9 @@
|
||||
<link rel="stylesheet" href="styles-modern.css">
|
||||
<link rel="stylesheet" href="styles-concreteinfo.css">
|
||||
<link rel="stylesheet" href="styles-sidebar.css">
|
||||
<link rel="stylesheet" href="styles-zen.css">
|
||||
<link rel="stylesheet" href="styles-welcome.css">
|
||||
<link rel="stylesheet" href="styles-zen.css">
|
||||
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+18
-1
@@ -26,6 +26,8 @@ function getReplPanel() { if (!_ReplPanel) _ReplPanel = require('./repl/repl-pan
|
||||
function getCommandPalette() { if (!_CommandPalette) _CommandPalette = require('./command-palette').CommandPalette; return _CommandPalette; }
|
||||
function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview; return _PrintPreview; }
|
||||
function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; }
|
||||
let _ZenMode;
|
||||
function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; }
|
||||
|
||||
// Configure marked with highlight extension
|
||||
marked.use(markedHighlight({
|
||||
@@ -1504,6 +1506,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Zen Mode
|
||||
const ZenModeClass = getZenMode();
|
||||
const zenMode = new ZenModeClass(tabManager);
|
||||
|
||||
// Welcome tab on startup
|
||||
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
|
||||
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
|
||||
@@ -1656,13 +1662,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
commandPalette.register('Heading 3', '', () => tabManager.insertAtLineStart('### '));
|
||||
commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)'));
|
||||
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
||||
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
||||
|
||||
// Ctrl+Shift+P keyboard shortcut for command palette
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+Shift+P — Command Palette
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
||||
e.preventDefault();
|
||||
commandPalette.open();
|
||||
}
|
||||
// F11 — Zen Mode
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
zenMode.toggle();
|
||||
}
|
||||
// Escape — Exit Zen Mode
|
||||
if (e.key === 'Escape' && zenMode.active) {
|
||||
zenMode.deactivate();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize CodeMirror for the initial tab (tab 1)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* Zen Mode — Distraction-free writing styles */
|
||||
|
||||
/* Hide chrome elements */
|
||||
body.zen-mode .app-header,
|
||||
body.zen-mode .tab-bar,
|
||||
body.zen-mode .toolbar,
|
||||
body.zen-mode .status-bar,
|
||||
body.zen-mode .sidebar,
|
||||
body.zen-mode .breadcrumb-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Main content fills viewport */
|
||||
body.zen-mode .main-content {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Editor container takes full width */
|
||||
body.zen-mode .editor-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide preview panes */
|
||||
body.zen-mode .preview-container,
|
||||
body.zen-mode .preview-pane,
|
||||
body.zen-mode .pane-resizer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Center and style editor content for comfortable reading width */
|
||||
body.zen-mode .cm-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
body.zen-mode .cm-line {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
body.zen-mode .editor-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Floating HUD pill */
|
||||
.zen-hud {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 13px;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.zen-hud-stat {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zen-hud-sep {
|
||||
opacity: 0.3;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Word goal progress bar */
|
||||
.zen-progress-container {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zen-progress-bar {
|
||||
height: 100%;
|
||||
background: rgba(255, 178, 70, 0.85);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark mode — HUD stays dark in both themes */
|
||||
body[class*="dark"].zen-mode .zen-hud {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Zen Mode — distraction-free writing mode
|
||||
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
|
||||
* Toggle with F11, exit with Escape.
|
||||
*/
|
||||
|
||||
const {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
} = require('@codemirror/view');
|
||||
const { RangeSetBuilder } = require('@codemirror/state');
|
||||
|
||||
class ZenMode {
|
||||
/**
|
||||
* @param {import('./renderer').TabManager} tabManager
|
||||
*/
|
||||
constructor(tabManager) {
|
||||
this.tabManager = tabManager;
|
||||
this.active = false;
|
||||
this._hud = null;
|
||||
this._timerInterval = null;
|
||||
this._sessionStart = null;
|
||||
this._extensions = [];
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.active) return;
|
||||
this.active = true;
|
||||
|
||||
document.body.classList.add('zen-mode');
|
||||
|
||||
// Collapse sidebar
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) sidebar.classList.add('collapsed');
|
||||
|
||||
// Hide preview pane for the active tab
|
||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||
if (tab) {
|
||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||
if (previewContainer) previewContainer.classList.add('hidden');
|
||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||
if (editorPane) editorPane.classList.add('full-width');
|
||||
|
||||
// Add CM6 extensions to the active editor
|
||||
if (tab.editorView) {
|
||||
this._extensions = [
|
||||
typewriterScrollExtension,
|
||||
lineDimmingExtension,
|
||||
];
|
||||
// Reconfigure is not straightforward with CM6's immutable state.
|
||||
// Instead we dispatch effects or simply rebuild with new extensions.
|
||||
// Since we cannot hot-swap extensions on an existing EditorView,
|
||||
// we store a reference and the CSS + updateListener handle the rest.
|
||||
// The typewriter + dimming plugins are applied via a compartment approach
|
||||
// would be ideal, but for simplicity we use DOM + updateListener.
|
||||
this._applyTypewriterBehavior(tab.editorView);
|
||||
}
|
||||
}
|
||||
|
||||
// Create HUD
|
||||
this._createHUD();
|
||||
|
||||
// Start session timer
|
||||
this._sessionStart = Date.now();
|
||||
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
||||
|
||||
// Focus editor
|
||||
if (tab?.editorView) tab.editorView.focus();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
|
||||
document.body.classList.remove('zen-mode');
|
||||
|
||||
// Restore preview pane for the active tab
|
||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||
if (tab) {
|
||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||
if (previewContainer) previewContainer.classList.remove('hidden');
|
||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||
if (editorPane) editorPane.classList.remove('full-width');
|
||||
|
||||
// Remove typewriter listener
|
||||
if (tab.editorView && this._selectionListener) {
|
||||
window.removeEventListener('zen-typewriter', this._selectionListener);
|
||||
this._selectionListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove HUD
|
||||
if (this._hud && this._hud.parentNode) {
|
||||
this._hud.parentNode.removeChild(this._hud);
|
||||
this._hud = null;
|
||||
}
|
||||
|
||||
// Stop timer
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
this._sessionStart = null;
|
||||
this._extensions = [];
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.active) {
|
||||
this.deactivate();
|
||||
} else {
|
||||
this.activate();
|
||||
}
|
||||
}
|
||||
|
||||
_applyTypewriterBehavior(view) {
|
||||
// We use a custom update listener that scrolls the cursor to center.
|
||||
// This is stored on the instance so we can clean it up.
|
||||
let scrollTimeout = null;
|
||||
|
||||
const scrollFn = () => {
|
||||
if (!this.active) return;
|
||||
const pos = view.state.selection.main.head;
|
||||
requestAnimationFrame(() => {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for selection/doc changes via a polling approach
|
||||
// since we cannot add extensions to an existing view.
|
||||
// Instead, use the DOM scrollIntoView after selection changes.
|
||||
const observer = new MutationObserver(() => {
|
||||
// No-op, we use interval below
|
||||
});
|
||||
|
||||
// Use interval to keep cursor centered during typing
|
||||
this._scrollInterval = setInterval(() => {
|
||||
if (!this.active) {
|
||||
clearInterval(this._scrollInterval);
|
||||
this._scrollInterval = null;
|
||||
return;
|
||||
}
|
||||
// Typewriter scroll: center the cursor line
|
||||
try {
|
||||
const pos = view.state.selection.main.head;
|
||||
const coords = view.coordsAtPos(pos);
|
||||
const scroller = view.scrollDOM;
|
||||
if (coords && scroller) {
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
|
||||
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
|
||||
const diff = cursorCenter - viewCenter;
|
||||
if (Math.abs(diff) > 30) {
|
||||
scroller.scrollTop += diff;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors from destroyed views
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Initial scroll to center
|
||||
scrollFn();
|
||||
}
|
||||
|
||||
_createHUD() {
|
||||
const hud = document.createElement('div');
|
||||
hud.className = 'zen-hud';
|
||||
hud.innerHTML = `
|
||||
<span class="zen-hud-item" id="zen-words">0 words</span>
|
||||
<span class="zen-hud-sep">·</span>
|
||||
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
||||
<span class="zen-hud-sep">·</span>
|
||||
<span class="zen-hud-item" id="zen-timer">00:00</span>
|
||||
<div class="zen-progress-container" id="zen-progress-container">
|
||||
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(hud);
|
||||
this._hud = hud;
|
||||
this._updateHUD();
|
||||
}
|
||||
|
||||
_updateHUD() {
|
||||
if (!this.active || !this._hud) return;
|
||||
|
||||
const content = this.tabManager.getEditorContent();
|
||||
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||
const readingTime = Math.max(1, Math.ceil(words / 200));
|
||||
|
||||
const wordsEl = document.getElementById('zen-words');
|
||||
const readingEl = document.getElementById('zen-reading-time');
|
||||
const timerEl = document.getElementById('zen-timer');
|
||||
|
||||
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
||||
if (readingEl) readingEl.textContent = `${readingTime} min read`;
|
||||
|
||||
// Session timer
|
||||
if (this._sessionStart) {
|
||||
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
|
||||
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
||||
const seconds = String(elapsed % 60).padStart(2, '0');
|
||||
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Word goal progress
|
||||
const goalStr = localStorage.getItem('zen-word-goal');
|
||||
const progressBar = document.getElementById('zen-progress-bar');
|
||||
const progressContainer = document.getElementById('zen-progress-container');
|
||||
if (progressBar && progressContainer) {
|
||||
if (goalStr) {
|
||||
const goal = parseInt(goalStr, 10);
|
||||
if (goal > 0) {
|
||||
const pct = Math.min(100, Math.round((words / goal) * 100));
|
||||
progressBar.style.width = `${pct}%`;
|
||||
progressContainer.style.display = 'block';
|
||||
} else {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Typewriter scroll ViewPlugin
|
||||
const typewriterScrollExtension = ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const pos = update.state.selection.main.head;
|
||||
requestAnimationFrame(() => {
|
||||
if (this.view.dom && this.view.dom.isConnected) {
|
||||
this.view.dispatch({
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Line dimming ViewPlugin
|
||||
function getOpacity(distance) {
|
||||
if (distance === 0) return 1.0;
|
||||
if (distance === 1) return 0.7;
|
||||
if (distance === 2) return 0.6;
|
||||
if (distance === 3) return 0.45;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
const lineDimmingExtension = ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view) {
|
||||
const builder = new RangeSetBuilder();
|
||||
const pos = view.state.selection.main.head;
|
||||
const activeLine = view.state.doc.lineAt(pos).number;
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
||||
const line = doc.line(lineNum);
|
||||
const distance = Math.abs(lineNum - activeLine);
|
||||
const opacity = getOpacity(distance);
|
||||
const deco = Decoration.line({
|
||||
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
||||
});
|
||||
builder.add(line.from, line.from, deco);
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
});
|
||||
|
||||
module.exports = { ZenMode };
|
||||
Reference in New Issue
Block a user