Compare commits

...

6 Commits

Author SHA1 Message Date
Gigi
8faa2e2de0 chore: bump version to 0.1.6 2025-10-05 04:17:44 +01:00
Gigi
07a5826774 refactor: extract components to keep files under 210 lines
- Extract ColorPicker component from Settings
- Extract FontSelector component from Settings
- Move hexToRgb helper to colorHelpers utils
- Export HIGHLIGHT_COLORS constant from colorHelpers
- Settings.tsx now 209 lines (was 242)
- ContentPanel.tsx now 197 lines (was 204)

Keeps code DRY and improves maintainability
2025-10-05 04:17:03 +01:00
Gigi
21d6916ae3 fix: ensure highlight color CSS variable inherits from parent
Remove local --highlight-rgb declarations that were preventing color inheritance in preview
2025-10-05 04:14:11 +01:00
Gigi
482ba9b2df style: make font size and color buttons match icon button size (33px) 2025-10-05 04:13:28 +01:00
Gigi
e4b6d1a122 feat: add configurable highlight colors
- Add highlightColor setting with 6 preset colors (yellow, orange, pink, green, blue, purple)
- Implement color picker UI with square color swatches
- Use CSS variables to dynamically apply highlight colors
- Add hex to RGB conversion for color transparency support
- Update both marker and underline styles to use selected color
2025-10-05 04:12:31 +01:00
Gigi
b59a295ad3 feat: add highlight style setting (marker & underline) 2025-10-05 04:08:58 +01:00
10 changed files with 245 additions and 55 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.5",
"version": "0.1.6",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -130,6 +130,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
highlightStyle={settings.highlightStyle || 'marker'}
highlightColor={settings.highlightColor || '#ffff00'}
onHighlightClick={(id) => {
setSelectedHighlightId(id)
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
interface ColorPickerProps {
selectedColor: string
onColorChange: (color: string) => void
}
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
return (
<div className="color-picker">
{HIGHLIGHT_COLORS.map(color => (
<button
key={color.value}
onClick={() => onColorChange(color.value)}
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={`${color.name} highlight color`}
/>
))}
</div>
)
}
export default ColorPicker

View File

@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
import { applyHighlightsToHTML } from '../utils/highlightMatching'
import { readingTime } from 'reading-time-estimator'
import { filterHighlightsByUrl } from '../utils/urlHelpers'
import { hexToRgb } from '../utils/colorHelpers'
interface ContentPanelProps {
loading: boolean
@@ -16,6 +17,8 @@ interface ContentPanelProps {
selectedUrl?: string
highlights?: Highlight[]
showUnderlines?: boolean
highlightStyle?: 'marker' | 'underline'
highlightColor?: string
onHighlightClick?: (highlightId: string) => void
selectedHighlightId?: string
}
@@ -28,6 +31,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
selectedUrl,
highlights = [],
showUnderlines = true,
highlightStyle = 'marker',
highlightColor = '#ffff00',
onHighlightClick,
selectedHighlightId
}) => {
@@ -38,7 +43,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark.content-highlight[data-highlight-id="${selectedHighlightId}"]`)
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -86,18 +91,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (!contentRef.current || !originalHtmlRef.current) return
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights)
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights, highlightStyle)
contentRef.current.innerHTML = highlightedHTML
})
return () => cancelAnimationFrame(rafId)
}, [relevantHighlights, html, markdown, showUnderlines])
}, [relevantHighlights, html, markdown, showUnderlines, highlightStyle])
// Attach click handlers separately (only when handler changes)
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
const marks = contentRef.current.querySelectorAll('mark[data-highlight-id]')
const handlers = new Map<Element, () => void>()
marks.forEach(mark => {
@@ -149,8 +154,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
}
const highlightRgb = hexToRgb(highlightColor)
return (
<div className="reader">
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>

View File

@@ -0,0 +1,38 @@
import React from 'react'
interface FontSelectorProps {
value: string
onChange: (font: string) => void
}
const FONTS = [
{ value: 'system', label: 'System Default', family: 'system-ui, -apple-system, sans-serif' },
{ value: 'inter', label: 'Inter', family: 'Inter, sans-serif' },
{ value: 'lora', label: 'Lora', family: 'Lora, serif' },
{ value: 'merriweather', label: 'Merriweather', family: 'Merriweather, serif' },
{ value: 'open-sans', label: 'Open Sans', family: 'Open Sans, sans-serif' },
{ value: 'roboto', label: 'Roboto', family: 'Roboto, sans-serif' },
{ value: 'source-serif-4', label: 'Source Serif 4', family: 'Source Serif 4, serif' },
{ value: 'crimson-text', label: 'Crimson Text', family: 'Crimson Text, serif' },
{ value: 'libre-baskerville', label: 'Libre Baskerville', family: 'Libre Baskerville, serif' },
{ value: 'pt-serif', label: 'PT Serif', family: 'PT Serif, serif' }
]
const FontSelector: React.FC<FontSelectorProps> = ({ value, onChange }) => {
return (
<select
id="readingFont"
value={value || 'system'}
onChange={(e) => onChange(e.target.value)}
className="setting-select font-select"
>
{FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.family }}>
{font.label}
</option>
))}
</select>
)
}
export default FontSelector

View File

@@ -1,8 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import ColorPicker from './ColorPicker'
import FontSelector from './FontSelector'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { hexToRgb } from '../utils/colorHelpers'
interface SettingsProps {
settings: UserSettings
@@ -62,23 +65,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<select
id="readingFont"
<FontSelector
value={localSettings.readingFont || 'system'}
onChange={(e) => setLocalSettings({ ...localSettings, readingFont: e.target.value })}
className="setting-select font-select"
>
<option value="system" style={{ fontFamily: 'system-ui, -apple-system, sans-serif' }}>System Default</option>
<option value="inter" style={{ fontFamily: 'Inter, sans-serif' }}>Inter</option>
<option value="lora" style={{ fontFamily: 'Lora, serif' }}>Lora</option>
<option value="merriweather" style={{ fontFamily: 'Merriweather, serif' }}>Merriweather</option>
<option value="open-sans" style={{ fontFamily: 'Open Sans, sans-serif' }}>Open Sans</option>
<option value="roboto" style={{ fontFamily: 'Roboto, sans-serif' }}>Roboto</option>
<option value="source-serif-4" style={{ fontFamily: 'Source Serif 4, serif' }}>Source Serif 4</option>
<option value="crimson-text" style={{ fontFamily: 'Crimson Text, serif' }}>Crimson Text</option>
<option value="libre-baskerville" style={{ fontFamily: 'Libre Baskerville, serif' }}>Libre Baskerville</option>
<option value="pt-serif" style={{ fontFamily: 'PT Serif, serif' }}>PT Serif</option>
</select>
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
/>
</div>
<div className="setting-group setting-inline">
@@ -111,17 +101,46 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
</label>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
<IconButton
icon={faHighlighter}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'marker' })}
title="Text marker style"
ariaLabel="Text marker style"
variant={(localSettings.highlightStyle || 'marker') === 'marker' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUnderline}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'underline' })}
title="Underline style"
ariaLabel="Underline style"
variant={localSettings.highlightStyle === 'underline' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label>Highlight Color</label>
<ColorPicker
selectedColor={localSettings.highlightColor || '#ffff00'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
/>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${localSettings.fontSize || 16}px`
}}
fontSize: `${localSettings.fontSize || 16}px`,
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? "content-highlight" : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'}` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
</div>
</div>

