diff --git a/browser-extension/biome.json b/browser-extension/biome.json index d4cccec..5dd9019 100644 --- a/browser-extension/biome.json +++ b/browser-extension/biome.json @@ -10,7 +10,7 @@ }, "files": { "ignoreUnknown": false, - "includes": [".*", "src/**", "tests/**"] + "includes": [".*", "src/**", "tests/**", "!src/overtype", "!src/playgrounds"] }, "formatter": { "enabled": true, diff --git a/browser-extension/package-lock.json b/browser-extension/package-lock.json index 5632634..b1987b4 100644 --- a/browser-extension/package-lock.json +++ b/browser-extension/package-lock.json @@ -1,16 +1,17 @@ { "name": "gitcasso", - "version": "1.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitcasso", - "version": "1.0.0", + "version": "0.0.1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@wxt-dev/webextension-polyfill": "^1.0.0", + "highlight.js": "^11.11.1", "webextension-polyfill": "^0.12.0" }, "devDependencies": { @@ -2382,6 +2383,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cli-highlight/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3634,12 +3644,12 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/hookable": { diff --git a/browser-extension/package.json b/browser-extension/package.json index d79cdb5..c00fb1e 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -2,6 +2,7 @@ "author": "DiffPlug", "dependencies": { "@wxt-dev/webextension-polyfill": "^1.0.0", + "highlight.js": "^11.11.1", "webextension-polyfill": "^0.12.0" }, "description": "Syntax highlighting and autosave for comments on GitHub (and other other markdown-friendly websites).", diff --git a/browser-extension/src/entrypoints/content.ts b/browser-extension/src/entrypoints/content.ts index e8041dc..1610d92 100644 --- a/browser-extension/src/entrypoints/content.ts +++ b/browser-extension/src/entrypoints/content.ts @@ -1,12 +1,17 @@ import { CONFIG } from '../lib/config' import { logger } from '../lib/logger' import { EnhancerRegistry, TextareaRegistry } from '../lib/registries' +import { githubPrNewCommentContentScript } from '../playgrounds/github-playground' const enhancers = new EnhancerRegistry() const enhancedTextareas = new TextareaRegistry() export default defineContentScript({ main() { + if (CONFIG.MODE === 'PLAYGROUNDS_PR') { + githubPrNewCommentContentScript() + return + } const textAreasOnPageLoad = document.querySelectorAll(`textarea`) for (const textarea of textAreasOnPageLoad) { enhanceMaybe(textarea) diff --git a/browser-extension/src/lib/config.ts b/browser-extension/src/lib/config.ts index c4f0a86..1f5e748 100644 --- a/browser-extension/src/lib/config.ts +++ b/browser-extension/src/lib/config.ts @@ -1,9 +1,10 @@ -// Configuration constants for the extension +const MODES = ['PROD', 'PLAYGROUNDS_PR'] as const + +export type ModeType = (typeof MODES)[number] + export const CONFIG = { ADDED_OVERTYPE_CLASS: 'gitcasso-overtype', - // Debug settings - DEBUG: true, // Set to true to enable debug logging - EXTENSION_NAME: 'gitcasso', - INITIAL_SCAN_DELAY_MS: 100, - MUTATION_OBSERVER_DELAY_MS: 100, + DEBUG: true, // enabled debug logging + EXTENSION_NAME: 'gitcasso', // decorates logs + MODE: 'PLAYGROUNDS_PR' satisfies ModeType, } as const diff --git a/browser-extension/src/overtype/icons.js b/browser-extension/src/overtype/icons.js new file mode 100644 index 0000000..a297ef7 --- /dev/null +++ b/browser-extension/src/overtype/icons.js @@ -0,0 +1,77 @@ +/** + * SVG icons for OverType toolbar + * Quill-style icons with inline styles + */ + +export const boldIcon = ` + + +`; + +export const italicIcon = ` + + + +`; + +export const h1Icon = ` + +`; + +export const h2Icon = ` + +`; + +export const h3Icon = ` + +`; + +export const linkIcon = ` + + + +`; + +export const codeIcon = ` + + + +`; + +export const bulletListIcon = ` + + + + + + +`; + +export const orderedListIcon = ` + + + + + + + +`; + +export const quoteIcon = ` + + +`; + +export const taskListIcon = ` + + + + + + +`; + +export const eyeIcon = ` + + +`; diff --git a/browser-extension/src/overtype/link-tooltip.js b/browser-extension/src/overtype/link-tooltip.js new file mode 100644 index 0000000..64aca7b --- /dev/null +++ b/browser-extension/src/overtype/link-tooltip.js @@ -0,0 +1,204 @@ +/** + * Link Tooltip - CSS Anchor Positioning with index-based anchors + * Shows a clickable tooltip when cursor is within a link + * Uses CSS anchor positioning with dynamically selected anchor + */ + +export class LinkTooltip { + constructor(editor) { + this.editor = editor; + this.tooltip = null; + this.currentLink = null; + this.hideTimeout = null; + + this.init(); + } + + init() { + // Check for CSS anchor positioning support + const supportsAnchor = + CSS.supports("position-anchor: --x") && + CSS.supports("position-area: center"); + + if (!supportsAnchor) { + // Don't show anything if not supported + return; + } + + // Create tooltip element + this.createTooltip(); + + // Listen for cursor position changes + this.editor.textarea.addEventListener("selectionchange", () => + this.checkCursorPosition() + ); + this.editor.textarea.addEventListener("keyup", (e) => { + if (e.key.includes("Arrow") || e.key === "Home" || e.key === "End") { + this.checkCursorPosition(); + } + }); + + // Hide tooltip when typing or scrolling + this.editor.textarea.addEventListener("input", () => this.hide()); + this.editor.textarea.addEventListener("scroll", () => this.hide()); + + // Keep tooltip visible on hover + this.tooltip.addEventListener("mouseenter", () => this.cancelHide()); + this.tooltip.addEventListener("mouseleave", () => this.scheduleHide()); + } + + createTooltip() { + // Create tooltip element + this.tooltip = document.createElement("div"); + this.tooltip.className = "overtype-link-tooltip"; + + // Add CSS anchor positioning styles + const tooltipStyles = document.createElement("style"); + tooltipStyles.textContent = ` + @supports (position-anchor: --x) and (position-area: center) { + .overtype-link-tooltip { + position: absolute; + position-anchor: var(--target-anchor, --link-0); + position-area: block-end center; + margin-top: 8px; + + background: #333; + color: white; + padding: 6px 10px; + border-radius: 16px; + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + display: none; + z-index: 10000; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + position-try: most-width block-end inline-end, flip-inline, block-start center; + position-visibility: anchors-visible; + } + + .overtype-link-tooltip.visible { + display: flex; + } + } + `; + document.head.appendChild(tooltipStyles); + + // Add link icon and text container + this.tooltip.innerHTML = ` + + + + + + + + `; + + // Click handler to open link + this.tooltip.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (this.currentLink) { + window.open(this.currentLink.url, "_blank"); + this.hide(); + } + }); + + // Append tooltip to editor container + this.editor.container.appendChild(this.tooltip); + } + + checkCursorPosition() { + const cursorPos = this.editor.textarea.selectionStart; + const text = this.editor.textarea.value; + + // Find if cursor is within a markdown link + const linkInfo = this.findLinkAtPosition(text, cursorPos); + + if (linkInfo) { + if ( + !this.currentLink || + this.currentLink.url !== linkInfo.url || + this.currentLink.index !== linkInfo.index + ) { + this.show(linkInfo); + } + } else { + this.scheduleHide(); + } + } + + findLinkAtPosition(text, position) { + // Regex to find markdown links: [text](url) + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + let match; + let linkIndex = 0; + + while ((match = linkRegex.exec(text)) !== null) { + const start = match.index; + const end = match.index + match[0].length; + + if (position >= start && position <= end) { + return { + text: match[1], + url: match[2], + index: linkIndex, + start: start, + end: end, + }; + } + linkIndex++; + } + + return null; + } + + show(linkInfo) { + this.currentLink = linkInfo; + this.cancelHide(); + + // Update tooltip content + const urlSpan = this.tooltip.querySelector(".overtype-link-tooltip-url"); + urlSpan.textContent = linkInfo.url; + + // Set the CSS variable to point to the correct anchor + this.tooltip.style.setProperty( + "--target-anchor", + `--link-${linkInfo.index}` + ); + + // Show tooltip (CSS anchor positioning handles the rest) + this.tooltip.classList.add("visible"); + } + + hide() { + this.tooltip.classList.remove("visible"); + this.currentLink = null; + } + + scheduleHide() { + this.cancelHide(); + this.hideTimeout = setTimeout(() => this.hide(), 300); + } + + cancelHide() { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + } + + destroy() { + this.cancelHide(); + if (this.tooltip && this.tooltip.parentNode) { + this.tooltip.parentNode.removeChild(this.tooltip); + } + this.tooltip = null; + this.currentLink = null; + } +} diff --git a/browser-extension/src/overtype/overtype.d.ts b/browser-extension/src/overtype/overtype.d.ts new file mode 100644 index 0000000..aa8a8cc --- /dev/null +++ b/browser-extension/src/overtype/overtype.d.ts @@ -0,0 +1,148 @@ +// Type definitions for OverType +// Project: https://github.com/panphora/overtype +// Definitions generated from JSDoc comments and implementation + +export interface Theme { + name: string; + colors: { + bgPrimary?: string; + bgSecondary?: string; + text?: string; + textSecondary?: string; + h1?: string; + h2?: string; + h3?: string; + strong?: string; + em?: string; + link?: string; + code?: string; + codeBg?: string; + blockquote?: string; + hr?: string; + syntaxMarker?: string; + listMarker?: string; + cursor?: string; + selection?: string; + rawLine?: string; + // Toolbar theme colors + toolbarBg?: string; + toolbarIcon?: string; + toolbarHover?: string; + toolbarActive?: string; + border?: string; + }; +} + +export interface Stats { + words: number; + chars: number; + lines: number; + line: number; + column: number; +} + +export interface MobileOptions { + fontSize?: string; + padding?: string; + lineHeight?: string | number; +} + +export interface Options { + // Typography + fontSize?: string; + lineHeight?: string | number; + fontFamily?: string; + padding?: string; + + // Mobile responsive + mobile?: MobileOptions; + + // Native textarea attributes (v1.1.2+) + textareaProps?: Record; + + // Behavior + autofocus?: boolean; + autoResize?: boolean; // v1.1.2+ Auto-expand height with content + minHeight?: string; // v1.1.2+ Minimum height for autoResize mode + maxHeight?: string | null; // v1.1.2+ Maximum height for autoResize mode + placeholder?: string; + value?: string; + + // Features + showActiveLineRaw?: boolean; + showStats?: boolean; + toolbar?: boolean; + statsFormatter?: (stats: Stats) => string; + + // Theme (deprecated in favor of global theme) + theme?: string | Theme; + colors?: Partial; + + // Callbacks + onChange?: (value: string, instance: OverTypeInstance) => void; + onKeydown?: (event: KeyboardEvent, instance: OverTypeInstance) => void; +} + +// Interface for constructor that returns array +export interface OverTypeConstructor { + new(target: string | Element | NodeList | Element[], options?: Options): OverTypeInstance[]; + // Static members + instances: WeakMap; + stylesInjected: boolean; + globalListenersInitialized: boolean; + instanceCount: number; + currentTheme: Theme; + themes: { + solar: Theme; + cave: Theme; + }; + MarkdownParser: any; + ShortcutsManager: any; + init(target: string | Element | NodeList | Element[], options?: Options): OverTypeInstance[]; + getInstance(element: Element): OverTypeInstance | null; + destroyAll(): void; + injectStyles(force?: boolean): void; + setCodeHighlighter(highlighterFn: (code: string, language: string) => string); + setTheme(theme: string | Theme, customColors?: Partial): void; + initGlobalListeners(): void; + getTheme(name: string): Theme; +} + +export interface OverTypeInstance { + // Public properties + container: HTMLElement; + wrapper: HTMLElement; + textarea: HTMLTextAreaElement; + preview: HTMLElement; + statsBar?: HTMLElement; + toolbar?: any; // Toolbar instance + shortcuts?: any; // ShortcutsManager instance + linkTooltip?: any; // LinkTooltip instance + options: Options; + initialized: boolean; + instanceId: number; + element: Element; + + // Public methods + getValue(): string; + setValue(value: string): void; + getStats(): Stats; + getContainer(): HTMLElement; + focus(): void; + blur(): void; + destroy(): void; + isInitialized(): boolean; + reinit(options: Options): void; + showStats(show: boolean): void; + setTheme(theme: string | Theme): void; + updatePreview(): void; +} + +// Declare the constructor as a constant with proper typing +declare const OverType: OverTypeConstructor; + +// Export the instance type under a different name for clarity +export type OverType = OverTypeInstance; + +// Module exports - default export is the constructor +export default OverType; \ No newline at end of file diff --git a/browser-extension/src/overtype/overtype.js b/browser-extension/src/overtype/overtype.js new file mode 100644 index 0000000..b7bd2c5 --- /dev/null +++ b/browser-extension/src/overtype/overtype.js @@ -0,0 +1,1317 @@ +/** + * OverType - A lightweight markdown editor library with perfect WYSIWYG alignment + * @version 1.0.0 + * @license MIT + */ + +import { MarkdownParser } from "./parser.js"; +import { ShortcutsManager } from "./shortcuts.js"; +import { generateStyles } from "./styles.js"; +import { getTheme, mergeTheme, solar, themeToCSSVars } from "./themes.js"; +import { Toolbar } from "./toolbar.js"; +import { LinkTooltip } from "./link-tooltip.js"; + +/** + * OverType Editor Class + */ +class OverType { + // Static properties + static instances = new WeakMap(); + static stylesInjected = false; + static globalListenersInitialized = false; + static instanceCount = 0; + + /** + * Constructor - Always returns an array of instances + * @param {string|Element|NodeList|Array} target - Target element(s) + * @param {Object} options - Configuration options + * @returns {Array} Array of OverType instances + */ + constructor(target, options = {}) { + // Convert target to array of elements + let elements; + + if (typeof target === "string") { + elements = document.querySelectorAll(target); + if (elements.length === 0) { + throw new Error(`No elements found for selector: ${target}`); + } + elements = Array.from(elements); + } else if (target instanceof Element) { + elements = [target]; + } else if (target instanceof NodeList) { + elements = Array.from(target); + } else if (Array.isArray(target)) { + elements = target; + } else { + throw new Error( + "Invalid target: must be selector string, Element, NodeList, or Array" + ); + } + + // Initialize all elements and return array + const instances = elements.map((element) => { + // Check for existing instance + if (element.overTypeInstance) { + // Re-init existing instance + element.overTypeInstance.reinit(options); + return element.overTypeInstance; + } + + // Create new instance + const instance = Object.create(OverType.prototype); + instance._init(element, options); + element.overTypeInstance = instance; + OverType.instances.set(element, instance); + return instance; + }); + + return instances; + } + + /** + * Internal initialization + * @private + */ + _init(element, options = {}) { + this.element = element; + + // Store the original theme option before merging + this.instanceTheme = options.theme || null; + + this.options = this._mergeOptions(options); + this.instanceId = ++OverType.instanceCount; + this.initialized = false; + + // Inject styles if needed + OverType.injectStyles(); + + // Initialize global listeners + OverType.initGlobalListeners(); + + // Check for existing OverType DOM structure + const container = element.querySelector(".overtype-container"); + const wrapper = element.querySelector(".overtype-wrapper"); + if (container || wrapper) { + this._recoverFromDOM(container, wrapper); + } else { + this._buildFromScratch(); + } + + // Setup shortcuts manager + this.shortcuts = new ShortcutsManager(this); + + // Setup link tooltip + this.linkTooltip = new LinkTooltip(this); + + // Setup toolbar if enabled + if (this.options.toolbar) { + this.toolbar = new Toolbar(this); + this.toolbar.create(); + + // Update toolbar states on selection change + this.textarea.addEventListener("selectionchange", () => { + this.toolbar.updateButtonStates(); + }); + this.textarea.addEventListener("input", () => { + this.toolbar.updateButtonStates(); + }); + } + + // Mark as initialized + this.initialized = true; + + // Call onChange if provided + if (this.options.onChange) { + this.options.onChange(this.getValue(), this); + } + } + + /** + * Merge user options with defaults + * @private + */ + _mergeOptions(options) { + const defaults = { + // Typography + fontSize: "14px", + lineHeight: 1.6, + /* System-first, guaranteed monospaced; avoids Android 'ui-monospace' pitfalls */ + fontFamily: + '"SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Code", Consolas, "Roboto Mono", "Noto Sans Mono", "Droid Sans Mono", "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", Courier, monospace', + padding: "16px", + + // Mobile styles + mobile: { + fontSize: "16px", // Prevent zoom on iOS + padding: "12px", + lineHeight: 1.5, + }, + + // Native textarea properties + textareaProps: {}, + + // Behavior + autofocus: false, + autoResize: false, // Auto-expand height with content + minHeight: "100px", // Minimum height for autoResize mode + maxHeight: null, // Maximum height for autoResize mode (null = unlimited) + placeholder: "Start typing...", + value: "", + + // Callbacks + onChange: null, + onKeydown: null, + + // Features + showActiveLineRaw: false, + showStats: false, + toolbar: false, + statsFormatter: null, + smartLists: true, // Enable smart list continuation + codeHighlighter: null, // Per-instance code highlighter + }; + + // Remove theme and colors from options - these are now global + const { theme, colors, ...cleanOptions } = options; + + return { + ...defaults, + ...cleanOptions, + }; + } + + /** + * Recover from existing DOM structure + * @private + */ + _recoverFromDOM(container, wrapper) { + // Handle old structure (wrapper only) or new structure (container + wrapper) + if (container && container.classList.contains("overtype-container")) { + this.container = container; + this.wrapper = container.querySelector(".overtype-wrapper"); + } else if (wrapper) { + // Old structure - just wrapper, no container + this.wrapper = wrapper; + // Wrap it in a container for consistency + this.container = document.createElement("div"); + this.container.className = "overtype-container"; + // Use instance theme if provided, otherwise use global theme + const themeToUse = this.instanceTheme || OverType.currentTheme || solar; + const themeName = + typeof themeToUse === "string" ? themeToUse : themeToUse.name; + if (themeName) { + this.container.setAttribute("data-theme", themeName); + } + + // If using instance theme, apply CSS variables to container + if (this.instanceTheme) { + const themeObj = + typeof this.instanceTheme === "string" + ? getTheme(this.instanceTheme) + : this.instanceTheme; + if (themeObj && themeObj.colors) { + const cssVars = themeToCSSVars(themeObj.colors); + this.container.style.cssText += cssVars; + } + } + wrapper.parentNode.insertBefore(this.container, wrapper); + this.container.appendChild(wrapper); + } + + if (!this.wrapper) { + // No valid structure found + if (container) container.remove(); + if (wrapper) wrapper.remove(); + this._buildFromScratch(); + return; + } + + this.textarea = this.wrapper.querySelector(".overtype-input"); + this.preview = this.wrapper.querySelector(".overtype-preview"); + + if (!this.textarea || !this.preview) { + // Partial DOM - clear and rebuild + this.container.remove(); + this._buildFromScratch(); + return; + } + + // Store reference on wrapper + this.wrapper._instance = this; + + // Apply instance-specific styles via CSS custom properties + if (this.options.fontSize) { + this.wrapper.style.setProperty( + "--instance-font-size", + this.options.fontSize + ); + } + if (this.options.lineHeight) { + this.wrapper.style.setProperty( + "--instance-line-height", + String(this.options.lineHeight) + ); + } + if (this.options.padding) { + this.wrapper.style.setProperty( + "--instance-padding", + this.options.padding + ); + } + + // Disable autofill, spellcheck, and extensions + this._configureTextarea(); + + // Apply any new options + this._applyOptions(); + } + + /** + * Build editor from scratch + * @private + */ + _buildFromScratch() { + // Extract any existing content + const content = this._extractContent(); + + // Clear element + this.element.innerHTML = ""; + + // Create DOM structure + this._createDOM(); + + // Set initial content + if (content || this.options.value) { + this.setValue(content || this.options.value); + } + + // Apply options + this._applyOptions(); + } + + /** + * Extract content from element + * @private + */ + _extractContent() { + // Look for existing OverType textarea + const textarea = this.element.querySelector(".overtype-input"); + if (textarea) return textarea.value; + + // Use element's text content as fallback + return this.element.textContent || ""; + } + + /** + * Create DOM structure + * @private + */ + _createDOM() { + // Create container that will hold toolbar and editor + this.container = document.createElement("div"); + this.container.className = "overtype-container"; + + // Set theme on container - use instance theme if provided + const themeToUse = this.instanceTheme || OverType.currentTheme || solar; + const themeName = + typeof themeToUse === "string" ? themeToUse : themeToUse.name; + if (themeName) { + this.container.setAttribute("data-theme", themeName); + } + + // If using instance theme, apply CSS variables to container + if (this.instanceTheme) { + const themeObj = + typeof this.instanceTheme === "string" + ? getTheme(this.instanceTheme) + : this.instanceTheme; + if (themeObj && themeObj.colors) { + const cssVars = themeToCSSVars(themeObj.colors); + this.container.style.cssText += cssVars; + } + } + + // Create wrapper for editor + this.wrapper = document.createElement("div"); + this.wrapper.className = "overtype-wrapper"; + + // Apply instance-specific styles via CSS custom properties + if (this.options.fontSize) { + this.wrapper.style.setProperty( + "--instance-font-size", + this.options.fontSize + ); + } + if (this.options.lineHeight) { + this.wrapper.style.setProperty( + "--instance-line-height", + String(this.options.lineHeight) + ); + } + if (this.options.padding) { + this.wrapper.style.setProperty( + "--instance-padding", + this.options.padding + ); + } + + this.wrapper._instance = this; + + // Create textarea + this.textarea = document.createElement("textarea"); + this.textarea.className = "overtype-input"; + this.textarea.placeholder = this.options.placeholder; + this._configureTextarea(); + + // Apply any native textarea properties + if (this.options.textareaProps) { + Object.entries(this.options.textareaProps).forEach(([key, value]) => { + if (key === "className" || key === "class") { + this.textarea.className += " " + value; + } else if (key === "style" && typeof value === "object") { + Object.assign(this.textarea.style, value); + } else { + this.textarea.setAttribute(key, value); + } + }); + } + + // Create preview div + this.preview = document.createElement("div"); + this.preview.className = "overtype-preview"; + this.preview.setAttribute("aria-hidden", "true"); + + // Assemble DOM + this.wrapper.appendChild(this.textarea); + this.wrapper.appendChild(this.preview); + + // No need to prevent link clicks - pointer-events handles this + + // Add wrapper to container first + this.container.appendChild(this.wrapper); + + // Add stats bar at the end (bottom) if enabled + if (this.options.showStats) { + this.statsBar = document.createElement("div"); + this.statsBar.className = "overtype-stats"; + this.container.appendChild(this.statsBar); + this._updateStats(); + } + + // Add container to element + this.element.appendChild(this.container); + + // Debug logging + if (window.location.pathname.includes("demo.html")) { + console.log("_createDOM completed:", { + elementId: this.element.id, + autoResize: this.options.autoResize, + containerClasses: this.container.className, + hasStats: !!this.statsBar, + hasToolbar: this.options.toolbar, + }); + } + + // Setup auto-resize if enabled + if (this.options.autoResize) { + this._setupAutoResize(); + } else { + // Ensure auto-resize class is removed if not using auto-resize + this.container.classList.remove("overtype-auto-resize"); + + if (window.location.pathname.includes("demo.html")) { + console.log("Removed auto-resize class from:", this.element.id); + } + } + } + + /** + * Configure textarea attributes + * @private + */ + _configureTextarea() { + this.textarea.setAttribute("autocomplete", "off"); + this.textarea.setAttribute("autocorrect", "off"); + this.textarea.setAttribute("autocapitalize", "off"); + this.textarea.setAttribute("spellcheck", "false"); + this.textarea.setAttribute("data-gramm", "false"); + this.textarea.setAttribute("data-gramm_editor", "false"); + this.textarea.setAttribute("data-enable-grammarly", "false"); + } + + /** + * Apply options to the editor + * @private + */ + _applyOptions() { + // Apply autofocus + if (this.options.autofocus) { + this.textarea.focus(); + } + + // Setup or remove auto-resize + if (this.options.autoResize) { + if (!this.container.classList.contains("overtype-auto-resize")) { + this._setupAutoResize(); + } + } else { + // Ensure auto-resize class is removed + this.container.classList.remove("overtype-auto-resize"); + } + + // Update preview with initial content + this.updatePreview(); + } + + /** + * Update preview with parsed markdown + */ + updatePreview() { + const text = this.textarea.value; + const cursorPos = this.textarea.selectionStart; + const activeLine = this._getCurrentLine(text, cursorPos); + + // Parse markdown + const html = MarkdownParser.parse( + text, + activeLine, + this.options.showActiveLineRaw, + this.options.codeHighlighter + ); + this.preview.innerHTML = + html || 'Start typing...'; + + // Apply code block backgrounds + this._applyCodeBlockBackgrounds(); + + // Links always have real hrefs now - no need to update them + + // Update stats if enabled + if (this.options.showStats && this.statsBar) { + this._updateStats(); + } + + // Trigger onChange callback + if (this.options.onChange && this.initialized) { + this.options.onChange(text, this); + } + } + + /** + * Apply background styling to code blocks + * @private + */ + _applyCodeBlockBackgrounds() { + // Find all code fence elements + const codeFences = this.preview.querySelectorAll(".code-fence"); + + // Process pairs of code fences + for (let i = 0; i < codeFences.length - 1; i += 2) { + const openFence = codeFences[i]; + const closeFence = codeFences[i + 1]; + + // Get parent divs + const openParent = openFence.parentElement; + const closeParent = closeFence.parentElement; + + if (!openParent || !closeParent) continue; + + // Make fences display: block + openFence.style.display = "block"; + closeFence.style.display = "block"; + + // Apply class to parent divs + openParent.classList.add("code-block-line"); + closeParent.classList.add("code-block-line"); + + // With the new structure, there's a
 block between fences, not DIVs
+      // We don't need to process anything between the fences anymore
+      // The 
 structure already handles the content correctly
+    }
+  }
+
+  /**
+   * Get current line number from cursor position
+   * @private
+   */
+  _getCurrentLine(text, cursorPos) {
+    const lines = text.substring(0, cursorPos).split("\n");
+    return lines.length - 1;
+  }
+
+  /**
+   * Handle input events
+   * @private
+   */
+  handleInput(event) {
+    this.updatePreview();
+  }
+
+  /**
+   * Handle keydown events
+   * @private
+   */
+  handleKeydown(event) {
+    // Handle Tab key to prevent focus loss and insert spaces
+    if (event.key === "Tab") {
+      event.preventDefault();
+
+      const start = this.textarea.selectionStart;
+      const end = this.textarea.selectionEnd;
+      const value = this.textarea.value;
+
+      // If there's a selection, indent/outdent based on shift key
+      if (start !== end && event.shiftKey) {
+        // Outdent: remove 2 spaces from start of each selected line
+        const before = value.substring(0, start);
+        const selection = value.substring(start, end);
+        const after = value.substring(end);
+
+        const lines = selection.split("\n");
+        const outdented = lines
+          .map((line) => line.replace(/^  /, ""))
+          .join("\n");
+
+        // Try to use execCommand first to preserve undo history
+        if (document.execCommand) {
+          // Select the text that needs to be replaced
+          this.textarea.setSelectionRange(start, end);
+          document.execCommand("insertText", false, outdented);
+        } else {
+          // Fallback to direct manipulation
+          this.textarea.value = before + outdented + after;
+          this.textarea.selectionStart = start;
+          this.textarea.selectionEnd = start + outdented.length;
+        }
+      } else if (start !== end) {
+        // Indent: add 2 spaces to start of each selected line
+        const before = value.substring(0, start);
+        const selection = value.substring(start, end);
+        const after = value.substring(end);
+
+        const lines = selection.split("\n");
+        const indented = lines.map((line) => "  " + line).join("\n");
+
+        // Try to use execCommand first to preserve undo history
+        if (document.execCommand) {
+          // Select the text that needs to be replaced
+          this.textarea.setSelectionRange(start, end);
+          document.execCommand("insertText", false, indented);
+        } else {
+          // Fallback to direct manipulation
+          this.textarea.value = before + indented + after;
+          this.textarea.selectionStart = start;
+          this.textarea.selectionEnd = start + indented.length;
+        }
+      } else {
+        // No selection: just insert 2 spaces
+        // Use execCommand to preserve undo history
+        if (document.execCommand) {
+          document.execCommand("insertText", false, "  ");
+        } else {
+          // Fallback to direct manipulation
+          this.textarea.value =
+            value.substring(0, start) + "  " + value.substring(end);
+          this.textarea.selectionStart = this.textarea.selectionEnd = start + 2;
+        }
+      }
+
+      // Trigger input event to update preview
+      this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
+      return;
+    }
+
+    // Handle Enter key for smart list continuation
+    if (
+      event.key === "Enter" &&
+      !event.shiftKey &&
+      !event.metaKey &&
+      !event.ctrlKey &&
+      this.options.smartLists
+    ) {
+      if (this.handleSmartListContinuation()) {
+        event.preventDefault();
+        return;
+      }
+    }
+
+    // Let shortcuts manager handle other keys
+    const handled = this.shortcuts.handleKeydown(event);
+
+    // Call user callback if provided
+    if (!handled && this.options.onKeydown) {
+      this.options.onKeydown(event, this);
+    }
+  }
+
+  /**
+   * Handle smart list continuation
+   * @returns {boolean} Whether the event was handled
+   */
+  handleSmartListContinuation() {
+    const textarea = this.textarea;
+    const cursorPos = textarea.selectionStart;
+    const context = MarkdownParser.getListContext(textarea.value, cursorPos);
+
+    if (!context || !context.inList) return false;
+
+    // Handle empty list item (exit list)
+    if (context.content.trim() === "" && cursorPos >= context.markerEndPos) {
+      this.deleteListMarker(context);
+      return true;
+    }
+
+    // Handle text splitting if cursor is in middle of content
+    if (cursorPos > context.markerEndPos && cursorPos < context.lineEnd) {
+      this.splitListItem(context, cursorPos);
+    } else {
+      // Just add new item after current line
+      this.insertNewListItem(context);
+    }
+
+    // Handle numbered list renumbering
+    if (context.listType === "numbered") {
+      this.scheduleNumberedListUpdate();
+    }
+
+    return true;
+  }
+
+  /**
+   * Delete list marker and exit list
+   * @private
+   */
+  deleteListMarker(context) {
+    // Select from line start to marker end
+    this.textarea.setSelectionRange(context.lineStart, context.markerEndPos);
+    document.execCommand("delete");
+
+    // Trigger input event
+    this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
+  }
+
+  /**
+   * Insert new list item
+   * @private
+   */
+  insertNewListItem(context) {
+    const newItem = MarkdownParser.createNewListItem(context);
+    document.execCommand("insertText", false, "\n" + newItem);
+
+    // Trigger input event
+    this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
+  }
+
+  /**
+   * Split list item at cursor position
+   * @private
+   */
+  splitListItem(context, cursorPos) {
+    // Get text after cursor
+    const textAfterCursor = context.content.substring(
+      cursorPos - context.markerEndPos
+    );
+
+    // Delete text after cursor
+    this.textarea.setSelectionRange(cursorPos, context.lineEnd);
+    document.execCommand("delete");
+
+    // Insert new list item with remaining text
+    const newItem = MarkdownParser.createNewListItem(context);
+    document.execCommand("insertText", false, "\n" + newItem + textAfterCursor);
+
+    // Position cursor after new list marker
+    const newCursorPos = this.textarea.selectionStart - textAfterCursor.length;
+    this.textarea.setSelectionRange(newCursorPos, newCursorPos);
+
+    // Trigger input event
+    this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
+  }
+
+  /**
+   * Schedule numbered list renumbering
+   * @private
+   */
+  scheduleNumberedListUpdate() {
+    // Clear any pending update
+    if (this.numberUpdateTimeout) {
+      clearTimeout(this.numberUpdateTimeout);
+    }
+
+    // Schedule update after current input cycle
+    this.numberUpdateTimeout = setTimeout(() => {
+      this.updateNumberedLists();
+    }, 10);
+  }
+
+  /**
+   * Update/renumber all numbered lists
+   * @private
+   */
+  updateNumberedLists() {
+    const value = this.textarea.value;
+    const cursorPos = this.textarea.selectionStart;
+
+    const newValue = MarkdownParser.renumberLists(value);
+
+    if (newValue !== value) {
+      // Calculate cursor offset
+      let offset = 0;
+      const oldLines = value.split("\n");
+      const newLines = newValue.split("\n");
+      let charCount = 0;
+
+      for (let i = 0; i < oldLines.length && charCount < cursorPos; i++) {
+        if (oldLines[i] !== newLines[i]) {
+          const diff = newLines[i].length - oldLines[i].length;
+          if (charCount + oldLines[i].length < cursorPos) {
+            offset += diff;
+          }
+        }
+        charCount += oldLines[i].length + 1; // +1 for newline
+      }
+
+      // Update textarea
+      this.textarea.value = newValue;
+      const newCursorPos = cursorPos + offset;
+      this.textarea.setSelectionRange(newCursorPos, newCursorPos);
+
+      // Trigger update
+      this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
+    }
+  }
+
+  /**
+   * Handle scroll events
+   * @private
+   */
+  handleScroll(event) {
+    // Sync preview scroll with textarea
+    this.preview.scrollTop = this.textarea.scrollTop;
+    this.preview.scrollLeft = this.textarea.scrollLeft;
+  }
+
+  /**
+   * Get editor content
+   * @returns {string} Current markdown content
+   */
+  getValue() {
+    return this.textarea.value;
+  }
+
+  /**
+   * Set editor content
+   * @param {string} value - Markdown content to set
+   */
+  setValue(value) {
+    this.textarea.value = value;
+    this.updatePreview();
+
+    // Update height if auto-resize is enabled
+    if (this.options.autoResize) {
+      this._updateAutoHeight();
+    }
+  }
+
+  /**
+   * Get the rendered HTML of the current content
+   * @param {boolean} processForPreview - If true, post-processes HTML for preview mode (consolidates lists/code blocks)
+   * @returns {string} Rendered HTML
+   */
+  getRenderedHTML(processForPreview = false) {
+    const markdown = this.getValue();
+    let html = MarkdownParser.parse(
+      markdown,
+      -1,
+      false,
+      this.options.codeHighlighter
+    );
+
+    if (processForPreview) {
+      // Post-process HTML for preview mode
+      html = MarkdownParser.postProcessHTML(html, this.options.codeHighlighter);
+    }
+
+    return html;
+  }
+
+  /**
+   * Get the current preview element's HTML
+   * @returns {string} Current preview HTML (as displayed)
+   */
+  getPreviewHTML() {
+    return this.preview.innerHTML;
+  }
+
+  /**
+   * Focus the editor
+   */
+  focus() {
+    this.textarea.focus();
+  }
+
+  /**
+   * Blur the editor
+   */
+  blur() {
+    this.textarea.blur();
+  }
+
+  /**
+   * Check if editor is initialized
+   * @returns {boolean}
+   */
+  isInitialized() {
+    return this.initialized;
+  }
+
+  /**
+   * Re-initialize with new options
+   * @param {Object} options - New options to apply
+   */
+  reinit(options = {}) {
+    this.options = this._mergeOptions({ ...this.options, ...options });
+    this._applyOptions();
+    this.updatePreview();
+  }
+
+  /**
+   * Set instance-specific code highlighter
+   * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML
+   */
+  setCodeHighlighter(highlighter) {
+    this.options.codeHighlighter = highlighter;
+    this.updatePreview();
+  }
+
+  /**
+   * Update stats bar
+   * @private
+   */
+  _updateStats() {
+    if (!this.statsBar) return;
+
+    const value = this.textarea.value;
+    const lines = value.split("\n");
+    const chars = value.length;
+    const words = value.split(/\s+/).filter((w) => w.length > 0).length;
+
+    // Calculate line and column
+    const selectionStart = this.textarea.selectionStart;
+    const beforeCursor = value.substring(0, selectionStart);
+    const linesBeforeCursor = beforeCursor.split("\n");
+    const currentLine = linesBeforeCursor.length;
+    const currentColumn =
+      linesBeforeCursor[linesBeforeCursor.length - 1].length + 1;
+
+    // Use custom formatter if provided
+    if (this.options.statsFormatter) {
+      this.statsBar.innerHTML = this.options.statsFormatter({
+        chars,
+        words,
+        lines: lines.length,
+        line: currentLine,
+        column: currentColumn,
+      });
+    } else {
+      // Default format with live dot
+      this.statsBar.innerHTML = `
+          
+ + ${chars} chars, ${words} words, ${lines.length} lines +
+
Line ${currentLine}, Col ${currentColumn}
+ `; + } + } + + /** + * Setup auto-resize functionality + * @private + */ + _setupAutoResize() { + // Add auto-resize class for styling + this.container.classList.add("overtype-auto-resize"); + + // Store previous height for comparison + this.previousHeight = null; + + // Initial height update + this._updateAutoHeight(); + + // Listen for input events + this.textarea.addEventListener("input", () => this._updateAutoHeight()); + + // Listen for window resize + window.addEventListener("resize", () => this._updateAutoHeight()); + } + + /** + * Update height based on scrollHeight + * @private + */ + _updateAutoHeight() { + if (!this.options.autoResize) return; + + const textarea = this.textarea; + const preview = this.preview; + const wrapper = this.wrapper; + + // Get computed styles + const computed = window.getComputedStyle(textarea); + const paddingTop = parseFloat(computed.paddingTop); + const paddingBottom = parseFloat(computed.paddingBottom); + + // Store scroll positions + const scrollTop = textarea.scrollTop; + + // Reset height to get accurate scrollHeight + textarea.style.setProperty("height", "auto", "important"); + + // Calculate new height based on scrollHeight + let newHeight = textarea.scrollHeight; + + // Apply min height constraint + if (this.options.minHeight) { + const minHeight = parseInt(this.options.minHeight); + newHeight = Math.max(newHeight, minHeight); + } + + // Apply max height constraint + let overflow = "hidden"; + if (this.options.maxHeight) { + const maxHeight = parseInt(this.options.maxHeight); + if (newHeight > maxHeight) { + newHeight = maxHeight; + overflow = "auto"; + } + } + + // Apply the new height to all elements with !important to override base styles + const heightPx = newHeight + "px"; + textarea.style.setProperty("height", heightPx, "important"); + textarea.style.setProperty("overflow-y", overflow, "important"); + + preview.style.setProperty("height", heightPx, "important"); + preview.style.setProperty("overflow-y", overflow, "important"); + + wrapper.style.setProperty("height", heightPx, "important"); + + // Restore scroll position + textarea.scrollTop = scrollTop; + preview.scrollTop = scrollTop; + + // Track if height changed + if (this.previousHeight !== newHeight) { + this.previousHeight = newHeight; + // Could dispatch a custom event here if needed + } + } + + /** + * Show or hide stats bar + * @param {boolean} show - Whether to show stats + */ + showStats(show) { + this.options.showStats = show; + + if (show && !this.statsBar) { + // Create stats bar (add to container, not wrapper) + this.statsBar = document.createElement("div"); + this.statsBar.className = "overtype-stats"; + this.container.appendChild(this.statsBar); + this._updateStats(); + } else if (!show && this.statsBar) { + // Remove stats bar + this.statsBar.remove(); + this.statsBar = null; + } + } + + /** + * Show or hide the plain textarea (toggle overlay visibility) + * @param {boolean} show - true to show plain textarea (hide overlay), false to show overlay + * @returns {boolean} Current plain textarea state + */ + showPlainTextarea(show) { + if (show) { + // Show plain textarea mode (hide overlay) + this.container.classList.add("plain-mode"); + } else { + // Show overlay mode (hide plain textarea text) + this.container.classList.remove("plain-mode"); + } + + // Update toolbar button if exists + if (this.toolbar) { + const toggleBtn = this.container.querySelector( + '[data-action="toggle-plain"]' + ); + if (toggleBtn) { + // Button is active when showing overlay (not plain mode) + toggleBtn.classList.toggle("active", !show); + toggleBtn.title = show + ? "Show markdown preview" + : "Show plain textarea"; + } + } + + return show; + } + + /** + * Show/hide preview mode + * @param {boolean} show - Show preview mode if true, edit mode if false + * @returns {boolean} Current preview mode state + */ + showPreviewMode(show) { + if (show) { + // Show preview mode (hide textarea, make preview interactive) + this.container.classList.add("preview-mode"); + } else { + // Show edit mode + this.container.classList.remove("preview-mode"); + } + + return show; + } + + /** + * Destroy the editor instance + */ + destroy() { + // Remove instance reference + this.element.overTypeInstance = null; + OverType.instances.delete(this.element); + + // Cleanup shortcuts + if (this.shortcuts) { + this.shortcuts.destroy(); + } + + // Remove DOM if created by us + if (this.wrapper) { + const content = this.getValue(); + this.wrapper.remove(); + + // Restore original content + this.element.textContent = content; + } + + this.initialized = false; + } + + // ===== Static Methods ===== + + /** + * Initialize multiple editors (static convenience method) + * @param {string|Element|NodeList|Array} target - Target element(s) + * @param {Object} options - Configuration options + * @returns {Array} Array of OverType instances + */ + static init(target, options = {}) { + return new OverType(target, options); + } + + /** + * Get instance from element + * @param {Element} element - DOM element + * @returns {OverType|null} OverType instance or null + */ + static getInstance(element) { + return element.overTypeInstance || OverType.instances.get(element) || null; + } + + /** + * Destroy all instances + */ + static destroyAll() { + const elements = document.querySelectorAll("[data-overtype-instance]"); + elements.forEach((element) => { + const instance = OverType.getInstance(element); + if (instance) { + instance.destroy(); + } + }); + } + + /** + * Inject styles into the document + * @param {boolean} force - Force re-injection + */ + static injectStyles(force = false) { + if (OverType.stylesInjected && !force) return; + + // Remove any existing OverType styles + const existing = document.querySelector("style.overtype-styles"); + if (existing) { + existing.remove(); + } + + // Generate and inject new styles with current theme + const theme = OverType.currentTheme || solar; + const styles = generateStyles({ theme }); + const styleEl = document.createElement("style"); + styleEl.className = "overtype-styles"; + styleEl.textContent = styles; + document.head.appendChild(styleEl); + + OverType.stylesInjected = true; + } + + /** + * Set global code highlighter for all OverType instances + * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML + */ + static setCodeHighlighter(highlighter) { + MarkdownParser.setCodeHighlighter(highlighter); + + // Update all existing instances + document.querySelectorAll(".overtype-wrapper").forEach((wrapper) => { + const instance = wrapper._instance; + if (instance && instance.updatePreview) { + instance.updatePreview(); + } + }); + } + + /** + * Set global theme for all OverType instances + * @param {string|Object} theme - Theme name or custom theme object + * @param {Object} customColors - Optional color overrides + */ + static setTheme(theme, customColors = null) { + // Process theme + let themeObj = typeof theme === "string" ? getTheme(theme) : theme; + + // Apply custom colors if provided + if (customColors) { + themeObj = mergeTheme(themeObj, customColors); + } + + // Store as current theme + OverType.currentTheme = themeObj; + + // Re-inject styles with new theme + OverType.injectStyles(true); + + // Update all existing instances - update container theme attribute + document.querySelectorAll(".overtype-container").forEach((container) => { + const themeName = typeof themeObj === "string" ? themeObj : themeObj.name; + if (themeName) { + container.setAttribute("data-theme", themeName); + } + }); + + // Also handle any old-style wrappers without containers + document.querySelectorAll(".overtype-wrapper").forEach((wrapper) => { + if (!wrapper.closest(".overtype-container")) { + const themeName = + typeof themeObj === "string" ? themeObj : themeObj.name; + if (themeName) { + wrapper.setAttribute("data-theme", themeName); + } + } + + // Trigger preview update for the instance + const instance = wrapper._instance; + if (instance) { + instance.updatePreview(); + } + }); + } + + /** + * Initialize global event listeners + */ + static initGlobalListeners() { + if (OverType.globalListenersInitialized) return; + + // Input event + document.addEventListener("input", (e) => { + if ( + e.target && + e.target.classList && + e.target.classList.contains("overtype-input") + ) { + const wrapper = e.target.closest(".overtype-wrapper"); + const instance = wrapper?._instance; + if (instance) instance.handleInput(e); + } + }); + + // Keydown event + document.addEventListener("keydown", (e) => { + if ( + e.target && + e.target.classList && + e.target.classList.contains("overtype-input") + ) { + const wrapper = e.target.closest(".overtype-wrapper"); + const instance = wrapper?._instance; + if (instance) instance.handleKeydown(e); + } + }); + + // Scroll event + document.addEventListener( + "scroll", + (e) => { + if ( + e.target && + e.target.classList && + e.target.classList.contains("overtype-input") + ) { + const wrapper = e.target.closest(".overtype-wrapper"); + const instance = wrapper?._instance; + if (instance) instance.handleScroll(e); + } + }, + true + ); + + // Selection change event + document.addEventListener("selectionchange", (e) => { + const activeElement = document.activeElement; + if (activeElement && activeElement.classList.contains("overtype-input")) { + const wrapper = activeElement.closest(".overtype-wrapper"); + const instance = wrapper?._instance; + if (instance) { + // Update stats bar for cursor position + if (instance.options.showStats && instance.statsBar) { + instance._updateStats(); + } + // Debounce updates + clearTimeout(instance._selectionTimeout); + instance._selectionTimeout = setTimeout(() => { + instance.updatePreview(); + }, 50); + } + } + }); + + OverType.globalListenersInitialized = true; + } +} + +// Export classes for advanced usage +OverType.MarkdownParser = MarkdownParser; +OverType.ShortcutsManager = ShortcutsManager; + +// Export theme utilities +OverType.themes = { solar, cave: getTheme("cave") }; +OverType.getTheme = getTheme; + +// Set default theme +OverType.currentTheme = solar; + +// Only attach to global in browser environments (not Node.js) +if (typeof window !== "undefined" && typeof window.document !== "undefined") { + // Browser environment - attach to window + window.OverType = OverType; +} + +// Export for module systems +export default OverType; +export { OverType }; diff --git a/browser-extension/src/overtype/parser.js b/browser-extension/src/overtype/parser.js new file mode 100644 index 0000000..3579ad4 --- /dev/null +++ b/browser-extension/src/overtype/parser.js @@ -0,0 +1,784 @@ +/** + * MarkdownParser - Parses markdown into HTML while preserving character alignment + * + * Key principles: + * - Every character must occupy the exact same position as in the textarea + * - No font-size changes, no padding/margin on inline elements + * - Markdown tokens remain visible but styled + */ +export class MarkdownParser { + // Track link index for anchor naming + static linkIndex = 0; + + // Global code highlighter function + static codeHighlighter = null; + + /** + * Reset link index (call before parsing a new document) + */ + static resetLinkIndex() { + this.linkIndex = 0; + } + + /** + * Set global code highlighter function + * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML + */ + static setCodeHighlighter(highlighter) { + this.codeHighlighter = highlighter; + } + + /** + * Escape HTML special characters + * @param {string} text - Raw text to escape + * @returns {string} Escaped HTML-safe text + */ + static escapeHtml(text) { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + + /** + * Preserve leading spaces as non-breaking spaces + * @param {string} html - HTML string + * @param {string} originalLine - Original line with spaces + * @returns {string} HTML with preserved indentation + */ + static preserveIndentation(html, originalLine) { + const leadingSpaces = originalLine.match(/^(\s*)/)[1]; + const indentation = leadingSpaces.replace(/ /g, " "); + return html.replace(/^\s*/, indentation); + } + + /** + * Parse headers (h1-h3 only) + * @param {string} html - HTML line to parse + * @returns {string} Parsed HTML with header styling + */ + static parseHeader(html) { + return html.replace(/^(#{1,3})\s(.+)$/, (match, hashes, content) => { + const level = hashes.length; + return `${hashes} ${content}`; + }); + } + + /** + * Parse horizontal rules + * @param {string} html - HTML line to parse + * @returns {string|null} Parsed horizontal rule or null + */ + static parseHorizontalRule(html) { + if (html.match(/^(-{3,}|\*{3,}|_{3,})$/)) { + return `
${html}
`; + } + return null; + } + + /** + * Parse blockquotes + * @param {string} html - HTML line to parse + * @returns {string} Parsed blockquote + */ + static parseBlockquote(html) { + return html.replace(/^> (.+)$/, (match, content) => { + return `> ${content}`; + }); + } + + /** + * Parse bullet lists + * @param {string} html - HTML line to parse + * @returns {string} Parsed bullet list item + */ + static parseBulletList(html) { + return html.replace( + /^((?: )*)([-*])\s(.+)$/, + (match, indent, marker, content) => { + return `${indent}
  • ${marker} ${content}
  • `; + } + ); + } + + /** + * Parse numbered lists + * @param {string} html - HTML line to parse + * @returns {string} Parsed numbered list item + */ + static parseNumberedList(html) { + return html.replace( + /^((?: )*)(\d+\.)\s(.+)$/, + (match, indent, marker, content) => { + return `${indent}
  • ${marker} ${content}
  • `; + } + ); + } + + /** + * Parse code blocks (markers only) + * @param {string} html - HTML line to parse + * @returns {string|null} Parsed code fence or null + */ + static parseCodeBlock(html) { + // The line must start with three backticks and have no backticks after subsequent text + const codeFenceRegex = /^`{3}[^`]*$/; + if (codeFenceRegex.test(html)) { + return `
    ${html}
    `; + } + return null; + } + + /** + * Parse bold text + * @param {string} html - HTML with potential bold markdown + * @returns {string} HTML with bold styling + */ + static parseBold(html) { + html = html.replace( + /\*\*(.+?)\*\*/g, + '**$1**' + ); + html = html.replace( + /__(.+?)__/g, + '__$1__' + ); + return html; + } + + /** + * Parse italic text + * Note: Uses lookbehind assertions - requires modern browsers + * @param {string} html - HTML with potential italic markdown + * @returns {string} HTML with italic styling + */ + static parseItalic(html) { + html = html.replace( + /(?*$1*' + ); + html = html.replace( + /(?_$1_' + ); + return html; + } + + /** + * Parse inline code + * @param {string} html - HTML with potential code markdown + * @returns {string} HTML with code styling + */ + static parseInlineCode(html) { + // Must have equal number of backticks before and after inline code + // + // Regex explainer: + // (?$1$2$3
    ' + ); + } + + /** + * Sanitize URL to prevent XSS attacks + * @param {string} url - URL to sanitize + * @returns {string} Safe URL or '#' if dangerous + */ + static sanitizeUrl(url) { + // Trim whitespace and convert to lowercase for protocol check + const trimmed = url.trim(); + const lower = trimmed.toLowerCase(); + + // Allow safe protocols + const safeProtocols = [ + "http://", + "https://", + "mailto:", + "ftp://", + "ftps://", + ]; + + // Check if URL starts with a safe protocol + const hasSafeProtocol = safeProtocols.some((protocol) => + lower.startsWith(protocol) + ); + + // Allow relative URLs (starting with / or # or no protocol) + const isRelative = + trimmed.startsWith("/") || + trimmed.startsWith("#") || + trimmed.startsWith("?") || + trimmed.startsWith(".") || + (!trimmed.includes(":") && !trimmed.includes("//")); + + // If safe protocol or relative URL, return as-is + if (hasSafeProtocol || isRelative) { + return url; + } + + // Block dangerous protocols (javascript:, data:, vbscript:, etc.) + return "#"; + } + + /** + * Parse links + * @param {string} html - HTML with potential link markdown + * @returns {string} HTML with link styling + */ + static parseLinks(html) { + return html.replace(/\[(.+?)\]\((.+?)\)/g, (match, text, url) => { + const anchorName = `--link-${this.linkIndex++}`; + // Sanitize URL to prevent XSS attacks + const safeUrl = this.sanitizeUrl(url); + // Use real href - pointer-events handles click prevention in normal mode + return `[${text}](${url})`; + }); + } + + /** + * Parse all inline elements in correct order + * @param {string} text - Text with potential inline markdown + * @returns {string} HTML with all inline styling + */ + static parseInlineElements(text) { + let html = text; + // Order matters: parse code first + html = this.parseInlineCode(html); + + // Use placeholders to protect inline code while preserving formatting spans + // We use Unicode Private Use Area (U+E000-U+F8FF) as placeholders because: + // 1. These characters are reserved for application-specific use + // 2. They'll never appear in user text + // 3. They maintain single-character width (important for alignment) + // 4. They're invisible if accidentally rendered + const sanctuaries = new Map(); + + // Protect code blocks + html = html.replace(/(.*?<\/code>)/g, (match) => { + const placeholder = `\uE000${sanctuaries.size}\uE001`; + sanctuaries.set(placeholder, match); + return placeholder; + }); + + // Parse links AFTER protecting code but BEFORE bold/italic + // This ensures link URLs don't get processed as markdown + html = this.parseLinks(html); + + // Protect entire link elements (not just the URL part) + html = html.replace(/(]*>.*?<\/a>)/g, (match) => { + const placeholder = `\uE000${sanctuaries.size}\uE001`; + sanctuaries.set(placeholder, match); + return placeholder; + }); + + // Process other inline elements on text with placeholders + html = this.parseBold(html); + html = this.parseItalic(html); + + // Restore all sanctuaries + sanctuaries.forEach((content, placeholder) => { + html = html.replace(placeholder, content); + }); + + return html; + } + + /** + * Parse a single line of markdown + * @param {string} line - Raw markdown line + * @returns {string} Parsed HTML line + */ + static parseLine(line) { + let html = this.escapeHtml(line); + + // Preserve indentation + html = this.preserveIndentation(html, line); + + // Check for block elements first + const horizontalRule = this.parseHorizontalRule(html); + if (horizontalRule) return horizontalRule; + + const codeBlock = this.parseCodeBlock(html); + if (codeBlock) return codeBlock; + + // Parse block elements + html = this.parseHeader(html); + html = this.parseBlockquote(html); + html = this.parseBulletList(html); + html = this.parseNumberedList(html); + + // Parse inline elements + html = this.parseInlineElements(html); + + // Wrap in div to maintain line structure + if (html.trim() === "") { + // Intentionally use   for empty lines to maintain vertical spacing + // This causes a 0->1 character count difference but preserves visual alignment + return "
     
    "; + } + + return `
    ${html}
    `; + } + + /** + * Parse full markdown text + * @param {string} text - Full markdown text + * @param {number} activeLine - Currently active line index (optional) + * @param {boolean} showActiveLineRaw - Show raw markdown on active line + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) + * @returns {string} Parsed HTML + */ + static parse( + text, + activeLine = -1, + showActiveLineRaw = false, + instanceHighlighter = null + ) { + // Reset link counter for each parse + this.resetLinkIndex(); + + const lines = text.split("\n"); + let inCodeBlock = false; + + const parsedLines = lines.map((line, index) => { + // Show raw markdown on active line if requested + if (showActiveLineRaw && index === activeLine) { + const content = this.escapeHtml(line) || " "; + return `
    ${content}
    `; + } + + // Check if this line is a code fence + const codeFenceRegex = /^```[^`]*$/; + if (codeFenceRegex.test(line)) { + inCodeBlock = !inCodeBlock; + // Parse fence markers normally to get styled output + return this.parseLine(line); + } + + // If we're inside a code block, don't parse as markdown + if (inCodeBlock) { + const escaped = this.escapeHtml(line); + const indented = this.preserveIndentation(escaped, line); + return `
    ${indented || " "}
    `; + } + + // Otherwise, parse the markdown normally + return this.parseLine(line); + }); + + // Join without newlines to prevent extra spacing + const html = parsedLines.join(""); + + // Apply post-processing for list consolidation + return this.postProcessHTML(html, instanceHighlighter); + } + + /** + * Post-process HTML to consolidate lists and code blocks + * @param {string} html - HTML to post-process + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) + * @returns {string} Post-processed HTML with consolidated lists and code blocks + */ + static postProcessHTML(html, instanceHighlighter = null) { + // Check if we're in a browser environment + if (typeof document === "undefined" || !document) { + // In Node.js environment - do manual post-processing + return this.postProcessHTMLManual(html, instanceHighlighter); + } + + // Parse HTML string into DOM + const container = document.createElement("div"); + container.innerHTML = html; + + let currentList = null; + let listType = null; + let currentCodeBlock = null; + let inCodeBlock = false; + + // Process all direct children - need to be careful with live NodeList + const children = Array.from(container.children); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + // Skip if child was already processed/removed + if (!child.parentNode) continue; + + // Check for code fence start/end + const codeFence = child.querySelector(".code-fence"); + if (codeFence) { + const fenceText = codeFence.textContent; + if (fenceText.startsWith("```")) { + if (!inCodeBlock) { + // Start of code block - keep fence visible, then add pre/code + inCodeBlock = true; + + // Create the code block that will follow the fence + currentCodeBlock = document.createElement("pre"); + const codeElement = document.createElement("code"); + currentCodeBlock.appendChild(codeElement); + currentCodeBlock.className = "code-block"; + + // Extract language if present + const lang = fenceText.slice(3).trim(); + if (lang) { + codeElement.className = `language-${lang}`; + } + + // Insert code block after the fence div (don't remove the fence) + container.insertBefore(currentCodeBlock, child.nextSibling); + + // Store reference to the code element for adding content + currentCodeBlock._codeElement = codeElement; + currentCodeBlock._language = lang; + currentCodeBlock._codeContent = ""; + continue; + } else { + // End of code block - apply highlighting if needed + const highlighter = instanceHighlighter || this.codeHighlighter; + if ( + currentCodeBlock && + highlighter && + currentCodeBlock._codeContent + ) { + try { + const highlightedCode = highlighter( + currentCodeBlock._codeContent, + currentCodeBlock._language || "" + ); + currentCodeBlock._codeElement.innerHTML = highlightedCode; + } catch (error) { + console.warn("Code highlighting failed:", error); + // Keep the plain text content as fallback + } + } + + inCodeBlock = false; + currentCodeBlock = null; + continue; + } + } + } + + // Check if we're in a code block - any div that's not a code fence + if ( + inCodeBlock && + currentCodeBlock && + child.tagName === "DIV" && + !child.querySelector(".code-fence") + ) { + const codeElement = + currentCodeBlock._codeElement || + currentCodeBlock.querySelector("code"); + // Add the line content to the code block content (for highlighting) + if (currentCodeBlock._codeContent.length > 0) { + currentCodeBlock._codeContent += "\n"; + } + // Get the actual text content, preserving spaces + const lineText = child.textContent.replace(/\u00A0/g, " "); // \u00A0 is nbsp + currentCodeBlock._codeContent += lineText; + + // Also add to the code element (fallback if no highlighter) + if (codeElement.textContent.length > 0) { + codeElement.textContent += "\n"; + } + codeElement.textContent += lineText; + child.remove(); + continue; + } + + // Check if this div contains a list item + let listItem = null; + if (child.tagName === "DIV") { + // Look for li inside the div + listItem = child.querySelector("li"); + } + + if (listItem) { + const isBullet = listItem.classList.contains("bullet-list"); + const isOrdered = listItem.classList.contains("ordered-list"); + + if (!isBullet && !isOrdered) { + currentList = null; + listType = null; + continue; + } + + const newType = isBullet ? "ul" : "ol"; + + // Start new list or continue current + if (!currentList || listType !== newType) { + currentList = document.createElement(newType); + container.insertBefore(currentList, child); + listType = newType; + } + + // Move the list item to the current list + currentList.appendChild(listItem); + + // Remove the now-empty div wrapper + child.remove(); + } else { + // Non-list element ends current list + currentList = null; + listType = null; + } + } + + return container.innerHTML; + } + + /** + * Manual post-processing for Node.js environments (without DOM) + * @param {string} html - HTML to post-process + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) + * @returns {string} Post-processed HTML + */ + static postProcessHTMLManual(html, instanceHighlighter = null) { + let processed = html; + + // Process unordered lists + processed = processed.replace( + /((?:
    (?: )*
  • .*?<\/li><\/div>\s*)+)/gs, + (match) => { + const items = match.match(/
  • .*?<\/li>/gs) || []; + if (items.length > 0) { + return "
      " + items.join("") + "
    "; + } + return match; + } + ); + + // Process ordered lists + processed = processed.replace( + /((?:
    (?: )*
  • .*?<\/li><\/div>\s*)+)/gs, + (match) => { + const items = match.match(/
  • .*?<\/li>/gs) || []; + if (items.length > 0) { + return "
      " + items.join("") + "
    "; + } + return match; + } + ); + + // Process code blocks - KEEP the fence markers for alignment AND use semantic pre/code + const codeBlockRegex = + /
    (```[^<]*)<\/span><\/div>(.*?)
    (```)<\/span><\/div>/gs; + processed = processed.replace( + codeBlockRegex, + (match, openFence, content, closeFence) => { + // Extract the content between fences + const lines = content.match(/
    (.*?)<\/div>/gs) || []; + const codeContent = lines + .map((line) => { + // Extract text from each div - content is already escaped + const text = line + .replace(/
    (.*?)<\/div>/s, "$1") + .replace(/ /g, " "); + return text; + }) + .join("\n"); + + // Extract language from the opening fence + const lang = openFence.slice(3).trim(); + const langClass = lang ? ` class="language-${lang}"` : ""; + + // Apply code highlighting if available + let highlightedContent = codeContent; + const highlighter = instanceHighlighter || this.codeHighlighter; + if (highlighter) { + try { + highlightedContent = highlighter(codeContent, lang); + } catch (error) { + console.warn("Code highlighting failed:", error); + // Fall back to original content + } + } + + // Keep fence markers visible as separate divs, with pre/code block between them + let result = `
    ${openFence}
    `; + // Use highlighted content if available, otherwise use escaped content + result += `
    ${highlightedContent}
    `; + result += `
    ${closeFence}
    `; + + return result; + } + ); + + return processed; + } + + /** + * List pattern definitions + */ + static LIST_PATTERNS = { + bullet: /^(\s*)([-*+])\s+(.*)$/, + numbered: /^(\s*)(\d+)\.\s+(.*)$/, + checkbox: /^(\s*)-\s+\[([ x])\]\s+(.*)$/, + }; + + /** + * Get list context at cursor position + * @param {string} text - Full text content + * @param {number} cursorPosition - Current cursor position + * @returns {Object} List context information + */ + static getListContext(text, cursorPosition) { + // Find the line containing the cursor + const lines = text.split("\n"); + let currentPos = 0; + let lineIndex = 0; + let lineStart = 0; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length; + if (currentPos + lineLength >= cursorPosition) { + lineIndex = i; + lineStart = currentPos; + break; + } + currentPos += lineLength + 1; // +1 for newline + } + + const currentLine = lines[lineIndex]; + const lineEnd = lineStart + currentLine.length; + + // Check for checkbox first (most specific) + const checkboxMatch = currentLine.match(this.LIST_PATTERNS.checkbox); + if (checkboxMatch) { + return { + inList: true, + listType: "checkbox", + indent: checkboxMatch[1], + marker: "-", + checked: checkboxMatch[2] === "x", + content: checkboxMatch[3], + lineStart, + lineEnd, + markerEndPos: + lineStart + checkboxMatch[1].length + checkboxMatch[2].length + 5, // indent + "- [ ] " + }; + } + + // Check for bullet list + const bulletMatch = currentLine.match(this.LIST_PATTERNS.bullet); + if (bulletMatch) { + return { + inList: true, + listType: "bullet", + indent: bulletMatch[1], + marker: bulletMatch[2], + content: bulletMatch[3], + lineStart, + lineEnd, + markerEndPos: + lineStart + bulletMatch[1].length + bulletMatch[2].length + 1, // indent + marker + space + }; + } + + // Check for numbered list + const numberedMatch = currentLine.match(this.LIST_PATTERNS.numbered); + if (numberedMatch) { + return { + inList: true, + listType: "numbered", + indent: numberedMatch[1], + marker: parseInt(numberedMatch[2]), + content: numberedMatch[3], + lineStart, + lineEnd, + markerEndPos: + lineStart + numberedMatch[1].length + numberedMatch[2].length + 2, // indent + number + ". " + }; + } + + // Not in a list + return { + inList: false, + listType: null, + indent: "", + marker: null, + content: currentLine, + lineStart, + lineEnd, + markerEndPos: lineStart, + }; + } + + /** + * Create a new list item based on context + * @param {Object} context - List context from getListContext + * @returns {string} New list item text + */ + static createNewListItem(context) { + switch (context.listType) { + case "bullet": + return `${context.indent}${context.marker} `; + case "numbered": + return `${context.indent}${context.marker + 1}. `; + case "checkbox": + return `${context.indent}- [ ] `; + default: + return ""; + } + } + + /** + * Renumber all numbered lists in text + * @param {string} text - Text containing numbered lists + * @returns {string} Text with renumbered lists + */ + static renumberLists(text) { + const lines = text.split("\n"); + const numbersByIndent = new Map(); + let inList = false; + + const result = lines.map((line) => { + const match = line.match(this.LIST_PATTERNS.numbered); + + if (match) { + const indent = match[1]; + const indentLevel = indent.length; + const content = match[3]; + + // If we weren't in a list or indent changed, reset lower levels + if (!inList) { + numbersByIndent.clear(); + } + + // Get the next number for this indent level + const currentNumber = (numbersByIndent.get(indentLevel) || 0) + 1; + numbersByIndent.set(indentLevel, currentNumber); + + // Clear deeper indent levels + for (const [level] of numbersByIndent) { + if (level > indentLevel) { + numbersByIndent.delete(level); + } + } + + inList = true; + return `${indent}${currentNumber}. ${content}`; + } else { + // Not a numbered list item + if (line.trim() === "" || !line.match(/^\s/)) { + // Empty line or non-indented line breaks the list + inList = false; + numbersByIndent.clear(); + } + return line; + } + }); + + return result.join("\n"); + } +} diff --git a/browser-extension/src/overtype/shortcuts.js b/browser-extension/src/overtype/shortcuts.js new file mode 100644 index 0000000..ec453a8 --- /dev/null +++ b/browser-extension/src/overtype/shortcuts.js @@ -0,0 +1,125 @@ +/** + * Keyboard shortcuts handler for OverType editor + * Uses the same handleAction method as toolbar for consistency + */ + +import * as markdownActions from "markdown-actions"; + +/** + * ShortcutsManager - Handles keyboard shortcuts for the editor + */ +export class ShortcutsManager { + constructor(editor) { + this.editor = editor; + this.textarea = editor.textarea; + // No need to add our own listener - OverType will call handleKeydown + } + + /** + * Handle keydown events - called by OverType + * @param {KeyboardEvent} event - The keyboard event + * @returns {boolean} Whether the event was handled + */ + handleKeydown(event) { + const isMac = navigator.platform.toLowerCase().includes("mac"); + const modKey = isMac ? event.metaKey : event.ctrlKey; + + if (!modKey) return false; + + let action = null; + + // Map keyboard shortcuts to toolbar actions + switch (event.key.toLowerCase()) { + case "b": + if (!event.shiftKey) { + action = "toggleBold"; + } + break; + + case "i": + if (!event.shiftKey) { + action = "toggleItalic"; + } + break; + + case "k": + if (!event.shiftKey) { + action = "insertLink"; + } + break; + + case "7": + if (event.shiftKey) { + action = "toggleNumberedList"; + } + break; + + case "8": + if (event.shiftKey) { + action = "toggleBulletList"; + } + break; + } + + // If we have an action, handle it exactly like the toolbar does + if (action) { + event.preventDefault(); + + // If toolbar exists, use its handleAction method (exact same code path) + if (this.editor.toolbar) { + this.editor.toolbar.handleAction(action); + } else { + // Fallback: duplicate the toolbar's handleAction logic + this.handleAction(action); + } + + return true; + } + + return false; + } + + /** + * Handle action - fallback when no toolbar exists + * This duplicates toolbar.handleAction for consistency + */ + async handleAction(action) { + const textarea = this.textarea; + if (!textarea) return; + + // Focus textarea + textarea.focus(); + + try { + switch (action) { + case "toggleBold": + markdownActions.toggleBold(textarea); + break; + case "toggleItalic": + markdownActions.toggleItalic(textarea); + break; + case "insertLink": + markdownActions.insertLink(textarea); + break; + case "toggleBulletList": + markdownActions.toggleBulletList(textarea); + break; + case "toggleNumberedList": + markdownActions.toggleNumberedList(textarea); + break; + } + + // Trigger input event to update preview + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } catch (error) { + console.error("Error in markdown action:", error); + } + } + + /** + * Cleanup + */ + destroy() { + // Nothing to clean up since we don't add our own listener + } +} diff --git a/browser-extension/src/overtype/styles.js b/browser-extension/src/overtype/styles.js new file mode 100644 index 0000000..a3d0ebb --- /dev/null +++ b/browser-extension/src/overtype/styles.js @@ -0,0 +1,824 @@ +/** + * CSS styles for OverType editor + * Embedded in JavaScript to ensure single-file distribution + */ + +import { themeToCSSVars } from "./themes.js"; + +/** + * Generate the complete CSS for the editor + * @param {Object} options - Configuration options + * @returns {string} Complete CSS string + */ +export function generateStyles(options = {}) { + let { + fontSize = "14px", + lineHeight = 1.6, + /* System-first, guaranteed monospaced; avoids Android 'ui-monospace' pitfalls */ + fontFamily = '"SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Code", Consolas, "Roboto Mono", "Noto Sans Mono", "Droid Sans Mono", "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", Courier, monospace', + padding = "20px", + theme = null, + mobile = {}, + } = options; + + fontFamily = "inherit"; + fontSize = "var(--text-body-size-medium)"; + + // Generate mobile overrides + const mobileStyles = + Object.keys(mobile).length > 0 + ? ` + @media (max-width: 640px) { + .overtype-wrapper .overtype-input, + .overtype-wrapper .overtype-preview { + ${Object.entries(mobile) + .map(([prop, val]) => { + const cssProp = prop.replace(/([A-Z])/g, "-$1").toLowerCase(); + return `${cssProp}: ${val} !important;`; + }) + .join("\n ")} + } + } + ` + : ""; + + // Generate theme variables if provided + const themeVars = theme && theme.colors ? themeToCSSVars(theme.colors) : ""; + + return ` + /* OverType Editor Styles */ + /* GitHub styles */ + .overtype-preview pre.code-block { + font-family: ${fontFamily} !important; + font-size: ${fontSize} !important; + } + + /* Middle-ground CSS Reset - Prevent parent styles from leaking in */ + .overtype-container * { + /* Box model - these commonly leak */ + /* margin: 0 !important; */ + padding: 0 !important; + /* border: 0 !important; */ + + /* Layout - these can break our layout */ + /* Don't reset position - it breaks dropdowns */ + float: none !important; + clear: none !important; + + /* Typography - only reset decorative aspects */ + text-decoration: none !important; + text-transform: none !important; + letter-spacing: normal !important; + + /* Visual effects that can interfere */ + box-shadow: none !important; + text-shadow: none !important; + + /* Ensure box-sizing is consistent */ + box-sizing: border-box !important; + + /* Keep inheritance for these */ + /* font-family, color, line-height, font-size - inherit */ + } + + /* Container base styles after reset */ + .overtype-container { + display: grid !important; + grid-template-rows: auto 1fr auto !important; + width: 100% !important; + height: 100% !important; + position: relative !important; /* Override reset - needed for absolute children */ + overflow: visible !important; /* Allow dropdown to overflow container */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; + text-align: left !important; + ${ + themeVars + ? ` + /* Theme Variables */ + ${themeVars}` + : "" + } + } + + /* Force left alignment for all elements in the editor */ + .overtype-container .overtype-wrapper * { + text-align: left !important; + } + + /* Auto-resize mode styles */ + .overtype-container.overtype-auto-resize { + height: auto !important; + grid-template-rows: auto auto auto !important; + } + + .overtype-container.overtype-auto-resize .overtype-wrapper { + height: auto !important; + min-height: 60px !important; + overflow: visible !important; + } + + .overtype-wrapper { + position: relative !important; /* Override reset - needed for absolute children */ + /* width: 100% !important; */ + height: 100% !important; /* Take full height of grid cell */ + min-height: 60px !important; /* Minimum usable height */ + overflow: hidden !important; + background: var(--bg-secondary, #ffffff) !important; + grid-row: 2 !important; /* Always second row in grid */ + z-index: 1; /* Below toolbar and dropdown */ + } + + /* Critical alignment styles - must be identical for both layers */ + .overtype-wrapper .overtype-input, + .overtype-wrapper .overtype-preview { + /* Positioning - must be identical */ + position: absolute !important; /* Override reset - required for overlay */ + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + + /* Font properties - any difference breaks alignment */ + font-family: ${fontFamily} !important; + font-variant-ligatures: none !important; /* keep metrics stable for code */ + font-size: var(--instance-font-size, ${fontSize}) !important; + line-height: var(--instance-line-height, ${lineHeight}) !important; + font-weight: normal !important; + font-style: normal !important; + font-variant: normal !important; + font-stretch: normal !important; + font-kerning: none !important; + font-feature-settings: normal !important; + + /* Box model - must match exactly */ + padding: var(--instance-padding, ${padding}) !important; + margin: 0 !important; + border: none !important; + outline: none !important; + box-sizing: border-box !important; + + /* Text layout - critical for character positioning */ + white-space: pre-wrap !important; + word-wrap: break-word !important; + word-break: normal !important; + overflow-wrap: break-word !important; + tab-size: 2 !important; + -moz-tab-size: 2 !important; + text-align: left !important; + text-indent: 0 !important; + letter-spacing: normal !important; + word-spacing: normal !important; + + /* Text rendering */ + text-transform: none !important; + text-rendering: auto !important; + -webkit-font-smoothing: auto !important; + -webkit-text-size-adjust: 100% !important; + + /* Direction and writing */ + direction: ltr !important; + writing-mode: horizontal-tb !important; + unicode-bidi: normal !important; + text-orientation: mixed !important; + + /* Visual effects that could shift perception */ + text-shadow: none !important; + filter: none !important; + transform: none !important; + zoom: 1 !important; + + /* Vertical alignment */ + vertical-align: baseline !important; + + /* Size constraints */ + min-width: 0 !important; + min-height: 0 !important; + max-width: none !important; + max-height: none !important; + + /* Overflow */ + overflow-y: auto !important; + overflow-x: auto !important; + /* overscroll-behavior removed to allow scroll-through to parent */ + scrollbar-width: auto !important; + scrollbar-gutter: auto !important; + + /* Animation/transition - disabled to prevent movement */ + animation: none !important; + transition: none !important; + } + + /* Input layer styles */ + .overtype-wrapper .overtype-input { + /* Layer positioning */ + z-index: 1 !important; + + /* Text visibility */ + color: transparent !important; + caret-color: var(--cursor, #f95738) !important; + background-color: transparent !important; + + /* Textarea-specific */ + resize: none !important; + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + + /* Prevent mobile zoom on focus */ + touch-action: manipulation !important; + + /* Disable autofill and spellcheck */ + autocomplete: off !important; + autocorrect: off !important; + autocapitalize: off !important; + spellcheck: false !important; + } + + .overtype-wrapper .overtype-input::selection { + background-color: var(--selection, rgba(244, 211, 94, 0.4)); + } + + /* Preview layer styles */ + .overtype-wrapper .overtype-preview { + /* Layer positioning */ + z-index: 0 !important; + pointer-events: none !important; + color: var(--text, #0d3b66) !important; + background-color: transparent !important; + + /* Prevent text selection */ + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; + } + + /* Defensive styles for preview child divs */ + .overtype-wrapper .overtype-preview div { + /* Reset any inherited styles */ + margin: 0 !important; + padding: 0 !important; + border: none !important; + text-align: left !important; + text-indent: 0 !important; + display: block !important; + position: static !important; + transform: none !important; + min-height: 0 !important; + max-height: none !important; + line-height: inherit !important; + font-size: inherit !important; + font-family: inherit !important; + } + + /* Markdown element styling - NO SIZE CHANGES */ + .overtype-wrapper .overtype-preview .header { + font-weight: normal !important; + } + + /* Header colors */ + .overtype-wrapper .overtype-preview .h1 { + color: var(--h1, #f95738) !important; + } + .overtype-wrapper .overtype-preview .h2 { + color: var(--h2, #ee964b) !important; + } + .overtype-wrapper .overtype-preview .h3 { + color: var(--h3, #3d8a51) !important; + } + + /* Semantic headers - flatten in edit mode */ + .overtype-wrapper .overtype-preview h1, + .overtype-wrapper .overtype-preview h2, + .overtype-wrapper .overtype-preview h3 { + font-size: inherit !important; + font-weight: normal !important; + margin: 0 !important; + padding: 0 !important; + display: inline !important; + line-height: inherit !important; + } + + /* Header colors for semantic headers */ + .overtype-wrapper .overtype-preview h1 { + color: var(--h1, #f95738) !important; + } + .overtype-wrapper .overtype-preview h2 { + color: var(--h2, #ee964b) !important; + } + .overtype-wrapper .overtype-preview h3 { + color: var(--h3, #3d8a51) !important; + } + + /* Lists - remove styling in edit mode */ + .overtype-wrapper .overtype-preview ul, + .overtype-wrapper .overtype-preview ol { + list-style: none !important; + margin: 0 !important; + padding: 0 !important; + display: block !important; /* Lists need to be block for line breaks */ + } + + .overtype-wrapper .overtype-preview li { + display: block !important; /* Each item on its own line */ + margin: 0 !important; + padding: 0 !important; + /* Don't set list-style here - let ul/ol control it */ + } + + /* Bold text */ + .overtype-wrapper .overtype-preview strong { + color: var(--strong, #ee964b) !important; + font-weight: normal !important; + } + + /* Italic text */ + .overtype-wrapper .overtype-preview em { + color: var(--em, #f95738) !important; + text-decoration-color: var(--em, #f95738) !important; + text-decoration-thickness: 1px !important; + font-style: italic !important; + } + + /* Inline code */ + .overtype-wrapper .overtype-preview code { + background: var(--code-bg, rgba(244, 211, 94, 0.4)) !important; + color: var(--code, #0d3b66) !important; + padding: 0 !important; + border-radius: 2px !important; + font-family: inherit !important; + font-size: inherit !important; + line-height: inherit !important; + font-weight: normal !important; + } + + /* Code blocks - consolidated pre blocks */ + .overtype-wrapper .overtype-preview pre { + padding: 0 !important; + margin: 0 !important; + border-radius: 4px !important; + overflow-x: auto !important; + } + + /* Code block styling in normal mode - yellow background */ + .overtype-wrapper .overtype-preview pre.code-block { + background: var(--code-bg, rgba(244, 211, 94, 0.4)) !important; + } + + /* Code inside pre blocks - remove background */ + .overtype-wrapper .overtype-preview pre code { + background: transparent !important; + color: var(--code, #0d3b66) !important; + } + + /* Blockquotes */ + .overtype-wrapper .overtype-preview .blockquote { + color: var(--blockquote, #5a7a9b) !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + } + + /* Links */ + .overtype-wrapper .overtype-preview a { + color: var(--link, #0d3b66) !important; + text-decoration: underline !important; + font-weight: normal !important; + } + + .overtype-wrapper .overtype-preview a:hover { + text-decoration: underline !important; + color: var(--link, #0d3b66) !important; + } + + /* Lists - no list styling */ + .overtype-wrapper .overtype-preview ul, + .overtype-wrapper .overtype-preview ol { + list-style: none !important; + margin: 0 !important; + padding: 0 !important; + } + + + /* Horizontal rules */ + .overtype-wrapper .overtype-preview hr { + border: none !important; + color: var(--hr, #5a7a9b) !important; + margin: 0 !important; + padding: 0 !important; + } + + .overtype-wrapper .overtype-preview .hr-marker { + color: var(--hr, #5a7a9b) !important; + opacity: 0.6 !important; + } + + /* Code fence markers - with background when not in code block */ + .overtype-wrapper .overtype-preview .code-fence { + color: var(--code, #0d3b66) !important; + background: var(--code-bg, rgba(244, 211, 94, 0.4)) !important; + } + + /* Code block lines - background for entire code block */ + .overtype-wrapper .overtype-preview .code-block-line { + background: var(--code-bg, rgba(244, 211, 94, 0.4)) !important; + } + + /* Remove background from code fence when inside code block line */ + .overtype-wrapper .overtype-preview .code-block-line .code-fence { + background: transparent !important; + } + + /* Raw markdown line */ + .overtype-wrapper .overtype-preview .raw-line { + color: var(--raw-line, #5a7a9b) !important; + font-style: normal !important; + font-weight: normal !important; + } + + /* Syntax markers */ + .overtype-wrapper .overtype-preview .syntax-marker { + color: var(--syntax-marker, rgba(13, 59, 102, 0.52)) !important; + opacity: 0.7 !important; + } + + /* List markers */ + .overtype-wrapper .overtype-preview .list-marker { + color: var(--list-marker, #ee964b) !important; + } + + /* Stats bar */ + + /* Stats bar - positioned by grid, not absolute */ + .overtype-stats { + height: 40px !important; + padding: 0 20px !important; + background: #f8f9fa !important; + border-top: 1px solid #e0e0e0 !important; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + font-size: 0.85rem !important; + color: #666 !important; + grid-row: 3 !important; /* Always third row in grid */ + } + + /* Dark theme stats bar */ + .overtype-container[data-theme="cave"] .overtype-stats { + background: var(--bg-secondary, #1D2D3E) !important; + border-top: 1px solid rgba(197, 221, 232, 0.1) !important; + color: var(--text, #c5dde8) !important; + } + + .overtype-stats .overtype-stat { + display: flex !important; + align-items: center !important; + gap: 5px !important; + white-space: nowrap !important; + } + + .overtype-stats .live-dot { + width: 8px !important; + height: 8px !important; + background: #4caf50 !important; + border-radius: 50% !important; + animation: pulse 2s infinite !important; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.2); } + } + + + /* Toolbar Styles */ + .overtype-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 8px !important; /* Override reset */ + background: var(--toolbar-bg, var(--bg-primary, #f8f9fa)) !important; /* Override reset */ + overflow-x: auto !important; /* Allow horizontal scrolling */ + overflow-y: hidden !important; /* Hide vertical overflow */ + -webkit-overflow-scrolling: touch; + flex-shrink: 0; + height: auto !important; + grid-row: 1 !important; /* Always first row in grid */ + position: relative !important; /* Override reset */ + z-index: 100; /* Ensure toolbar is above wrapper */ + scrollbar-width: thin; /* Thin scrollbar on Firefox */ + } + + /* Thin scrollbar styling */ + .overtype-toolbar::-webkit-scrollbar { + height: 4px; + } + + .overtype-toolbar::-webkit-scrollbar-track { + background: transparent; + } + + .overtype-toolbar::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 2px; + } + + .overtype-toolbar-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--toolbar-icon, var(--text-secondary, #666)); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + } + + .overtype-toolbar-button svg { + width: 20px; + height: 20px; + fill: currentColor; + } + + .overtype-toolbar-button:hover { + background: var(--toolbar-hover, var(--bg-secondary, #e9ecef)); + color: var(--toolbar-icon, var(--text-primary, #333)); + } + + .overtype-toolbar-button:active { + transform: scale(0.95); + } + + .overtype-toolbar-button.active { + background: var(--toolbar-active, var(--primary, #007bff)); + color: var(--toolbar-icon, var(--text-primary, #333)); + } + + .overtype-toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .overtype-toolbar-separator { + width: 1px; + height: 24px; + background: var(--border, #e0e0e0); + margin: 0 4px; + flex-shrink: 0; + } + + /* Adjust wrapper when toolbar is present */ + .overtype-container .overtype-toolbar + .overtype-wrapper { + } + + /* Mobile toolbar adjustments */ + @media (max-width: 640px) { + .overtype-toolbar { + padding: 6px; + gap: 2px; + } + + .overtype-toolbar-button { + width: 36px; + height: 36px; + } + + .overtype-toolbar-separator { + margin: 0 2px; + } + } + + /* Plain mode - hide preview and show textarea text */ + .overtype-container.plain-mode .overtype-preview { + display: none !important; + } + + .overtype-container.plain-mode .overtype-input { + color: var(--text, #0d3b66) !important; + /* Use system font stack for better plain text readability */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif !important; + } + + /* Ensure textarea remains transparent in overlay mode */ + .overtype-container:not(.plain-mode) .overtype-input { + color: transparent !important; + } + + /* Dropdown menu styles */ + .overtype-toolbar-button { + position: relative !important; /* Override reset - needed for dropdown */ + } + + .overtype-toolbar-button.dropdown-active { + background: var(--toolbar-active, var(--hover-bg, #f0f0f0)); + } + + .overtype-dropdown-menu { + position: fixed !important; /* Fixed positioning relative to viewport */ + background: var(--bg-secondary, white) !important; /* Override reset */ + border: 1px solid var(--border, #e0e0e0) !important; /* Override reset */ + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; /* Override reset */ + z-index: 10000; /* Very high z-index to ensure visibility */ + min-width: 150px; + padding: 4px 0 !important; /* Override reset */ + /* Position will be set via JavaScript based on button position */ + } + + .overtype-dropdown-item { + display: flex; + align-items: center; + width: 100%; + padding: 8px 12px; + border: none; + background: none; + text-align: left; + cursor: pointer; + font-size: 14px; + color: var(--text, #333); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + .overtype-dropdown-item:hover { + background: var(--hover-bg, #f0f0f0); + } + + .overtype-dropdown-item.active { + font-weight: 600; + } + + .overtype-dropdown-check { + width: 16px; + margin-right: 8px; + color: var(--h1, #007bff); + } + + /* Preview mode styles */ + .overtype-container.preview-mode .overtype-input { + display: none !important; + } + + .overtype-container.preview-mode .overtype-preview { + pointer-events: auto !important; + user-select: text !important; + cursor: text !important; + } + + /* Hide syntax markers in preview mode */ + .overtype-container.preview-mode .syntax-marker { + display: none !important; + } + + /* Hide URL part of links in preview mode - extra specificity */ + .overtype-container.preview-mode .syntax-marker.url-part, + .overtype-container.preview-mode .url-part { + display: none !important; + } + + /* Hide all syntax markers inside links too */ + .overtype-container.preview-mode a .syntax-marker { + display: none !important; + } + + /* Headers - restore proper sizing in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h1, + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h2, + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h3 { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; + font-weight: 600 !important; + margin: 0 !important; + display: block !important; + color: inherit !important; /* Use parent text color */ + line-height: 1 !important; /* Tight line height for headings */ + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h1 { + font-size: 2em !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h2 { + font-size: 1.5em !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview h3 { + font-size: 1.17em !important; + } + + /* Lists - restore list styling in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview ul { + display: block !important; + list-style: disc !important; + padding-left: 2em !important; + margin: 1em 0 !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview ol { + display: block !important; + list-style: decimal !important; + padding-left: 2em !important; + margin: 1em 0 !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview li { + display: list-item !important; + margin: 0 !important; + padding: 0 !important; + } + + /* Links - make clickable in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview a { + pointer-events: auto !important; + cursor: pointer !important; + color: var(--link, #0066cc) !important; + text-decoration: underline !important; + } + + /* Code blocks - proper pre/code styling in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview pre.code-block { + background: #2d2d2d !important; + color: #f8f8f2 !important; + padding: 1.2em !important; + border-radius: 3px !important; + overflow-x: auto !important; + margin: 0 !important; + display: block !important; + } + + /* Cave theme code block background in preview mode */ + .overtype-container[data-theme="cave"].preview-mode .overtype-wrapper .overtype-preview pre.code-block { + background: #11171F !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview pre.code-block code { + background: transparent !important; + color: inherit !important; + padding: 0 !important; + font-family: ${fontFamily} !important; + font-size: 0.9em !important; + line-height: 1.4 !important; + } + + /* Hide old code block lines and fences in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview .code-block-line { + display: none !important; + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview .code-fence { + display: none !important; + } + + /* Blockquotes - enhanced styling in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview .blockquote { + display: block !important; + border-left: 4px solid var(--blockquote, #ddd) !important; + padding-left: 1em !important; + margin: 1em 0 !important; + font-style: italic !important; + } + + /* Typography improvements in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview { + font-family: Georgia, 'Times New Roman', serif !important; + font-size: 16px !important; + line-height: 1.8 !important; + color: var(--text, #333) !important; /* Consistent text color */ + } + + /* Inline code in preview mode - keep monospace */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview code { + font-family: ${fontFamily} !important; + font-size: 0.9em !important; + background: rgba(135, 131, 120, 0.15) !important; + padding: 0.2em 0.4em !important; + border-radius: 3px !important; + } + + /* Strong and em elements in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview strong { + font-weight: 700 !important; + color: inherit !important; /* Use parent text color */ + } + + .overtype-container.preview-mode .overtype-wrapper .overtype-preview em { + font-style: italic !important; + color: inherit !important; /* Use parent text color */ + } + + /* HR in preview mode */ + .overtype-container.preview-mode .overtype-wrapper .overtype-preview .hr-marker { + display: block !important; + border-top: 2px solid var(--hr, #ddd) !important; + text-indent: -9999px !important; + height: 2px !important; + } + + ${mobileStyles} + `; +} diff --git a/browser-extension/src/overtype/themes.js b/browser-extension/src/overtype/themes.js new file mode 100644 index 0000000..fa4257b --- /dev/null +++ b/browser-extension/src/overtype/themes.js @@ -0,0 +1,124 @@ +/** + * Built-in themes for OverType editor + * Each theme provides a complete color palette for the editor + */ + +/** + * Solar theme - Light, warm and bright + */ +export const solar = { + name: "solar", + colors: { + bgPrimary: "#faf0ca", // Lemon Chiffon - main background + bgSecondary: "#ffffff", // White - editor background + text: "#0d3b66", // Yale Blue - main text + h1: "#f95738", // Tomato - h1 headers + h2: "#ee964b", // Sandy Brown - h2 headers + h3: "#3d8a51", // Forest green - h3 headers + strong: "#ee964b", // Sandy Brown - bold text + em: "#f95738", // Tomato - italic text + link: "#0d3b66", // Yale Blue - links + code: "#0d3b66", // Yale Blue - inline code + codeBg: "rgba(244, 211, 94, 0.4)", // Naples Yellow with transparency + blockquote: "#5a7a9b", // Muted blue - blockquotes + hr: "#5a7a9b", // Muted blue - horizontal rules + syntaxMarker: "rgba(13, 59, 102, 0.52)", // Yale Blue with transparency + cursor: "#f95738", // Tomato - cursor + selection: "rgba(244, 211, 94, 0.4)", // Naples Yellow with transparency + listMarker: "#ee964b", // Sandy Brown - list markers + // Toolbar colors + toolbarBg: "#ffffff", // White - toolbar background + toolbarBorder: "rgba(13, 59, 102, 0.15)", // Yale Blue border + toolbarIcon: "#0d3b66", // Yale Blue - icon color + toolbarHover: "#f5f5f5", // Light gray - hover background + toolbarActive: "#faf0ca", // Lemon Chiffon - active button background + }, +}; + +/** + * Cave theme - Dark ocean depths + */ +export const cave = { + name: "cave", + colors: { + bgPrimary: "#141E26", // Deep ocean - main background + bgSecondary: "#1D2D3E", // Darker charcoal - editor background + text: "#c5dde8", // Light blue-gray - main text + h1: "#d4a5ff", // Rich lavender - h1 headers + h2: "#f6ae2d", // Hunyadi Yellow - h2 headers + h3: "#9fcfec", // Brighter blue - h3 headers + strong: "#f6ae2d", // Hunyadi Yellow - bold text + em: "#9fcfec", // Brighter blue - italic text + link: "#9fcfec", // Brighter blue - links + code: "#c5dde8", // Light blue-gray - inline code + codeBg: "#1a232b", // Very dark blue - code background + blockquote: "#9fcfec", // Brighter blue - same as italic + hr: "#c5dde8", // Light blue-gray - horizontal rules + syntaxMarker: "rgba(159, 207, 236, 0.73)", // Brighter blue semi-transparent + cursor: "#f26419", // Orange Pantone - cursor + selection: "rgba(51, 101, 138, 0.4)", // Lapis Lazuli with transparency + listMarker: "#f6ae2d", // Hunyadi Yellow - list markers + // Toolbar colors for dark theme + toolbarBg: "#1D2D3E", // Darker charcoal - toolbar background + toolbarBorder: "rgba(197, 221, 232, 0.1)", // Light blue-gray border + toolbarIcon: "#c5dde8", // Light blue-gray - icon color + toolbarHover: "#243546", // Slightly lighter charcoal - hover background + toolbarActive: "#2a3f52", // Even lighter - active button background + }, +}; + +/** + * Default themes registry + */ +export const themes = { + solar, + cave, + // Aliases for backward compatibility + light: solar, + dark: cave, +}; + +/** + * Get theme by name or return custom theme object + * @param {string|Object} theme - Theme name or custom theme object + * @returns {Object} Theme configuration + */ +export function getTheme(theme) { + if (typeof theme === "string") { + const themeObj = themes[theme] || themes.solar; + // Preserve the requested theme name (important for 'light' and 'dark' aliases) + return { ...themeObj, name: theme }; + } + return theme; +} + +/** + * Apply theme colors to CSS variables + * @param {Object} colors - Theme colors object + * @returns {string} CSS custom properties string + */ +export function themeToCSSVars(colors) { + const vars = []; + for (const [key, value] of Object.entries(colors)) { + // Convert camelCase to kebab-case + const varName = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + vars.push(`--${varName}: ${value};`); + } + return vars.join("\n"); +} + +/** + * Merge custom colors with base theme + * @param {Object} baseTheme - Base theme object + * @param {Object} customColors - Custom color overrides + * @returns {Object} Merged theme object + */ +export function mergeTheme(baseTheme, customColors = {}) { + return { + ...baseTheme, + colors: { + ...baseTheme.colors, + ...customColors, + }, + }; +} diff --git a/browser-extension/src/overtype/toolbar.js b/browser-extension/src/overtype/toolbar.js new file mode 100644 index 0000000..4988461 --- /dev/null +++ b/browser-extension/src/overtype/toolbar.js @@ -0,0 +1,420 @@ +/** + * Toolbar component for OverType editor + * Provides markdown formatting buttons with icons + */ + +import * as icons from "./icons.js"; +import * as markdownActions from "markdown-actions"; + +export class Toolbar { + constructor(editor) { + this.editor = editor; + this.container = null; + this.buttons = {}; + } + + /** + * Create and attach toolbar to editor + */ + create() { + // Create toolbar container + this.container = document.createElement("div"); + this.container.className = "overtype-toolbar"; + this.container.setAttribute("role", "toolbar"); + this.container.setAttribute("aria-label", "Text formatting"); + + // Define toolbar buttons + const buttonConfig = [ + { + name: "bold", + icon: icons.boldIcon, + title: "Bold (Ctrl+B)", + action: "toggleBold", + }, + { + name: "italic", + icon: icons.italicIcon, + title: "Italic (Ctrl+I)", + action: "toggleItalic", + }, + { separator: true }, + { + name: "h1", + icon: icons.h1Icon, + title: "Heading 1", + action: "insertH1", + }, + { + name: "h2", + icon: icons.h2Icon, + title: "Heading 2", + action: "insertH2", + }, + { + name: "h3", + icon: icons.h3Icon, + title: "Heading 3", + action: "insertH3", + }, + { separator: true }, + { + name: "link", + icon: icons.linkIcon, + title: "Insert Link (Ctrl+K)", + action: "insertLink", + }, + { + name: "code", + icon: icons.codeIcon, + title: "Code (Ctrl+`)", + action: "toggleCode", + }, + { separator: true }, + { + name: "quote", + icon: icons.quoteIcon, + title: "Quote", + action: "toggleQuote", + }, + { separator: true }, + { + name: "bulletList", + icon: icons.bulletListIcon, + title: "Bullet List", + action: "toggleBulletList", + }, + { + name: "orderedList", + icon: icons.orderedListIcon, + title: "Numbered List", + action: "toggleNumberedList", + }, + { + name: "taskList", + icon: icons.taskListIcon, + title: "Task List", + action: "toggleTaskList", + }, + { separator: true }, + { + name: "viewMode", + icon: icons.eyeIcon, + title: "View mode", + action: "toggle-view-menu", + hasDropdown: true, + }, + ]; + + // Create buttons + buttonConfig.forEach((config) => { + if (config.separator) { + const separator = document.createElement("div"); + separator.className = "overtype-toolbar-separator"; + separator.setAttribute("role", "separator"); + this.container.appendChild(separator); + } else { + const button = this.createButton(config); + this.buttons[config.name] = button; + this.container.appendChild(button); + } + }); + + // Insert toolbar into container before editor wrapper + const container = this.editor.element.querySelector(".overtype-container"); + const wrapper = this.editor.element.querySelector(".overtype-wrapper"); + if (container && wrapper) { + container.insertBefore(this.container, wrapper); + } + + return this.container; + } + + /** + * Create individual toolbar button + */ + createButton(config) { + const button = document.createElement("button"); + button.className = "overtype-toolbar-button"; + button.type = "button"; + button.title = config.title; + button.setAttribute("aria-label", config.title); + button.setAttribute("data-action", config.action); + button.innerHTML = config.icon; + + // Add dropdown if needed + if (config.hasDropdown) { + button.classList.add("has-dropdown"); + // Store reference for dropdown + if (config.name === "viewMode") { + this.viewModeButton = button; + } + } + + // Add click handler + button.addEventListener("click", (e) => { + e.preventDefault(); + this.handleAction(config.action, button); + }); + + return button; + } + + /** + * Handle toolbar button actions + */ + async handleAction(action, button) { + const textarea = this.editor.textarea; + if (!textarea) return; + + // Handle dropdown toggle + if (action === "toggle-view-menu") { + this.toggleViewDropdown(button); + return; + } + + // Focus textarea for other actions + textarea.focus(); + + try { + switch (action) { + case "toggleBold": + markdownActions.toggleBold(textarea); + break; + case "toggleItalic": + markdownActions.toggleItalic(textarea); + break; + case "insertH1": + markdownActions.toggleH1(textarea); + break; + case "insertH2": + markdownActions.toggleH2(textarea); + break; + case "insertH3": + markdownActions.toggleH3(textarea); + break; + case "insertLink": + markdownActions.insertLink(textarea); + break; + case "toggleCode": + markdownActions.toggleCode(textarea); + break; + case "toggleBulletList": + markdownActions.toggleBulletList(textarea); + break; + case "toggleNumberedList": + markdownActions.toggleNumberedList(textarea); + break; + case "toggleQuote": + markdownActions.toggleQuote(textarea); + break; + case "toggleTaskList": + markdownActions.toggleTaskList(textarea); + break; + case "toggle-plain": + // Toggle between plain textarea and overlay mode + const isPlain = + this.editor.container.classList.contains("plain-mode"); + this.editor.showPlainTextarea(!isPlain); + break; + } + + // Trigger input event to update preview + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } catch (error) { + console.error("Error loading markdown-actions:", error); + } + } + + /** + * Update toolbar button states based on current selection + */ + async updateButtonStates() { + const textarea = this.editor.textarea; + if (!textarea) return; + + try { + const activeFormats = markdownActions.getActiveFormats(textarea); + + // Update button states + Object.entries(this.buttons).forEach(([name, button]) => { + let isActive = false; + + switch (name) { + case "bold": + isActive = activeFormats.includes("bold"); + break; + case "italic": + isActive = activeFormats.includes("italic"); + break; + case "code": + // Disabled: code detection is unreliable in code blocks + // isActive = activeFormats.includes('code'); + isActive = false; + break; + case "bulletList": + isActive = activeFormats.includes("bullet-list"); + break; + case "orderedList": + isActive = activeFormats.includes("numbered-list"); + break; + case "quote": + isActive = activeFormats.includes("quote"); + break; + case "taskList": + isActive = activeFormats.includes("task-list"); + break; + case "h1": + isActive = activeFormats.includes("header"); + break; + case "h2": + isActive = activeFormats.includes("header-2"); + break; + case "h3": + isActive = activeFormats.includes("header-3"); + break; + case "togglePlain": + // Button is active when in overlay mode (not plain mode) + isActive = !this.editor.container.classList.contains("plain-mode"); + break; + } + + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", isActive.toString()); + }); + } catch (error) { + // Silently fail if markdown-actions not available + } + } + + /** + * Toggle view mode dropdown menu + */ + toggleViewDropdown(button) { + // Close any existing dropdown + const existingDropdown = document.querySelector(".overtype-dropdown-menu"); + if (existingDropdown) { + existingDropdown.remove(); + button.classList.remove("dropdown-active"); + document.removeEventListener("click", this.handleDocumentClick); + return; + } + + // Create dropdown menu + const dropdown = this.createViewDropdown(); + + // Position dropdown relative to button + const rect = button.getBoundingClientRect(); + dropdown.style.top = `${rect.bottom + 4}px`; + dropdown.style.left = `${rect.left}px`; + + // Append to body instead of button + document.body.appendChild(dropdown); + button.classList.add("dropdown-active"); + + // Store reference for document click handler + this.handleDocumentClick = (e) => { + if (!button.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.remove(); + button.classList.remove("dropdown-active"); + document.removeEventListener("click", this.handleDocumentClick); + } + }; + + // Close on click outside + setTimeout(() => { + document.addEventListener("click", this.handleDocumentClick); + }, 0); + } + + /** + * Create view mode dropdown menu + */ + createViewDropdown() { + const dropdown = document.createElement("div"); + dropdown.className = "overtype-dropdown-menu"; + + // Determine current mode + const isPlain = this.editor.container.classList.contains("plain-mode"); + const isPreview = this.editor.container.classList.contains("preview-mode"); + const currentMode = isPreview ? "preview" : isPlain ? "plain" : "normal"; + + // Create menu items + const modes = [ + { id: "normal", label: "Normal Edit", icon: "✓" }, + { id: "plain", label: "Plain Textarea", icon: "✓" }, + { id: "preview", label: "Preview Mode", icon: "✓" }, + ]; + + modes.forEach((mode) => { + const item = document.createElement("button"); + item.className = "overtype-dropdown-item"; + item.type = "button"; + + const check = document.createElement("span"); + check.className = "overtype-dropdown-check"; + check.textContent = currentMode === mode.id ? mode.icon : ""; + + const label = document.createElement("span"); + label.textContent = mode.label; + + item.appendChild(check); + item.appendChild(label); + + if (currentMode === mode.id) { + item.classList.add("active"); + } + + item.addEventListener("click", (e) => { + e.stopPropagation(); + this.setViewMode(mode.id); + dropdown.remove(); + this.viewModeButton.classList.remove("dropdown-active"); + document.removeEventListener("click", this.handleDocumentClick); + }); + + dropdown.appendChild(item); + }); + + return dropdown; + } + + /** + * Set view mode + */ + setViewMode(mode) { + // Clear all mode classes + this.editor.container.classList.remove("plain-mode", "preview-mode"); + + switch (mode) { + case "plain": + this.editor.showPlainTextarea(true); + break; + case "preview": + this.editor.showPreviewMode(true); + break; + case "normal": + default: + // Normal edit mode + this.editor.showPlainTextarea(false); + if (typeof this.editor.showPreviewMode === "function") { + this.editor.showPreviewMode(false); + } + break; + } + } + + /** + * Destroy toolbar + */ + destroy() { + if (this.container) { + // Clean up event listeners + if (this.handleDocumentClick) { + document.removeEventListener("click", this.handleDocumentClick); + } + this.container.remove(); + this.container = null; + this.buttons = {}; + } + } +} diff --git a/browser-extension/src/playgrounds/github-playground.ts b/browser-extension/src/playgrounds/github-playground.ts new file mode 100644 index 0000000..dae988f --- /dev/null +++ b/browser-extension/src/playgrounds/github-playground.ts @@ -0,0 +1,49 @@ +import hljs from "highlight.js"; +import OverType from "../overtype/overtype"; + +export function githubPrNewCommentContentScript() { + if (window.location.hostname !== "github.com") { + return; + } + OverType.setCodeHighlighter(hljsHighlighter); + const ghCommentBox = document.getElementById( + "new_comment_field" + ) as HTMLTextAreaElement | null; + if (ghCommentBox) { + const overtypeContainer = modifyDOM(ghCommentBox); + new OverType(overtypeContainer, { + placeholder: "Add your comment here...", + autoResize: true, + minHeight: "102px", + padding: "var(--base-size-8)", + }); + } +} + +function modifyDOM(overtypeInput: HTMLTextAreaElement): HTMLElement { + overtypeInput.classList.add("overtype-input"); + const overtypePreview = document.createElement("div"); + overtypePreview.classList.add("overtype-preview"); + overtypeInput.insertAdjacentElement("afterend", overtypePreview); + const overtypeWrapper = overtypeInput.parentElement!.closest("div")!; + overtypeWrapper.classList.add("overtype-wrapper"); + overtypeInput.placeholder = "Add your comment here..."; + const overtypeContainer = overtypeWrapper.parentElement!.closest("div")!; + overtypeContainer.classList.add("overtype-container"); + return overtypeContainer.parentElement!.closest("div")!; +} + +function hljsHighlighter(code: string, language: string) { + try { + if (language && hljs.getLanguage(language)) { + const result = hljs.highlight(code, { language }); + return result.value; + } else { + const result = hljs.highlightAuto(code); + return result.value; + } + } catch (error) { + console.warn("highlight.js highlighting failed:", error); + return code; + } +}