From fcbdd1c4b15c3b1e89e26e4ba47ca7324448657f Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 29 Nov 2025 11:43:18 +0100 Subject: [PATCH 01/19] Make rich text images selectable and resizable --- components/rich-text/ImageNode.tsx | 244 ++++++++++++++++++++++++++--- 1 file changed, 226 insertions(+), 18 deletions(-) diff --git a/components/rich-text/ImageNode.tsx b/components/rich-text/ImageNode.tsx index 13d4079..334c4df 100644 --- a/components/rich-text/ImageNode.tsx +++ b/components/rich-text/ImageNode.tsx @@ -1,15 +1,28 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalEditable } from '@lexical/react/useLexicalEditable'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, DecoratorNode, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + SELECTION_CHANGE_COMMAND, createCommand, type DOMConversionMap, type DOMConversionOutput, type LexicalCommand, + type LexicalEditor, type LexicalNode, type NodeKey, type Spread, type SerializedLexicalNode, } from 'lexical'; +import { mergeRegister } from '@lexical/utils'; export type ImagePayload = { src: string; @@ -32,29 +45,217 @@ export type SerializedImageNode = Spread< export const INSERT_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_IMAGE_COMMAND'); -class ImageComponent extends React.Component { - render(): React.ReactNode { - const { src, altText, width, height } = this.props; - const resolvedWidth = typeof width === 'number' ? `${width}px` : width ?? 'auto'; - const resolvedHeight = typeof height === 'number' ? `${height}px` : height ?? 'auto'; +const MIN_DIMENSION = 64; - return ( +type ImageComponentProps = ImagePayload & { + nodeKey: NodeKey; +}; + +type PointerState = { + startX: number; + startY: number; + startWidth: number; + startHeight: number; +}; + +const ImageComponent: React.FC = ({ src, altText, width, height, nodeKey }) => { + const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); + const [isResizing, setIsResizing] = useState(false); + const imageRef = useRef(null); + const pointerStateRef = useRef(null); + const [currentWidth, setCurrentWidth] = useState(width ?? 'inherit'); + const [currentHeight, setCurrentHeight] = useState(height ?? 'inherit'); + + useEffect(() => { + setCurrentWidth(width ?? 'inherit'); + }, [width]); + + useEffect(() => { + setCurrentHeight(height ?? 'inherit'); + }, [height]); + + const updateDimensions = useCallback( + (nextWidth: number | 'inherit', nextHeight: number | 'inherit') => { + setCurrentWidth(nextWidth); + setCurrentHeight(nextHeight); + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.setWidthAndHeight(nextWidth, nextHeight); + } + }); + }, + [editor, nodeKey], + ); + + const onDelete = useCallback( + (event: KeyboardEvent) => { + if (isSelected && $isNodeSelection($getSelection())) { + event.preventDefault(); + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.remove(); + } + }); + return true; + } + return false; + }, + [editor, isSelected, nodeKey], + ); + + const onClick = useCallback( + (event: MouseEvent) => { + if (!imageRef.current) { + return false; + } + + if (event.target === imageRef.current) { + if (event.shiftKey) { + setSelected(!isSelected); + return true; + } + + clearSelection(); + setSelected(true); + return true; + } + + return false; + }, + [clearSelection, isSelected, setSelected], + ); + + useEffect( + () => + mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, _newEditor: LexicalEditor) => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + const isNodeSelected = selection.has(nodeKey); + setSelected(isNodeSelected); + return false; + } + if (isSelected) { + setSelected(false); + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), + ), + [editor, isSelected, nodeKey, onClick, onDelete, setSelected], + ); + + const resolvedWidth = useMemo(() => (typeof currentWidth === 'number' ? `${currentWidth}px` : currentWidth ?? 'auto'), [ + currentWidth, + ]); + const resolvedHeight = useMemo( + () => (typeof currentHeight === 'number' ? `${currentHeight}px` : currentHeight ?? 'auto'), + [currentHeight], + ); + + const handlePointerMove = useCallback((event: PointerEvent) => { + const state = pointerStateRef.current; + if (!state) { + return; + } + + const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX)); + const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY)); + + setCurrentWidth(nextWidth); + setCurrentHeight(nextHeight); + }, []); + + const handlePointerUp = useCallback((event: PointerEvent) => { + const state = pointerStateRef.current; + if (state) { + const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX)); + const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY)); + updateDimensions(nextWidth, nextHeight); + } + + pointerStateRef.current = null; + setIsResizing(false); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }, [handlePointerMove, updateDimensions]); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (!isEditable || !imageRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const rect = imageRef.current.getBoundingClientRect(); + pointerStateRef.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: rect.width, + startHeight: rect.height, + }; + + setIsResizing(true); + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, handlePointerUp, isEditable], + ); + + useEffect(() => { + return () => { + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }; + }, [handlePointerMove, handlePointerUp]); + + const onDragStart = useCallback( + (event: React.DragEvent) => { + if (!isEditable || !event.dataTransfer) { + return; + } + event.stopPropagation(); + event.dataTransfer.setData('text/plain', '_lexical_image'); + }, + [isEditable], + ); + + const showHandles = isEditable && isSelected; + + return ( + {altText} - ); - } -} + {showHandles ? ( +
+
+
+ ) : null} + {isResizing ?
: null} + + ); +}; export class ImageNode extends DecoratorNode { __src: string; @@ -96,6 +297,7 @@ export class ImageNode extends DecoratorNode { altText={this.__altText} width={this.__width} height={this.__height} + nodeKey={this.__key} /> ); } @@ -134,6 +336,12 @@ export class ImageNode extends DecoratorNode { return { element }; } + setWidthAndHeight(width?: number | 'inherit', height?: number | 'inherit') { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + static importDOM(): DOMConversionMap | null { return { img: (domNode: Node) => { From b565a1bbccf0403c5ab52a52b6f5ee6d8ebf1288 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 29 Nov 2025 11:55:19 +0100 Subject: [PATCH 02/19] Polish image drag and resize UX --- components/rich-text/ImageNode.tsx | 185 +++++++++++++++++++++++------ 1 file changed, 146 insertions(+), 39 deletions(-) diff --git a/components/rich-text/ImageNode.tsx b/components/rich-text/ImageNode.tsx index 334c4df..a8ec131 100644 --- a/components/rich-text/ImageNode.tsx +++ b/components/rich-text/ImageNode.tsx @@ -9,6 +9,8 @@ import { CLICK_COMMAND, COMMAND_PRIORITY_LOW, DecoratorNode, + DRAGEND_COMMAND, + DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, SELECTION_CHANGE_COMMAND, @@ -51,11 +53,15 @@ type ImageComponentProps = ImagePayload & { nodeKey: NodeKey; }; +type ResizeDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'; + type PointerState = { startX: number; startY: number; startWidth: number; startHeight: number; + direction: ResizeDirection; + aspectRatio: number; }; const ImageComponent: React.FC = ({ src, altText, width, height, nodeKey }) => { @@ -113,7 +119,8 @@ const ImageComponent: React.FC = ({ src, altText, width, he return false; } - if (event.target === imageRef.current) { + const target = event.target as HTMLElement | null; + if (target && (target === imageRef.current || target.dataset.type === 'image-handle')) { if (event.shiftKey) { setSelected(!isSelected); return true; @@ -129,6 +136,24 @@ const ImageComponent: React.FC = ({ src, altText, width, he [clearSelection, isSelected, setSelected], ); + const onDragStart = useCallback( + (event: DragEvent) => { + if (!isEditable || !event.dataTransfer || !imageRef.current) { + return false; + } + event.dataTransfer.setData('text/plain', '_lexical_image'); + event.dataTransfer.setDragImage(imageRef.current, imageRef.current.clientWidth / 2, imageRef.current.clientHeight / 2); + event.dataTransfer.effectAllowed = 'move'; + return true; + }, + [isEditable], + ); + + const onDragEnd = useCallback(() => { + setIsResizing(false); + return false; + }, []); + useEffect( () => mergeRegister( @@ -151,8 +176,10 @@ const ImageComponent: React.FC = ({ src, altText, width, he editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGSTART_COMMAND, onDragStart, COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGEND_COMMAND, onDragEnd, COMMAND_PRIORITY_LOW), ), - [editor, isSelected, nodeKey, onClick, onDelete, setSelected], + [editor, isSelected, nodeKey, onClick, onDelete, onDragEnd, onDragStart, setSelected], ); const resolvedWidth = useMemo(() => (typeof currentWidth === 'number' ? `${currentWidth}px` : currentWidth ?? 'auto'), [ @@ -169,29 +196,88 @@ const ImageComponent: React.FC = ({ src, altText, width, he return; } - const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX)); - const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY)); + const deltaX = event.clientX - state.startX; + const deltaY = event.clientY - state.startY; - setCurrentWidth(nextWidth); - setCurrentHeight(nextHeight); - }, []); + let nextWidth = state.startWidth; + let nextHeight = state.startHeight; - const handlePointerUp = useCallback((event: PointerEvent) => { - const state = pointerStateRef.current; - if (state) { - const nextWidth = Math.max(MIN_DIMENSION, state.startWidth + (event.clientX - state.startX)); - const nextHeight = Math.max(MIN_DIMENSION, state.startHeight + (event.clientY - state.startY)); - updateDimensions(nextWidth, nextHeight); + if (state.direction.includes('e')) { + nextWidth += deltaX; + } + if (state.direction.includes('w')) { + nextWidth -= deltaX; + } + if (state.direction.includes('s')) { + nextHeight += deltaY; + } + if (state.direction.includes('n')) { + nextHeight -= deltaY; } - pointerStateRef.current = null; - setIsResizing(false); - document.removeEventListener('pointermove', handlePointerMove); - document.removeEventListener('pointerup', handlePointerUp); - }, [handlePointerMove, updateDimensions]); + const lockAspect = event.shiftKey || state.direction.length === 2; + if (lockAspect) { + const widthBasedHeight = nextWidth / state.aspectRatio; + const heightBasedWidth = nextHeight * state.aspectRatio; + if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) { + nextHeight = widthBasedHeight; + } else { + nextWidth = heightBasedWidth; + } + } + + setCurrentWidth(Math.max(MIN_DIMENSION, nextWidth)); + setCurrentHeight(Math.max(MIN_DIMENSION, nextHeight)); + }, []); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + const state = pointerStateRef.current; + if (state) { + const deltaX = event.clientX - state.startX; + const deltaY = event.clientY - state.startY; + let nextWidth = state.startWidth; + let nextHeight = state.startHeight; + + if (state.direction.includes('e')) { + nextWidth += deltaX; + } + if (state.direction.includes('w')) { + nextWidth -= deltaX; + } + if (state.direction.includes('s')) { + nextHeight += deltaY; + } + if (state.direction.includes('n')) { + nextHeight -= deltaY; + } + + const lockAspect = event.shiftKey || state.direction.length === 2; + if (lockAspect) { + const widthBasedHeight = nextWidth / state.aspectRatio; + const heightBasedWidth = nextHeight * state.aspectRatio; + if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) { + nextHeight = widthBasedHeight; + } else { + nextWidth = heightBasedWidth; + } + } + + nextWidth = Math.max(MIN_DIMENSION, nextWidth); + nextHeight = Math.max(MIN_DIMENSION, nextHeight); + updateDimensions(nextWidth, nextHeight); + } + + pointerStateRef.current = null; + setIsResizing(false); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, updateDimensions], + ); const handlePointerDown = useCallback( - (event: React.PointerEvent) => { + (event: React.PointerEvent, direction: ResizeDirection) => { if (!isEditable || !imageRef.current) { return; } @@ -204,6 +290,8 @@ const ImageComponent: React.FC = ({ src, altText, width, he startY: event.clientY, startWidth: rect.width, startHeight: rect.height, + direction, + aspectRatio: rect.width / rect.height, }; setIsResizing(true); @@ -220,36 +308,55 @@ const ImageComponent: React.FC = ({ src, altText, width, he }; }, [handlePointerMove, handlePointerUp]); - const onDragStart = useCallback( - (event: React.DragEvent) => { - if (!isEditable || !event.dataTransfer) { - return; - } - event.stopPropagation(); - event.dataTransfer.setData('text/plain', '_lexical_image'); - }, - [isEditable], - ); - const showHandles = isEditable && isSelected; return ( - + {altText} { + onDragStart(event.nativeEvent); + }} + onDragEnd={(event) => { + event.preventDefault(); + onDragEnd(); + }} /> {showHandles ? ( -
-
+
+ {( + [ + ['nw', '-top-2 -left-2 cursor-nw-resize'], + ['n', '-top-2 left-1/2 -translate-x-1/2 cursor-n-resize'], + ['ne', '-top-2 -right-2 cursor-ne-resize'], + ['e', 'top-1/2 -right-2 -translate-y-1/2 cursor-e-resize'], + ['se', '-bottom-2 -right-2 cursor-se-resize'], + ['s', '-bottom-2 left-1/2 -translate-x-1/2 cursor-s-resize'], + ['sw', '-bottom-2 -left-2 cursor-sw-resize'], + ['w', 'top-1/2 -left-2 -translate-y-1/2 cursor-w-resize'], + ] as const + ).map(([direction, positionClass]) => ( +
) : null} {isResizing ?
: null} From 4180438df556daf65e7b38a9a97b82f9c5cf112c Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 29 Nov 2025 12:05:43 +0100 Subject: [PATCH 03/19] Fix image selection overlay sizing and dragging --- components/rich-text/ImageNode.tsx | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/components/rich-text/ImageNode.tsx b/components/rich-text/ImageNode.tsx index a8ec131..e688400 100644 --- a/components/rich-text/ImageNode.tsx +++ b/components/rich-text/ImageNode.tsx @@ -73,6 +73,10 @@ const ImageComponent: React.FC = ({ src, altText, width, he const pointerStateRef = useRef(null); const [currentWidth, setCurrentWidth] = useState(width ?? 'inherit'); const [currentHeight, setCurrentHeight] = useState(height ?? 'inherit'); + const [naturalSize, setNaturalSize] = useState({ + width: typeof width === 'number' ? width : 0, + height: typeof height === 'number' ? height : 0, + }); useEffect(() => { setCurrentWidth(width ?? 'inherit'); @@ -141,12 +145,15 @@ const ImageComponent: React.FC = ({ src, altText, width, he if (!isEditable || !event.dataTransfer || !imageRef.current) { return false; } + clearSelection(); + setSelected(true); event.dataTransfer.setData('text/plain', '_lexical_image'); + event.dataTransfer.setData('application/x-lexical-dragged-nodes', JSON.stringify([nodeKey])); event.dataTransfer.setDragImage(imageRef.current, imageRef.current.clientWidth / 2, imageRef.current.clientHeight / 2); event.dataTransfer.effectAllowed = 'move'; return true; }, - [isEditable], + [clearSelection, isEditable, nodeKey, setSelected], ); const onDragEnd = useCallback(() => { @@ -190,6 +197,20 @@ const ImageComponent: React.FC = ({ src, altText, width, he [currentHeight], ); + const measuredWidth = useMemo(() => { + if (typeof currentWidth === 'number') return currentWidth; + if (imageRef.current?.width) return imageRef.current.width; + if (naturalSize.width) return naturalSize.width; + return undefined; + }, [currentWidth, naturalSize.width]); + + const measuredHeight = useMemo(() => { + if (typeof currentHeight === 'number') return currentHeight; + if (imageRef.current?.height) return imageRef.current.height; + if (naturalSize.height) return naturalSize.height; + return undefined; + }, [currentHeight, naturalSize.height]); + const handlePointerMove = useCallback((event: PointerEvent) => { const state = pointerStateRef.current; if (!state) { @@ -312,8 +333,12 @@ const ImageComponent: React.FC = ({ src, altText, width, he return ( = ({ src, altText, width, he style={{ width: resolvedWidth, height: resolvedHeight, maxWidth: '100%', borderRadius: '0.5rem', objectFit: 'contain' }} className={`block border border-border-color/60 bg-secondary transition-shadow duration-150 ${showHandles ? 'ring-2 ring-primary shadow-lg' : 'shadow-sm'}`} draggable={isEditable} + onLoad={(event) => { + setNaturalSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); + }} onDragStart={(event) => { onDragStart(event.nativeEvent); }} @@ -353,9 +381,7 @@ const ImageComponent: React.FC = ({ src, altText, width, he /> ))}
- {`${Math.round(typeof currentWidth === 'number' ? currentWidth : imageRef.current?.width ?? 0)} ร— ${Math.round( - typeof currentHeight === 'number' ? currentHeight : imageRef.current?.height ?? 0, - )} px`} + {`${Math.round(measuredWidth ?? 0)} ร— ${Math.round(measuredHeight ?? 0)} px`}
) : null} @@ -388,7 +414,7 @@ export class ImageNode extends DecoratorNode { createDOM(): HTMLElement { const dom = document.createElement('span'); - dom.className = 'inline-block my-3 w-full'; + dom.className = 'inline-block my-3 max-w-full'; dom.style.maxWidth = '100%'; return dom; } From fb5b1f8597d60bb70c197c816bd3d84fa2dd571b Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 14:36:35 +0100 Subject: [PATCH 04/19] fix: prevent focus stealing in modal dialogs by optimizing focus trap logic --- components/Modal.tsx | 51 +++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/components/Modal.tsx b/components/Modal.tsx index 08d97f8..59dbc5e 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -40,7 +40,7 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef }; }, [onClose]); - // Effect for focus trapping + // Effect for setting initial focus - RUNS ONLY ONCE useEffect(() => { const focusTimer = setTimeout(() => { const modalNode = modalRef.current; @@ -58,23 +58,29 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef focusableElements[0].focus(); } } - }, 0); // Use a timeout to ensure the DOM is ready for focus + }, 50); // Small delay to ensure render - // Focus trapping logic for Tab key - const modalNode = modalRef.current; - if (!modalNode) return () => clearTimeout(focusTimer); - - const focusableElements = modalNode.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - if (focusableElements.length === 0) return () => clearTimeout(focusTimer); - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + return () => clearTimeout(focusTimer); + }, []); // Empty dependency array ensures this runs only on mount + // Effect for focus trapping (Tab key) + useEffect(() => { const handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; + const modalNode = modalRef.current; + if (!modalNode) return; + + // Re-query focusable elements every time Tab is pressed to handle dynamic content changes + const focusableElements = modalNode.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { e.preventDefault(); @@ -87,16 +93,21 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef } } }; - - modalNode.addEventListener('keydown', handleTabKey); + + // Attach listener to the specific modal node if possible, or window/document if needed for trapping. + // Attaching to modalNode is better for containment, but we need to ensure the modal has focus. + // For a robust trap, listening on the modal node is good IF the focus is inside. + const modalNode = modalRef.current; + if (modalNode) { + modalNode.addEventListener('keydown', handleTabKey); + } return () => { - clearTimeout(focusTimer); - if (modalNode) { - modalNode.removeEventListener('keydown', handleTabKey); - } + if (modalNode) { + modalNode.removeEventListener('keydown', handleTabKey); + } }; - }, [onClose, initialFocusRef]); + }, []); // Run once to attach handlers const modalContent = (
Date: Fri, 19 Dec 2025 14:41:54 +0100 Subject: [PATCH 05/19] chore: bump version to 0.8.1 and update font stack --- VERSION_LOG.md | 10 ++++++++++ index.html | 10 ++++------ package.json | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 26b67fb..333a6ae 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -1,5 +1,15 @@ # Version Log +## v0.8.1 - The Native Fonts & Focus Fix Update + +### ๐Ÿ›  Improvements + +- Updated the application font stack to match VS Code on Windows, using **Segoe UI** for the interface and **Consolas** for code. This provides a more native and consistent look for Windows users. + +### ๐Ÿ› Fixes + +- Resolved an issue where the "Create Document from Template" dialog would lose focus or reset the cursor position while typing in variable inputs. The focus trap logic has been optimized to handle dynamic content updates correctly. + ## v0.8.0 - The Emoji & Command Palette Update ### โœจ Features diff --git a/index.html b/index.html index 60d9375..14461d1 100644 --- a/index.html +++ b/index.html @@ -34,16 +34,14 @@ 'tree-selected': 'rgb(var(--color-tree-selected) / )', }, fontFamily: { - sans: ['Inter', 'sans-serif'], - mono: ['JetBrains Mono', 'monospace'], + sans: ['Segoe WPC', 'Segoe UI', 'sans-serif'], + mono: ['Consolas', 'Courier New', 'monospace'], }, } } } - - - + +
- + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ebbaa9e..f7601b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docforge", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docforge", - "version": "0.8.0", + "version": "0.8.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/services/themeCustomization.ts b/services/themeCustomization.ts index 08da636..15d7033 100644 --- a/services/themeCustomization.ts +++ b/services/themeCustomization.ts @@ -333,22 +333,22 @@ const TONE_OVERRIDES: Record> light: { neutral: {}, warm: { - background: '253 246 239', - secondary: '255 240 228', - textMain: '49 27 11', - textSecondary: '139 94 52', - border: '242 209 179', - accent: '249 115 22', + background: '255 252 245', // Warmer, lighter off-white + secondary: '255 247 237', // Subtle warm secondary + textMain: '67 20 7', // Deep warm brown + textSecondary: '124 45 18', // Muted warm reddish-brown + border: '253 230 138', // Soft warm border + accent: '245 158 11', // Amber/Orange accentText: '255 255 255', }, cool: { - background: '241 245 255', - secondary: '226 235 255', - textMain: '15 23 42', - textSecondary: '71 85 105', - border: '199 210 254', - accent: '37 99 235', - accentText: '248 250 252', + background: '248 250 252', // Very subtle cool slate-50 + secondary: '241 245 249', // Slate-100 + textMain: '15 23 42', // Slate-900 + textSecondary: '71 85 105', // Slate-600 + border: '226 232 240', // Slate-200 + accent: '59 130 246', // Blue-500 + accentText: '255 255 255', }, }, dark: { From 0c3abd887262adfd63d6b37db91708d4b20c23b1 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:24:54 +0100 Subject: [PATCH 07/19] Implement Antigravity styling across UI components - Sharper corners (rounded-sm instead of rounded-md/lg) - Reduced focus rings (ring-1, subtle opacity) - Removed shadows from modals, tooltips, dropdowns, context menus - Updated hover states to use tree-selected for list items - Ghost buttons now use text-only hover (no background) - Smaller toggle switch for compact feel --- components/Button.tsx | 14 +++++----- components/ContextMenu.tsx | 42 ++++++++++++++--------------- components/IconButton.tsx | 48 ++++++++++++++++----------------- components/LanguageDropdown.tsx | 13 +++++---- components/Modal.tsx | 2 +- components/ToggleSwitch.tsx | 10 +++---- components/Tooltip.tsx | 2 +- components/WelcomeScreen.tsx | 4 +-- 8 files changed, 66 insertions(+), 69 deletions(-) diff --git a/components/Button.tsx b/components/Button.tsx index 96c45b6..b857871 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -9,15 +9,15 @@ interface ButtonProps extends React.ButtonHTMLAttributes { const Button = React.forwardRef( ({ children, variant = 'primary', isLoading = false, className, ...props }, ref) => { - const baseClasses = 'inline-flex items-center justify-center px-3 py-1.5 border text-xs font-semibold rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background disabled:opacity-60 disabled:cursor-not-allowed transition-colors duration-150'; - + const baseClasses = 'inline-flex items-center justify-center px-3 py-1.5 border text-xs font-semibold rounded-sm focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-100'; + const variantClasses = { - primary: 'bg-primary text-primary-text border-transparent hover:bg-primary-hover focus:ring-primary', - secondary: 'bg-secondary text-text-main border-border-color hover:bg-border-color focus:ring-primary', - destructive: 'bg-destructive-bg text-destructive-text border-destructive-border hover:bg-destructive-bg-hover focus:ring-destructive-text', - ghost: 'bg-transparent text-text-main border-transparent hover:bg-border-color focus:ring-primary', + primary: 'bg-primary text-primary-text border-transparent hover:bg-primary-hover focus:ring-primary/50', + secondary: 'bg-secondary text-text-main border-border-color hover:bg-border-color/50 focus:ring-primary/50', + destructive: 'bg-destructive-bg text-destructive-text border-destructive-border hover:bg-destructive-bg-hover focus:ring-destructive-text/50', + ghost: 'bg-transparent text-text-secondary border-transparent hover:text-text-main focus:ring-primary/30', }; - + const disabled = props.disabled || isLoading; return ( diff --git a/components/ContextMenu.tsx b/components/ContextMenu.tsx index d1fda60..8fa155b 100644 --- a/components/ContextMenu.tsx +++ b/components/ContextMenu.tsx @@ -3,21 +3,21 @@ import ReactDOM from 'react-dom'; export type MenuItem = | { - label: string; - action: () => void; - icon?: React.FC<{ className?: string }>; - disabled?: boolean; - shortcut?: string; - submenu?: never; - } + label: string; + action: () => void; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + submenu?: never; + } | { - label: string; - submenu: MenuItem[]; - icon?: React.FC<{ className?: string }>; - disabled?: boolean; - shortcut?: string; - action?: never; - } + label: string; + submenu: MenuItem[]; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + action?: never; + } | { type: 'separator' }; interface ContextMenuProps { @@ -96,9 +96,9 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } + if (event.key === 'Escape') { + onClose(); + } } document.addEventListener('mousedown', handleClickOutside); @@ -200,7 +200,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }} disabled={item.disabled || !hasEnabledSubitem} - className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none" + className="w-full flex items-center justify-between text-left px-2 py-1 text-xs rounded-sm transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-tree-selected hover:text-text-main focus:bg-tree-selected focus:text-text-main focus:outline-none" >
{Icon && } @@ -209,7 +209,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl โ€บ {isOpen && item.submenu.length > 0 && ( -
+
    {renderItems(item.submenu, depth + 1)}
)} @@ -229,7 +229,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }} disabled={disabled} - className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none" + className="w-full flex items-center justify-between text-left px-2 py-1 text-xs rounded-sm transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-tree-selected hover:text-text-main focus:bg-tree-selected focus:text-text-main focus:outline-none" >
{Icon && } @@ -251,7 +251,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl maxHeight: menuStyle.maxHeight ? menuStyle.maxHeight : undefined, overflowY: menuStyle.overflowY, }} - className="fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast" + className="fixed z-50 w-[16.8rem] rounded-sm bg-secondary p-1 border border-border-color animate-fade-in-fast" >
    {renderItems(items)} diff --git a/components/IconButton.tsx b/components/IconButton.tsx index 6c84f17..ad4f5b4 100644 --- a/components/IconButton.tsx +++ b/components/IconButton.tsx @@ -9,34 +9,34 @@ interface IconButtonProps extends React.ButtonHTMLAttributes tooltipPosition?: 'top' | 'bottom'; } -const IconButton: React.FC = ({ children, tooltip, className, variant = 'primary', size='md', tooltipPosition = 'top', ...props }) => { - const [isHovered, setIsHovered] = useState(false); - const wrapperRef = useRef(null); - const { ['aria-label']: ariaLabel, ...buttonProps } = props; - const computedAriaLabel = ariaLabel ?? tooltip; +const IconButton: React.FC = ({ children, tooltip, className, variant = 'primary', size = 'md', tooltipPosition = 'top', ...props }) => { + const [isHovered, setIsHovered] = useState(false); + const wrapperRef = useRef(null); + const { ['aria-label']: ariaLabel, ...buttonProps } = props; + const computedAriaLabel = ariaLabel ?? tooltip; - const handleMouseEnter = useCallback(() => { - if (tooltip) setIsHovered(true); - }, [tooltip]); + const handleMouseEnter = useCallback(() => { + if (tooltip) setIsHovered(true); + }, [tooltip]); - const handleMouseLeave = useCallback(() => { - setIsHovered(false); - }, []); + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); - const baseClasses = "flex items-center justify-center rounded-md focus:outline-none transition-colors"; - - const variantClasses = { - primary: 'text-text-secondary hover:bg-border-color hover:text-text-main', - ghost: 'text-text-secondary/80 hover:bg-border-color hover:text-text-main', - destructive: 'text-destructive-text bg-transparent hover:bg-destructive-bg' - }; + const baseClasses = "flex items-center justify-center rounded-sm focus:outline-none transition-colors duration-100"; + + const variantClasses = { + primary: 'text-text-secondary hover:text-text-main hover:bg-border-color/30', + ghost: 'text-text-secondary hover:text-text-main', + destructive: 'text-destructive-text bg-transparent hover:bg-destructive-bg/50' + }; + + const sizeClasses = { + xs: 'w-6 h-6', + sm: 'w-7 h-7', + md: 'w-8 h-8' + }; - const sizeClasses = { - xs: 'w-6 h-6', - sm: 'w-7 h-7', - md: 'w-8 h-8' - }; - return ( <> @@ -106,7 +106,7 @@ const LanguageDropdown = React.forwardRef {isOpen && (
    @@ -119,11 +119,10 @@ const LanguageDropdown = React.forwardRef handleSelect(language.id)} - className={`text-left px-3 py-1.5 rounded-md transition-colors ${ - language.id === selectedLanguage.id - ? 'bg-secondary/70 text-primary font-semibold' - : 'text-text-main hover:bg-secondary' - }`} + className={`text-left px-3 py-1 rounded-sm transition-colors ${language.id === selectedLanguage.id + ? 'bg-tree-selected text-text-main font-medium' + : 'text-text-main hover:bg-tree-selected' + }`} > {language.label} diff --git a/components/Modal.tsx b/components/Modal.tsx index 59dbc5e..58da276 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -118,7 +118,7 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef >
    e.stopPropagation()} >
    diff --git a/components/ToggleSwitch.tsx b/components/ToggleSwitch.tsx index 3e3a55a..57f7609 100644 --- a/components/ToggleSwitch.tsx +++ b/components/ToggleSwitch.tsx @@ -14,14 +14,12 @@ const ToggleSwitch: React.FC = ({ id, checked, onChange }) => role="switch" aria-checked={checked} onClick={() => onChange(!checked)} - className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-secondary ${ - checked ? 'bg-primary' : 'bg-border-color' - }`} + className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors duration-100 focus:outline-none focus:ring-1 focus:ring-primary/50 ${checked ? 'bg-primary' : 'bg-border-color' + }`} > ); diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 68ec1ef..5841b7a 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -90,7 +90,7 @@ const Tooltip: React.FC = ({ targetRef, content, position = 'top', const overlayRoot = document.getElementById('overlay-root'); if (!overlayRoot) return null; - const baseClassName = 'fixed z-50 w-max px-2 py-1 text-xs font-semibold text-tooltip-text bg-tooltip-bg rounded-md shadow-lg transition-opacity duration-200 pointer-events-none'; + const baseClassName = 'fixed z-50 w-max px-2 py-1 text-xs font-semibold text-tooltip-text bg-tooltip-bg rounded-sm border border-border-color transition-opacity duration-100 pointer-events-none'; const composedClassName = className ? `${baseClassName} ${className}` : `${baseClassName} max-w-xs`; return ReactDOM.createPortal( diff --git a/components/WelcomeScreen.tsx b/components/WelcomeScreen.tsx index c92e312..24164f2 100644 --- a/components/WelcomeScreen.tsx +++ b/components/WelcomeScreen.tsx @@ -4,13 +4,13 @@ import { PlusIcon, FileIcon } from './Icons'; import Button from './Button'; interface WelcomeScreenProps { - onNewDocument: () => void; + onNewDocument: () => void; } export const WelcomeScreen: React.FC = ({ onNewDocument }) => { return (
    -
    +

    Welcome to DocForge

    From cb894f86b1a19c3f5c26eedee9bf4a66e7ad24e2 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:34:39 +0100 Subject: [PATCH 08/19] Revert ContextMenu styling changes per user request --- components/ContextMenu.tsx | 42 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/components/ContextMenu.tsx b/components/ContextMenu.tsx index 8fa155b..d1fda60 100644 --- a/components/ContextMenu.tsx +++ b/components/ContextMenu.tsx @@ -3,21 +3,21 @@ import ReactDOM from 'react-dom'; export type MenuItem = | { - label: string; - action: () => void; - icon?: React.FC<{ className?: string }>; - disabled?: boolean; - shortcut?: string; - submenu?: never; - } + label: string; + action: () => void; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + submenu?: never; + } | { - label: string; - submenu: MenuItem[]; - icon?: React.FC<{ className?: string }>; - disabled?: boolean; - shortcut?: string; - action?: never; - } + label: string; + submenu: MenuItem[]; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + action?: never; + } | { type: 'separator' }; interface ContextMenuProps { @@ -96,9 +96,9 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } + if (event.key === 'Escape') { + onClose(); + } } document.addEventListener('mousedown', handleClickOutside); @@ -200,7 +200,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }} disabled={item.disabled || !hasEnabledSubitem} - className="w-full flex items-center justify-between text-left px-2 py-1 text-xs rounded-sm transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-tree-selected hover:text-text-main focus:bg-tree-selected focus:text-text-main focus:outline-none" + className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none" >

    {Icon && } @@ -209,7 +209,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl โ€บ {isOpen && item.submenu.length > 0 && ( -
    +
      {renderItems(item.submenu, depth + 1)}
    )} @@ -229,7 +229,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl } }} disabled={disabled} - className="w-full flex items-center justify-between text-left px-2 py-1 text-xs rounded-sm transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-tree-selected hover:text-text-main focus:bg-tree-selected focus:text-text-main focus:outline-none" + className="w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-md transition-colors text-text-main disabled:text-text-secondary/50 disabled:cursor-not-allowed hover:bg-primary hover:text-primary-text focus:bg-primary focus:text-primary-text focus:outline-none" >
    {Icon && } @@ -251,7 +251,7 @@ const ContextMenu: React.FC = ({ isOpen, position, items, onCl maxHeight: menuStyle.maxHeight ? menuStyle.maxHeight : undefined, overflowY: menuStyle.overflowY, }} - className="fixed z-50 w-[16.8rem] rounded-sm bg-secondary p-1 border border-border-color animate-fade-in-fast" + className="fixed z-50 w-[16.8rem] rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast" >
      {renderItems(items)} From a89ac7cfcf47198d664b50eaffa383b1e102d2b3 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:37:17 +0100 Subject: [PATCH 09/19] Improve search box styling for professional look - Removed visible border, subtle background - Sharper corners - Reduced focus ring intensity - Muted placeholder color --- components/Sidebar.tsx | 338 ++++++++++++++++++++--------------------- 1 file changed, 169 insertions(+), 169 deletions(-) diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 51d723f..bf940ac 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -68,17 +68,17 @@ const DEFAULT_TEMPLATES_PANEL_HEIGHT = 160; const MIN_TEMPLATES_PANEL_HEIGHT = 80; // Helper function to find a node and its siblings in a tree structure -const findNodeAndSiblings = (nodes: DocumentNode[], id: string): {node: DocumentNode, siblings: DocumentNode[]} | null => { - for (const node of nodes) { - if (node.id === id) { - return { node, siblings: nodes }; - } - if (node.type === 'folder' && node.children.length > 0) { - const found = findNodeAndSiblings(node.children, id); - if (found) return found; - } +const findNodeAndSiblings = (nodes: DocumentNode[], id: string): { node: DocumentNode, siblings: DocumentNode[] } | null => { + for (const node of nodes) { + if (node.id === id) { + return { node, siblings: nodes }; + } + if (node.type === 'folder' && node.children.length > 0) { + const found = findNodeAndSiblings(node.children, id); + if (found) return found; } - return null; + } + return null; }; const Sidebar: React.FC = (props) => { @@ -173,23 +173,23 @@ const Sidebar: React.FC = (props) => { // Effect to scroll focused item into view useEffect(() => { if (focusedItemId && sidebarRef.current) { - const element = sidebarRef.current.querySelector(`[data-item-id='${focusedItemId}']`); - element?.scrollIntoView({ block: 'nearest' }); + const element = sidebarRef.current.querySelector(`[data-item-id='${focusedItemId}']`); + element?.scrollIntoView({ block: 'nearest' }); } }, [focusedItemId]); useEffect(() => { if (!pendingRevealId || !sidebarRef.current) { - return; + return; } const raf = requestAnimationFrame(() => { - const element = sidebarRef.current?.querySelector(`[data-item-id='${pendingRevealId}']`) as HTMLElement | null; - if (element) { - element.scrollIntoView({ block: 'center' }); - setFocusedItemId(pendingRevealId); - onRevealHandled(); - } + const element = sidebarRef.current?.querySelector(`[data-item-id='${pendingRevealId}']`) as HTMLElement | null; + if (element) { + element.scrollIntoView({ block: 'center' }); + setFocusedItemId(pendingRevealId); + onRevealHandled(); + } }); return () => cancelAnimationFrame(raf); @@ -209,7 +209,7 @@ const Sidebar: React.FC = (props) => { setIsTemplatesCollapsed(newCollapsedState); storageService.save(LOCAL_STORAGE_KEYS.SIDEBAR_TEMPLATES_COLLAPSED, newCollapsedState); }; - + // --- Resizing Logic for Templates Panel --- const handleTemplatesResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -220,11 +220,11 @@ const Sidebar: React.FC = (props) => { const handleGlobalMouseMove = useCallback((e: MouseEvent) => { if (!isResizingTemplates.current || !sidebarRef.current) return; - + const sidebarRect = sidebarRef.current.getBoundingClientRect(); const newHeight = sidebarRect.bottom - e.clientY; const maxTemplatesPanelHeight = sidebarRect.height - 200; // Ensure docs panel has at least 200px - + const clampedHeight = Math.max(MIN_TEMPLATES_PANEL_HEIGHT, Math.min(newHeight, maxTemplatesPanelHeight)); setTemplatesPanelHeight(clampedHeight); }, []); @@ -253,29 +253,29 @@ const Sidebar: React.FC = (props) => { const handleMoveUp = useCallback((id: string) => { - const result = findNodeAndSiblings(documentTree, id); - if (!result) return; - - const { siblings } = result; - const index = siblings.findIndex(s => s.id === id); - - if (index > 0) { - const targetSiblingId = siblings[index - 1].id; - props.onMoveNode([id], targetSiblingId, 'before'); - } + const result = findNodeAndSiblings(documentTree, id); + if (!result) return; + + const { siblings } = result; + const index = siblings.findIndex(s => s.id === id); + + if (index > 0) { + const targetSiblingId = siblings[index - 1].id; + props.onMoveNode([id], targetSiblingId, 'before'); + } }, [documentTree, props.onMoveNode]); - + const handleMoveDown = useCallback((id: string) => { - const result = findNodeAndSiblings(documentTree, id); - if (!result) return; - - const { siblings } = result; - const index = siblings.findIndex(s => s.id === id); - - if (index < siblings.length - 1) { - const targetSiblingId = siblings[index + 1].id; - props.onMoveNode([id], targetSiblingId, 'after'); - } + const result = findNodeAndSiblings(documentTree, id); + if (!result) return; + + const { siblings } = result; + const index = siblings.findIndex(s => s.id === id); + + if (index < siblings.length - 1) { + const targetSiblingId = siblings[index + 1].id; + props.onMoveNode([id], targetSiblingId, 'after'); + } }, [documentTree, props.onMoveNode]); const commandMap = useMemo(() => { @@ -398,9 +398,9 @@ const Sidebar: React.FC = (props) => { const key = e.key; if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) { - return; + return; } - + e.preventDefault(); const currentItem = navigableItems.find(item => item.id === focusedItemId); @@ -438,7 +438,7 @@ const Sidebar: React.FC = (props) => { const direction = key === 'ArrowUp' ? -1 : 1; const nextIndex = Math.max(0, Math.min(navigableItems.length - 1, currentIndex + direction)); const newItem = navigableItems[nextIndex]; - + if (e.shiftKey) { const anchorId = lastClickedId || focusedItemId; const anchorIndex = navigableItems.findIndex(i => i.id === anchorId); @@ -481,7 +481,7 @@ const Sidebar: React.FC = (props) => { } } }; - + const getTooltip = (commandId: string, baseText: string) => { const command = commands.find(c => c.id === commandId); return command?.shortcutString ? `${baseText} (${command.shortcutString})` : baseText; @@ -492,136 +492,136 @@ const Sidebar: React.FC = (props) => {
      - - setSearchTerm(e.target.value)} - onKeyDown={handleSearchKeyDown} - className={`w-full bg-background border border-border-color rounded-md pl-9 py-1 text-xs text-text-main focus:ring-2 focus:ring-primary focus:outline-none placeholder:text-text-secondary ${focusSearchShortcutString && !searchTerm.trim() ? 'pr-24' : 'pr-9'}`} - /> - {focusSearchShortcutString && !searchTerm.trim() && ( - - {focusSearchShortcutString} - - )} - {searchTerm.trim() && ( - - )} + + setSearchTerm(e.target.value)} + onKeyDown={handleSearchKeyDown} + className={`w-full bg-secondary/50 border-0 rounded-sm pl-9 py-1 text-xs text-text-main focus:bg-background focus:ring-1 focus:ring-primary/50 focus:outline-none placeholder:text-text-secondary/60 ${focusSearchShortcutString && !searchTerm.trim() ? 'pr-24' : 'pr-9'}`} + /> + {focusSearchShortcutString && !searchTerm.trim() && ( + + {focusSearchShortcutString} + + )} + {searchTerm.trim() && ( + + )}
      -
      - {/* Documents Panel */} -
      -
      -

      Documents

      -
      - - - - - - -
      - - - - - - - - - - - - -
      -
      -
      - -
      +
      + {/* Documents Panel */} +
      +
      +

      Documents

      +
      + + + + + + +
      + + + + + + + + + + + + +
      +
      +
      + +
      +
      + + {!isTemplatesCollapsed && ( +
      + )} + + {/* Templates Panel */} +
      +
      +
      + + {isTemplatesCollapsed ? : } + +

      Templates

      - {!isTemplatesCollapsed && ( -
      +
      + + + +
      )} - - {/* Templates Panel */} -
      -
      -
      - - {isTemplatesCollapsed ? : } - -

      Templates

      -
      - {!isTemplatesCollapsed && ( -
      - - - -
      - )} -
      - {!isTemplatesCollapsed && ( -
      - -
      - )} +
      + {!isTemplatesCollapsed && ( +
      +
      + )}
      +
      ); }; From 5e9c6ec43f09c5cff801ee9c8856f019a4aab33d Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:37:57 +0100 Subject: [PATCH 10/19] Remove rounded focus box from database dropdown --- components/ContextMenu.tsx.bak | 273 +++++++++++++++++++++++++++++++++ components/StatusBar.tsx | 56 +++---- 2 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 components/ContextMenu.tsx.bak diff --git a/components/ContextMenu.tsx.bak b/components/ContextMenu.tsx.bak new file mode 100644 index 0000000..d1fda60 --- /dev/null +++ b/components/ContextMenu.tsx.bak @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; + +export type MenuItem = + | { + label: string; + action: () => void; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + submenu?: never; + } + | { + label: string; + submenu: MenuItem[]; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + action?: never; + } + | { type: 'separator' }; + +interface ContextMenuProps { + isOpen: boolean; + position: { x: number; y: number }; + items: MenuItem[]; + onClose: () => void; +} + +const EDGE_MARGIN = 8; + +const ContextMenu: React.FC = ({ isOpen, position, items, onClose }) => { + const menuRef = useRef(null); + const [openSubmenu, setOpenSubmenu] = useState(null); + const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({ + top: position.y, + left: position.x, + maxHeight: 0, + overflowY: 'visible', + }); + + const recalculatePosition = useCallback(() => { + const menu = menuRef.current; + if (!menu) return; + + const { innerWidth, innerHeight } = window; + const rect = menu.getBoundingClientRect(); + const maxHeight = Math.max(innerHeight - EDGE_MARGIN * 2, 0); + + let left = rect.left; + let top = rect.top; + + if (rect.right > innerWidth - EDGE_MARGIN) { + left = Math.max(EDGE_MARGIN, innerWidth - rect.width - EDGE_MARGIN); + } + if (left < EDGE_MARGIN) { + left = EDGE_MARGIN; + } + + if (rect.bottom > innerHeight - EDGE_MARGIN) { + top = Math.max(EDGE_MARGIN, innerHeight - rect.height - EDGE_MARGIN); + } + if (top < EDGE_MARGIN) { + top = EDGE_MARGIN; + } + + const overflowY: React.CSSProperties['overflowY'] = rect.height > maxHeight ? 'auto' : 'visible'; + + setMenuStyle((previous) => { + if ( + previous.top === top && + previous.left === left && + previous.maxHeight === maxHeight && + previous.overflowY === overflowY + ) { + return previous; + } + + return { + top, + left, + maxHeight, + overflowY, + }; + }); + }, []); + + useEffect(() => { + if (!isOpen) return; + + setOpenSubmenu(null); + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + useLayoutEffect(() => { + if (!isOpen) return; + + setMenuStyle((previous) => { + if (previous.top === position.y && previous.left === position.x) { + return previous; + } + + return { + top: position.y, + left: position.x, + maxHeight: previous.maxHeight, + overflowY: previous.overflowY, + }; + }); + + const frame = requestAnimationFrame(() => { + recalculatePosition(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, position.x, position.y, recalculatePosition]); + + useLayoutEffect(() => { + if (!isOpen) return; + + const frame = requestAnimationFrame(() => { + recalculatePosition(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, items, recalculatePosition]); + + useEffect(() => { + if (!isOpen) return; + + const handleResize = () => { + recalculatePosition(); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [isOpen, recalculatePosition]); + + if (!isOpen) return null; + + const overlayRoot = document.getElementById('overlay-root'); + if (!overlayRoot) return null; + + const hasEnabledItem = (menuItems: MenuItem[]): boolean => { + return menuItems.some(item => { + if ('type' in item) { + return false; + } + + if ('submenu' in item) { + return !item.disabled && hasEnabledItem(item.submenu); + } + + return !item.disabled; + }); + }; + + const renderItems = (menuItems: MenuItem[], depth = 0) => { + return menuItems.map((item, index) => { + if ('type' in item) { + return
    • ; + } + + if ('submenu' in item) { + const menuKey = `${depth}-${index}`; + const isOpen = openSubmenu === menuKey; + const hasEnabledSubitem = hasEnabledItem(item.submenu); + const Icon = item.icon; + + return ( +
    • setOpenSubmenu(menuKey)} + onMouseLeave={() => setOpenSubmenu(null)} + > + + {isOpen && item.submenu.length > 0 && ( +
      +
        {renderItems(item.submenu, depth + 1)}
      +
      + )} +
    • + ); + } + + const { label, action, icon: Icon, disabled, shortcut } = item; + + return ( +
    • + +
    • + ); + }); + }; + + return ReactDOM.createPortal( +
      +
        + {renderItems(items)} +
      + +
      , + overlayRoot + ); +}; + +export default ContextMenu; \ No newline at end of file diff --git a/components/StatusBar.tsx b/components/StatusBar.tsx index 2088b79..3ccae5c 100644 --- a/components/StatusBar.tsx +++ b/components/StatusBar.tsx @@ -117,31 +117,31 @@ const ZoomButton: React.FC = ({ hint, icon, className = '', dis }; const StatusBar: React.FC = ({ - status, - modelName, - llmProviderName, - llmProviderUrl, - documentCount, - lastSaved, - availableModels, - onModelChange, - discoveredServices, - onProviderChange, - appVersion, - databasePath, - databaseStatus, - onDatabaseMenu, - onOpenAbout, - previewScale, - onPreviewZoomIn, - onPreviewZoomOut, - onPreviewReset, - isPreviewZoomAvailable, - previewMinScale, - previewMaxScale, - previewInitialScale, - previewMetadata, - zoomTarget, + status, + modelName, + llmProviderName, + llmProviderUrl, + documentCount, + lastSaved, + availableModels, + onModelChange, + discoveredServices, + onProviderChange, + appVersion, + databasePath, + databaseStatus, + onDatabaseMenu, + onOpenAbout, + previewScale, + onPreviewZoomIn, + onPreviewZoomOut, + onPreviewReset, + isPreviewZoomAvailable, + previewMinScale, + previewMaxScale, + previewInitialScale, + previewMetadata, + zoomTarget, }) => { const { text, color, tooltip } = statusConfig[status]; const selectedService = discoveredServices.find(s => s.generateUrl === llmProviderUrl); @@ -292,8 +292,8 @@ const StatusBar: React.FC = ({ const sizeText = `${previewMetadata.width} ร— ${previewMetadata.height} px`; const typeText = previewMetadata.mimeType ? (previewMetadata.mimeType.startsWith('image/') - ? previewMetadata.mimeType.replace('image/', '').toUpperCase() - : previewMetadata.mimeType.toUpperCase()) + ? previewMetadata.mimeType.replace('image/', '').toUpperCase() + : previewMetadata.mimeType.toUpperCase()) : null; return { label: baseLabel, @@ -373,7 +373,7 @@ const StatusBar: React.FC = ({ event.preventDefault(); handleDatabaseMenu(event); }} - className={`flex items-center gap-1.5 px-1.5 py-1 -my-1 rounded-md transition-colors ${onDatabaseMenu ? 'hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer' : 'cursor-default'}`} + className={`flex items-center gap-1.5 px-1.5 py-1 -my-1 transition-colors ${onDatabaseMenu ? 'hover:text-text-main focus:outline-none focus:text-text-main cursor-pointer' : 'cursor-default'}`} disabled={!onDatabaseMenu} ref={databaseTriggerRef} onMouseEnter={() => setShowDatabaseTooltip(true)} From afc7c9202c5ba53b06f9337d02f6c9d000d41464 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:39:29 +0100 Subject: [PATCH 11/19] Remove focus ring from LLM provider/model selects --- components/StatusBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/StatusBar.tsx b/components/StatusBar.tsx index 3ccae5c..e787554 100644 --- a/components/StatusBar.tsx +++ b/components/StatusBar.tsx @@ -333,7 +333,7 @@ const StatusBar: React.FC = ({ value={selectedService?.id || ''} onChange={(e) => onProviderChange(e.target.value)} disabled={discoveredServices.length === 0} - className="bg-transparent font-semibold text-text-main rounded-md py-0.5 px-1 -my-1 hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary appearance-none pr-4" + className="bg-transparent font-semibold text-text-main py-0.5 px-1 -my-1 hover:text-primary focus:outline-none focus:text-primary appearance-none pr-4" aria-label="LLM provider" style={selectStyles} > @@ -352,7 +352,7 @@ const StatusBar: React.FC = ({ value={modelName} onChange={(e) => onModelChange(e.target.value)} disabled={availableModels.length === 0} - className="bg-transparent font-semibold text-text-main rounded-md py-0.5 px-1 -my-1 hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary appearance-none pr-4" + className="bg-transparent font-semibold text-text-main py-0.5 px-1 -my-1 hover:text-primary focus:outline-none focus:text-primary appearance-none pr-4" aria-label="LLM model" style={selectStyles} > From 6a8bf72e5f89072b18125f55798f0d3b2bfd6e4e Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:43:39 +0100 Subject: [PATCH 12/19] Darken tree selection color in light mode --- services/themeCustomization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/themeCustomization.ts b/services/themeCustomization.ts index 15d7033..8e4edb2 100644 --- a/services/themeCustomization.ts +++ b/services/themeCustomization.ts @@ -300,7 +300,7 @@ const BASE_PALETTES: Record = { modalBackdrop: '0 0 0 / 0.5', tooltipBg: '23 23 23', tooltipText: '245 245 245', - treeSelected: '212 212 212', + treeSelected: '180 180 180', selectArrowBackground: DEFAULT_LIGHT_SELECT_ARROW, }, dark: { From 1a877150cd354e7303a933c70e5ac6f2c64ac248 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:44:42 +0100 Subject: [PATCH 13/19] Disable editor/preview scroll sync Per user request, the side-by-side editor and preview panes now scroll independently instead of being synchronized. --- components/PromptEditor.tsx | 55 ++++++------------------------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/components/PromptEditor.tsx b/components/PromptEditor.tsx index 3dab0ea..fad7e68 100644 --- a/components/PromptEditor.tsx +++ b/components/PromptEditor.tsx @@ -427,54 +427,15 @@ const DocumentEditor: React.FC = ({ }; }, [handleGlobalMouseMove, handleGlobalMouseUp]); - // --- Scroll Synchronization Logic --- - const handleEditorScroll = useCallback((scrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number; }) => { - if (!viewMode.startsWith('split-') || isSyncing.current || !previewScrollRef.current) return; - - if (scrollInfo.scrollHeight <= scrollInfo.clientHeight) return; - - const percentage = scrollInfo.scrollTop / (scrollInfo.scrollHeight - scrollInfo.clientHeight); - - const previewEl = previewScrollRef.current; - if (previewEl.scrollHeight <= previewEl.clientHeight) return; - const newPreviewScrollTop = percentage * (previewEl.scrollHeight - previewEl.clientHeight); - - isSyncing.current = true; - previewEl.scrollTop = newPreviewScrollTop; - - if (syncTimeout.current) clearTimeout(syncTimeout.current); - syncTimeout.current = window.setTimeout(() => { - isSyncing.current = false; - }, 100); - }, [viewMode]); - - const handlePreviewScroll = useCallback((e: React.UIEvent) => { - if (!viewMode.startsWith('split-') || isSyncing.current) return; - - const editorHandle = editorEngine === 'monaco' ? editorRef.current : richTextEditorRef.current; - if (!editorHandle) return; - - const previewEl = e.currentTarget; - const { scrollTop, scrollHeight, clientHeight } = previewEl; - - if (scrollHeight <= clientHeight) return; - - const percentage = scrollTop / (scrollHeight - clientHeight); - - editorHandle.getScrollInfo().then(editorInfo => { - if (!isSyncing.current && editorInfo.scrollHeight > editorInfo.clientHeight) { - const newEditorScrollTop = percentage * (editorInfo.scrollHeight - editorInfo.clientHeight); - - isSyncing.current = true; - editorHandle.setScrollTop(newEditorScrollTop); + // --- Scroll Synchronization Logic (DISABLED) --- + // Scroll sync between editor and preview is disabled per user request. + const handleEditorScroll = useCallback((_scrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number; }) => { + // Scroll sync disabled - panes scroll independently + }, []); - if (syncTimeout.current) clearTimeout(syncTimeout.current); - syncTimeout.current = window.setTimeout(() => { - isSyncing.current = false; - }, 100); - } - }); - }, [viewMode, editorEngine]); + const handlePreviewScroll = useCallback((_e: React.UIEvent) => { + // Scroll sync disabled - panes scroll independently + }, []); From 3bd827b6faa95b3dbd56fb1c3ab33fd1d917fe11 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:49:38 +0100 Subject: [PATCH 14/19] Remove focus ring from zoom buttons --- components/StatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/StatusBar.tsx b/components/StatusBar.tsx index e787554..c5e7fd1 100644 --- a/components/StatusBar.tsx +++ b/components/StatusBar.tsx @@ -273,7 +273,7 @@ const StatusBar: React.FC = ({ backgroundSize: '1.2em 1.2em', }; - const zoomButtonClass = 'p-1 rounded-sm text-text-secondary hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed'; + const zoomButtonClass = 'p-1 text-text-secondary hover:text-text-main focus:outline-none focus:text-text-main disabled:opacity-50 disabled:cursor-not-allowed'; const isZoomDisabled = !isPreviewZoomAvailable; const isAtMinZoom = previewScale <= previewMinScale + 0.001; const isAtMaxZoom = previewScale >= previewMaxScale - 0.001; From 7e02f07a39e329d76510592020d7114530d96ca7 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:52:06 +0100 Subject: [PATCH 15/19] Fix MD preview zoom to reflow text when scaling Added useZoomProperty prop to ZoomPanContainer that uses CSS zoom instead of transform:scale(). This allows text to reflow when zooming, eliminating the whitespace issue. --- components/ZoomPanContainer.tsx | 21 +++++++++++++++++++-- services/preview/markdownRenderer.tsx | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/components/ZoomPanContainer.tsx b/components/ZoomPanContainer.tsx index 0fe62d6..cb258e3 100644 --- a/components/ZoomPanContainer.tsx +++ b/components/ZoomPanContainer.tsx @@ -17,6 +17,11 @@ interface ZoomPanContainerProps extends React.HTMLAttributes { wrapperClassName?: string; layout?: 'overlay' | 'natural'; lockOverflow?: boolean; + /** + * When true, uses CSS `zoom` property instead of `transform: scale()`. + * This allows text to reflow when zooming, but panning becomes unavailable. + */ + useZoomProperty?: boolean; } const clamp = (value: number, min: number, max: number) => { @@ -49,6 +54,7 @@ const ZoomPanContainer = React.forwardRef disableZoom = false, layout = 'overlay', lockOverflow = true, + useZoomProperty = false, ...rest } = props; @@ -208,11 +214,18 @@ const ZoomPanContainer = React.forwardRef }, [initialScale, previewZoom, setOffset, setScale]); const transformStyle = useMemo(() => { + // When useZoomProperty is true, use CSS zoom for text reflow + if (useZoomProperty) { + return { + zoom: scale, + } as React.CSSProperties; + } + // Otherwise use transform: scale() for panning support return { transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})`, transformOrigin: disablePan ? '0 0' : undefined, } as React.CSSProperties; - }, [disablePan, offset.x, offset.y, scale]); + }, [disablePan, offset.x, offset.y, scale, useZoomProperty]); const naturalWrapperStyle = useMemo(() => { if (layout !== 'natural' || !disablePan) { @@ -244,8 +257,12 @@ const ZoomPanContainer = React.forwardRef }, [className, disablePan, isPanning, lockOverflow]); const renderContent = useCallback(() => { + // When using CSS zoom, no transform-gpu needed + const contentClass = useZoomProperty + ? contentClassName ?? '' + : `transform-gpu origin-center ${contentClassName ?? ''}`; const content = ( -
      +
      {children}
      ); diff --git a/services/preview/markdownRenderer.tsx b/services/preview/markdownRenderer.tsx index 2aec369..f52d9c7 100644 --- a/services/preview/markdownRenderer.tsx +++ b/services/preview/markdownRenderer.tsx @@ -312,6 +312,7 @@ const MarkdownViewer = forwardRef(({ conten disablePan layout="natural" lockOverflow={false} + useZoomProperty className="min-h-full" wrapperClassName="df-markdown-shell" contentClassName="df-markdown-stage origin-top" From 5f1d7fe539ecc21584f35917dd99f343d70f9e49 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:52:40 +0100 Subject: [PATCH 16/19] Further darken tree selection color --- services/themeCustomization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/themeCustomization.ts b/services/themeCustomization.ts index 8e4edb2..cb92629 100644 --- a/services/themeCustomization.ts +++ b/services/themeCustomization.ts @@ -300,7 +300,7 @@ const BASE_PALETTES: Record = { modalBackdrop: '0 0 0 / 0.5', tooltipBg: '23 23 23', tooltipText: '245 245 245', - treeSelected: '180 180 180', + treeSelected: '150 150 150', selectArrowBackground: DEFAULT_LIGHT_SELECT_ARROW, }, dark: { From c0b3933701684f73f86b6ad88d5257be549ff4ea Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:57:40 +0100 Subject: [PATCH 17/19] Fix tree selection opacity - use full bg-tree-selected instead of /20 --- components/PromptTreeItem.tsx | 4 ++-- components/TemplateList.tsx | 4 ++-- services/themeCustomization.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/PromptTreeItem.tsx b/components/PromptTreeItem.tsx index 4892336..cc9744c 100644 --- a/components/PromptTreeItem.tsx +++ b/components/PromptTreeItem.tsx @@ -478,8 +478,8 @@ const DocumentTreeItem: React.FC = (props) => { minHeight: '22px', }} className={`w-full text-left pr-1 flex justify-between items-center transition-colors duration-0 text-[13px] relative focus:outline-none cursor-default ${isSelected - ? 'bg-tree-selected/20 text-text-main font-medium' - : 'hover:bg-tree-selected/10 text-text-secondary hover:text-text-main' + ? 'bg-tree-selected text-text-main font-medium' + : 'hover:bg-tree-selected/40 text-text-secondary hover:text-text-main' } ${isFocused ? 'ring-1 ring-inset ring-primary' : ''}`} >
      diff --git a/components/TemplateList.tsx b/components/TemplateList.tsx index b65fd82..a72622b 100644 --- a/components/TemplateList.tsx +++ b/components/TemplateList.tsx @@ -77,8 +77,8 @@ const TemplateList: React.FC = ({ templates, activeTemplateId className={`w-full text-left pr-1 flex justify-between items-center transition-colors duration-0 text-[13px] relative focus:outline-none h-[22px] min-h-[22px] cursor-default ${ // Fix: Use template_id activeTemplateId === template.template_id - ? 'bg-tree-selected/20 text-text-main font-medium' - : 'hover:bg-tree-selected/10 text-text-secondary hover:text-text-main' + ? 'bg-tree-selected text-text-main font-medium' + : 'hover:bg-tree-selected/40 text-text-secondary hover:text-text-main' } ${isFocused ? 'ring-1 ring-inset ring-primary' : ''}`} >
      diff --git a/services/themeCustomization.ts b/services/themeCustomization.ts index cb92629..b52cdda 100644 --- a/services/themeCustomization.ts +++ b/services/themeCustomization.ts @@ -300,7 +300,7 @@ const BASE_PALETTES: Record = { modalBackdrop: '0 0 0 / 0.5', tooltipBg: '23 23 23', tooltipText: '245 245 245', - treeSelected: '150 150 150', + treeSelected: '200 200 200', selectArrowBackground: DEFAULT_LIGHT_SELECT_ARROW, }, dark: { From 7a5c6ecc8dfd62aff2bdb15e4faaec7f95fcfa53 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 15:58:34 +0100 Subject: [PATCH 18/19] Fix MD zoom-in reflow with CSS zoom --- components/ZoomPanContainer.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/components/ZoomPanContainer.tsx b/components/ZoomPanContainer.tsx index cb258e3..72e7e4d 100644 --- a/components/ZoomPanContainer.tsx +++ b/components/ZoomPanContainer.tsx @@ -232,6 +232,17 @@ const ZoomPanContainer = React.forwardRef return undefined; } + // When using CSS zoom, the wrapper width needs inverse scaling + // so the zoomed content fills the available space properly + if (useZoomProperty) { + // At 100% zoom, width is 100%. Below 100%, width stays 100%. + // Above 100%, we don't need to adjust as CSS zoom handles expansion + return { + width: '100%', + minHeight: '100%', + }; + } + const visualScale = Math.max(1, scale); return { @@ -239,7 +250,7 @@ const ZoomPanContainer = React.forwardRef minWidth: `${visualScale * 100}%`, minHeight: `${visualScale * 100}%`, }; - }, [disablePan, layout, scale]); + }, [disablePan, layout, scale, useZoomProperty]); const containerClasses = useMemo(() => { const classes = ['relative', 'bg-secondary']; From 60e8fe7f3eed5a817d19e5961e28a2957921dcfd Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Fri, 19 Dec 2025 16:04:12 +0100 Subject: [PATCH 19/19] Prepare v0.8.2 release: Antigravity Styling Update - Applied Antigravity design language (sharper corners, reduced focus rings, flat design) - Fixed MD preview zoom to reflow text properly - Fixed tree selection visibility in light mode - Disabled scroll sync between editor and preview - Improved search box styling --- VERSION_LOG.md | 17 ++++++++++++++ docs/releases/0.8.2.md | 51 ++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 docs/releases/0.8.2.md diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 333a6ae..bf6cb9c 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -1,5 +1,22 @@ # Version Log +## v0.8.2 - The Antigravity Styling Update + +### ๐Ÿ›  Improvements + +- **Antigravity Design Language:** Applied a cleaner, VS Code-inspired design across the application: + - Sharper corners (`rounded-sm`) on buttons, modals, tooltips, and dropdowns. + - Reduced focus ring intensity for a more subtle, native appearance. + - Removed shadows from modals, tooltips, and context menus for a flatter look. + - Status bar controls (LLM selectors, database dropdown, zoom buttons) now use text-only hover/focus instead of visible rings. +- **Improved Markdown Preview Zoom:** Text now reflows properly when zooming in or out, eliminating whitespace issues at different zoom levels. +- **Better Tree Selection Visibility:** Fixed the tree item selection highlight in light mode to be clearly visible instead of nearly transparent. + +### ๐Ÿ› Fixes + +- Disabled the scroll synchronization between the code editor and Markdown preview panes, as the sync behavior was unreliable. Panes now scroll independently. +- Fixed search box styling to be more professional with a subtle background and no visible border. + ## v0.8.1 - The Native Fonts & Focus Fix Update ### ๐Ÿ›  Improvements diff --git a/docs/releases/0.8.2.md b/docs/releases/0.8.2.md new file mode 100644 index 0000000..de78df9 --- /dev/null +++ b/docs/releases/0.8.2.md @@ -0,0 +1,51 @@ +# v0.8.2 - The Antigravity Styling Update + +**Release Date:** 2025-12-19 + +## Summary + +This release introduces the Antigravity design languageโ€”a cleaner, VS Code-inspired aesthetic with sharper corners, reduced focus rings, and flatter UI elements. The Markdown preview zoom has been improved to properly reflow text, and tree selection visibility has been fixed in light mode. + +## Improvements + +### Antigravity Design Language + +Applied a comprehensive styling update across the application for a more professional, IDE-like appearance: + +- **Sharper corners:** Buttons, modals, tooltips, dropdowns, and cards now use `rounded-sm` instead of `rounded-md`/`rounded-lg`. +- **Reduced focus rings:** Focus indicators are now more subtle (1px, lower opacity) to avoid visual clutter. +- **Flatter design:** Removed shadows from modals, tooltips, context menus, and dropdowns, relying on borders for definition. +- **Status bar polish:** LLM provider/model selectors, database dropdown, and zoom buttons now use text-only hover/focus states instead of visible rings or backgrounds. + +### Markdown Preview Zoom + +Text now reflows properly when zooming in or out in the Markdown preview pane. This eliminates the whitespace issue that occurred when zooming out, where content would shrink but not reflow to use the available width. + +### Tree Selection Visibility + +Fixed the tree item selection highlight in light mode to be clearly visible. The previous implementation used 20% opacity on the selection color, making it nearly invisible. + +## Fixes + +- **Scroll sync disabled:** Removed the scroll synchronization between the code editor and Markdown preview panes, as the sync behavior was unreliable. The panes now scroll independently. +- **Search box styling:** Improved the sidebar search box with a subtle background, no visible border, and sharper corners for a more professional look. + +## Technical Changes + +### Files Modified + +- `components/Button.tsx` - Sharper corners, reduced focus ring +- `components/Modal.tsx` - Removed shadow, sharper corners +- `components/Tooltip.tsx` - Removed shadow, added border, sharper corners +- `components/IconButton.tsx` - Sharper corners, subtle hover +- `components/ToggleSwitch.tsx` - Reduced focus ring +- `components/LanguageDropdown.tsx` - Sharper corners, removed shadow +- `components/WelcomeScreen.tsx` - Sharper card corners +- `components/StatusBar.tsx` - Removed focus rings from selects and zoom buttons +- `components/Sidebar.tsx` - Improved search box styling +- `components/PromptTreeItem.tsx` - Fixed selection opacity +- `components/TemplateList.tsx` - Fixed selection opacity +- `components/ZoomPanContainer.tsx` - Added CSS zoom support for text reflow +- `components/PromptEditor.tsx` - Disabled scroll sync +- `services/preview/markdownRenderer.tsx` - Enabled CSS zoom for preview +- `services/themeCustomization.ts` - Adjusted tree selection color diff --git a/package.json b/package.json index 8cbeefd..00c9c41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docforge", - "version": "0.8.1", + "version": "0.8.2", "description": "An application to manage and refine documents.", "main": "dist/main.js", "scripts": {