View File

@@ -1402,60 +1402,102 @@ body {
}
/* Inline content highlights - fluorescent marker style */
.content-highlight {
background: rgba(255, 255, 0, 0.35);
.content-highlight,
.content-highlight-marker {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35);
padding: 0.125rem 0.25rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
border-radius: 2px;
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
}
.content-highlight:hover {
background: rgba(255, 255, 0, 0.5);
box-shadow: 0 0 12px rgba(255, 255, 0, 0.3);
.content-highlight:hover,
.content-highlight-marker:hover {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5);
box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3);
}
.content-highlight.highlight-pulse {
/* Underline style for highlights */
.content-highlight-underline {
background: transparent;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
text-decoration: underline;
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
.content-highlight-underline:hover {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
text-decoration-thickness: 3px;
}
.content-highlight.highlight-pulse,
.content-highlight-marker.highlight-pulse,
.content-highlight-underline.highlight-pulse {
animation: highlight-pulse-animation 1.5s ease-in-out;
}
@keyframes highlight-pulse-animation {
0%, 100% {
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
transform: scale(1);
}
25% {
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
transform: scale(1.02);
}
50% {
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
transform: scale(1);
}
75% {
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
transform: scale(1.02);
}
}
.reader-html .content-highlight,
.reader-markdown .content-highlight {
.reader-markdown .content-highlight,
.reader-html .content-highlight-marker,
.reader-markdown .content-highlight-marker,
.reader-html .content-highlight-underline,
.reader-markdown .content-highlight-underline {
color: inherit;
}
.reader-html .content-highlight,
.reader-markdown .content-highlight,
.reader-html .content-highlight-marker,
.reader-markdown .content-highlight-marker {
text-decoration: none;
}
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight {
background: rgba(255, 255, 0, 0.4);
box-shadow: 0 0 6px rgba(255, 255, 0, 0.15);
.content-highlight,
.content-highlight-marker {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4);
box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15);
}
.content-highlight:hover {
background: rgba(255, 255, 0, 0.55);
box-shadow: 0 0 10px rgba(255, 255, 0, 0.25);
.content-highlight:hover,
.content-highlight-marker:hover {
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55);
box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25);
}
.content-highlight-underline {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9);
}
.content-highlight-underline:hover {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
}
.highlight-indicator {
@@ -1545,13 +1587,50 @@ body {
gap: 0.5rem;
}
.color-picker {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-swatch {
width: 33px;
height: 33px;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.color-swatch:hover {
border-color: #888;
}
.color-swatch.active {
border-color: #646cff;
box-shadow: 0 0 0 2px #646cff;
}
.color-swatch.active::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #000;
font-size: 0.875rem;
font-weight: bold;
text-shadow: 0 0 2px #fff;
}
.font-size-btn {
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
min-width: 33px;
height: 33px;
padding: 0;
background: transparent;
border: 1px solid #444;
border-radius: 4px;
border-radius: 6px;
color: #ccc;
cursor: pointer;
transition: all 0.2s;

View File

@@ -16,6 +16,8 @@ export interface UserSettings {
highlightsCollapsed?: boolean
readingFont?: string
fontSize?: number
highlightStyle?: 'marker' | 'underline'
highlightColor?: string
}
export async function loadSettings(

16
src/utils/colorHelpers.ts Normal file
View File

@@ -0,0 +1,16 @@
// Helper to convert hex color to RGB values
export function hexToRgb(hex: string): string {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
: '255, 255, 0'
}
export const HIGHLIGHT_COLORS = [
{ name: 'Yellow', value: '#ffff00' },
{ name: 'Orange', value: '#ff9500' },
{ name: 'Pink', value: '#ff69b4' },
{ name: 'Green', value: '#00ff7f' },
{ name: 'Blue', value: '#4da6ff' },
{ name: 'Purple', value: '#b19cd9' }
]

View File

@@ -99,9 +99,9 @@ export function applyHighlightsToText(
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string): HTMLElement {
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
const mark = document.createElement('mark')
mark.className = 'content-highlight'
mark.className = `content-highlight-${highlightStyle}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
@@ -127,7 +127,8 @@ function tryMarkInTextNodes(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean
useNormalized: boolean,
highlightStyle: 'marker' | 'underline' = 'marker'
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
@@ -154,7 +155,7 @@ function tryMarkInTextNodes(
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
const mark = createMarkElement(highlight, match)
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
return true
@@ -166,7 +167,7 @@ function tryMarkInTextNodes(
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(html: string, highlights: Highlight[]): string {
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
if (!html || highlights.length === 0) return html
const tempDiv = document.createElement('div')
@@ -183,8 +184,8 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[]): st
while ((node = walker.nextNode())) textNodes.push(node as Text)
// Try exact match first, then normalized match
tryMarkInTextNodes(textNodes, searchText, highlight, false) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true)
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
}
return tempDiv.innerHTML