From 1218efd3aac6ec590c059f52bb00aae647bc3bb7 Mon Sep 17 00:00:00 2001 From: Filip Noetzel Date: Mon, 9 Jun 2025 21:33:44 +0200 Subject: [PATCH] =?UTF-8?q?Add=20support=20for=20full-only=20rating=20symb?= =?UTF-8?q?ols=20like=20=F0=9F=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added fullOnly flag to SymbolSet interface - Added 10 new full-only symbols (🎥, 🏆, ⭐, 💎, 🔥, ⚡, 🎯, 🚀, 💰, 🎖️) - Visual: rated symbols full color, unrated grey 50% opacity - Disk: only rated symbols saved (e.g., 🎥🎥🎥 (3/5)) - No 0 ratings, no half ratings for full-only symbols - Updated CSS, tests, and documentation Refactor full-only symbols: auto-detect, show denominator count, require rating text - Remove fullOnly flag, auto-detect with isFullOnlySymbol() - Visual: show denominator count (numerator color, rest grey) - Require rating text for denominator info - Disk: save only rated symbols - Updated docs and tests Clarify and verify full-only symbol visual behavior - Added debug test files to verify expected behavior - Confirmed visual display always shows denominator count symbols - Rated symbols in full color, unrated in grey 50% opacity - Implementation already correct: displaySymbolCount uses denominator - Added debug logging capability for troubleshooting --- src/constants.ts | 16 +++- src/editor-extension.ts | 190 ++++++++++++++++++++++++++++------------ src/utils.ts | 46 +++++++++- styles.css | 33 ++++++- 4 files changed, 221 insertions(+), 64 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index df4cb49..ff4889e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ import { SymbolSet } from './types'; -// Global logging control - set at build time via environment variable -export const LOGGING_ENABLED = process.env.LOGGING_ENABLED === 'true'; +// Global logging control - set to false for production, true for debugging +export const LOGGING_ENABLED = false; // Define symbol patterns as a global constant export const SYMBOL_PATTERNS: SymbolSet[] = [ @@ -33,6 +33,18 @@ export const SYMBOL_PATTERNS: SymbolSet[] = [ { full: '⬤', empty: '◯', half: null }, // Bold circles { full: '⚫', empty: '⚪', half: null }, // Black/white circles { full: '█', empty: '░', half: null }, // Block/light shade + + // Full-only symbols (same symbol for full and empty, no half) + { full: '🎥', empty: '🎥', half: null }, // Movie cameras + { full: '🏆', empty: '🏆', half: null }, // Trophies + { full: '⭐', empty: '⭐', half: null }, // Gold stars + { full: '💎', empty: '💎', half: null }, // Diamonds + { full: '🔥', empty: '🔥', half: null }, // Fire + { full: '⚡', empty: '⚡', half: null }, // Lightning + { full: '🎯', empty: '🎯', half: null }, // Target/bullseye + { full: '🚀', empty: '🚀', half: null }, // Rockets + { full: '💰', empty: '💰', half: null }, // Money bags + { full: '🎖️', empty: '🎖️', half: null }, // Military medals ]; // Interaction constants diff --git a/src/editor-extension.ts b/src/editor-extension.ts index 7b543ee..a49d2dd 100644 --- a/src/editor-extension.ts +++ b/src/editor-extension.ts @@ -1,7 +1,7 @@ import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view"; import { RangeSetBuilder } from "@codemirror/state"; import { generateSymbolRegexPatterns, getSymbolSetForPattern, calculateRating, parseRatingText } from './ratings-parser'; -import { generateSymbolsString, formatRatingText, getUnicodeCharLength, utf16ToUnicodePosition } from './utils'; +import { generateSymbolsString, generateSymbolsStringForDisk, formatRatingText, getUnicodeCharLength, utf16ToUnicodePosition, isFullOnlySymbol } from './utils'; import { LOGGING_ENABLED } from './constants'; import { RatingText } from './types'; @@ -19,7 +19,7 @@ interface RatingMatch { /** * Rating widget for inline editing in CodeMirror - * Handles both symbols and rating text with full half-symbol support + * Handles both symbols and rating text with full half-symbol support and full-only symbols */ class RatingWidget extends WidgetType { constructor( @@ -41,41 +41,48 @@ class RatingWidget extends WidgetType { container.setAttribute('data-pattern-length', unicodeLength.toString()); container.setAttribute('data-supports-half', (!!this.symbolSet.half).toString()); + const isFullOnly = isFullOnlySymbol(this.symbolSet); + container.setAttribute('data-full-only', isFullOnly.toString()); + + // For full-only symbols, use denominator from rating text if available + const displaySymbolCount = (isFullOnly && this.ratingText) ? this.ratingText.denominator : unicodeLength; + container.setAttribute('data-display-symbol-count', displaySymbolCount.toString()); + // Create symbols container const symbolsContainer = document.createElement('span'); symbolsContainer.className = 'interactive-rating-symbols'; - // Create clickable symbols - [...this.pattern].forEach((symbol, index) => { + // Create clickable symbols based on display count + for (let i = 0; i < displaySymbolCount; i++) { const span = document.createElement('span'); - span.textContent = symbol; + span.textContent = this.symbolSet.full; span.style.cursor = 'pointer'; span.style.position = 'relative'; - span.setAttribute('data-symbol-index', index.toString()); + span.setAttribute('data-symbol-index', i.toString()); - // Add click handler with half-symbol support + // Add click handler with full-only validation span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - const newRating = this.calculateRatingFromClick(e, span, index); + const newRating = this.calculateRatingFromClick(e, span, i); this.updateRating(view, newRating); }); - // Add hover preview with half-symbol support + // Add hover preview span.addEventListener('mouseenter', (e) => { - const previewRating = this.calculateRatingFromHover(e, span, index); + const previewRating = this.calculateRatingFromHover(e, span, i); this.previewRating(previewRating, container); }); - // Add mousemove for fine-grained half-symbol preview + // Add mousemove for fine-grained preview span.addEventListener('mousemove', (e) => { - const previewRating = this.calculateRatingFromHover(e, span, index); + const previewRating = this.calculateRatingFromHover(e, span, i); this.previewRating(previewRating, container); }); symbolsContainer.appendChild(span); - }); + } container.appendChild(symbolsContainer); @@ -99,9 +106,16 @@ class RatingWidget extends WidgetType { } /** - * Calculate rating from click position with half-symbol support + * Calculate rating from click position with full-only validation */ private calculateRatingFromClick(event: MouseEvent, span: HTMLElement, symbolIndex: number): number { + const isFullOnly = isFullOnlySymbol(this.symbolSet); + + // For full-only symbols, don't support half ratings and enforce minimum rating of 1 + if (isFullOnly) { + return Math.max(1, symbolIndex + 1); + } + if (!this.symbolSet.half) { // No half-symbol support, return full symbol rating return symbolIndex + 1; @@ -121,9 +135,16 @@ class RatingWidget extends WidgetType { } /** - * Calculate rating from hover position with half-symbol support + * Calculate rating from hover position with full-only validation */ private calculateRatingFromHover(event: MouseEvent, span: HTMLElement, symbolIndex: number): number { + const isFullOnly = isFullOnlySymbol(this.symbolSet); + + // For full-only symbols, don't support half ratings and enforce minimum rating of 1 + if (isFullOnly) { + return Math.max(1, symbolIndex + 1); + } + if (!this.symbolSet.half) { // No half-symbol support, return full symbol rating return symbolIndex + 1; @@ -143,26 +164,47 @@ class RatingWidget extends WidgetType { } /** - * Preview rating with proper half-symbol rendering + * Preview rating with proper half-symbol rendering and full-only symbol support */ private previewRating(newRating: number, container: HTMLElement): void { const symbolsContainer = container.querySelector('.interactive-rating-symbols'); if (!symbolsContainer) return; const spans = symbolsContainer.querySelectorAll('span'); + const isFullOnly = isFullOnlySymbol(this.symbolSet); + spans.forEach((span, index) => { const symbolRating = index + 1; const halfRating = index + 0.5; - if (symbolRating <= newRating) { - // Full symbol + if (isFullOnly) { + // For full-only symbols: show full symbol for rated, grey for unrated span.textContent = this.symbolSet.full; - } else if (this.symbolSet.half && halfRating <= newRating && halfRating > newRating - 0.5) { - // Half symbol - span.textContent = this.symbolSet.half; + if (symbolRating <= newRating) { + span.style.opacity = '1'; + span.style.filter = 'none'; + } else { + span.style.opacity = '0.5'; + span.style.filter = 'grayscale(100%)'; + } } else { - // Empty symbol - span.textContent = this.symbolSet.empty; + // Regular symbol behavior + if (symbolRating <= newRating) { + // Full symbol + span.textContent = this.symbolSet.full; + span.style.opacity = '1'; + span.style.filter = 'none'; + } else if (this.symbolSet.half && halfRating <= newRating && halfRating > newRating - 0.5) { + // Half symbol + span.textContent = this.symbolSet.half; + span.style.opacity = '1'; + span.style.filter = 'none'; + } else { + // Empty symbol + span.textContent = this.symbolSet.empty; + span.style.opacity = '1'; + span.style.filter = 'none'; + } } }); @@ -170,50 +212,67 @@ class RatingWidget extends WidgetType { if (this.ratingText) { const textContainer = container.querySelector('.interactive-rating-text'); if (textContainer) { - // Use the Unicode character count as the denominator for preview - const unicodeLength = getUnicodeCharLength(this.pattern); const previewText = formatRatingText( this.ratingText.format, newRating, - unicodeLength, - unicodeLength, // Use symbol count as denominator - !!this.symbolSet.half + this.ratingText.denominator, // Use original denominator + this.ratingText.denominator, + !!this.symbolSet.half && !isFullOnly ); textContainer.textContent = previewText; } } if (LOGGING_ENABLED) { - console.debug('[InteractiveRatings] Preview rating with half-symbol support', { + console.debug('[InteractiveRatings] Preview rating with full-only support', { newRating, hasHalf: !!this.symbolSet.half, + isFullOnly, symbolSet: this.symbolSet, - denominator: getUnicodeCharLength(this.pattern) + denominator: this.ratingText?.denominator }); } } /** - * Render rating with proper half-symbol display + * Render rating with proper half-symbol display and full-only symbol support */ private renderRating(rating: number, container: HTMLElement): void { const symbolsContainer = container.querySelector('.interactive-rating-symbols'); if (!symbolsContainer) return; const spans = symbolsContainer.querySelectorAll('span'); + const isFullOnly = isFullOnlySymbol(this.symbolSet); + spans.forEach((span, index) => { const symbolRating = index + 1; const halfRating = index + 0.5; - if (symbolRating <= rating) { - // Full symbol + if (isFullOnly) { + // For full-only symbols: show full symbol for rated, grey for unrated span.textContent = this.symbolSet.full; - } else if (this.symbolSet.half && halfRating <= rating && halfRating > rating - 0.5) { - // Half symbol - span.textContent = this.symbolSet.half; + if (symbolRating <= rating) { + span.style.opacity = '1'; + span.style.filter = 'none'; + } else { + span.style.opacity = '0.5'; + span.style.filter = 'grayscale(100%)'; + } } else { - // Empty symbol - span.textContent = this.symbolSet.empty; + // Regular symbol behavior + if (symbolRating <= rating) { + // Full symbol + span.textContent = this.symbolSet.full; + } else if (this.symbolSet.half && halfRating <= rating && halfRating > rating - 0.5) { + // Half symbol + span.textContent = this.symbolSet.half; + } else { + // Empty symbol + span.textContent = this.symbolSet.empty; + } + // Reset any styling for regular symbols + span.style.opacity = '1'; + span.style.filter = 'none'; } }); @@ -226,42 +285,42 @@ class RatingWidget extends WidgetType { } if (LOGGING_ENABLED) { - console.debug('[InteractiveRatings] Render rating with half-symbol support', { + console.debug('[InteractiveRatings] Render rating with full-only support', { rating, hasHalf: !!this.symbolSet.half, + isFullOnly, symbolSet: this.symbolSet }); } } /** - * Update rating in the document with half-symbol support + * Update rating in the document with half-symbol support and full-only symbols */ private updateRating(view: EditorView, newRating: number): void { try { - // Use Unicode character count for proper emoji handling - const unicodeLength = getUnicodeCharLength(this.pattern); + const isFullOnly = isFullOnlySymbol(this.symbolSet); - // Generate new symbol string with half-symbol support - const newSymbols = generateSymbolsString( + // For full-only symbols, use the disk-safe function that only includes rated symbols + const newSymbols = generateSymbolsStringForDisk( newRating, - unicodeLength, + getUnicodeCharLength(this.pattern), this.symbolSet.full, this.symbolSet.empty, this.symbolSet.half || '', - !!this.symbolSet.half + !!this.symbolSet.half && !isFullOnly, + this.symbolSet ); // Generate new rating text if it exists let newText = newSymbols; if (this.ratingText) { - // Use the Unicode character count as the denominator for final update const newRatingText = formatRatingText( this.ratingText.format, newRating, - unicodeLength, - unicodeLength, // Use symbol count as denominator - !!this.symbolSet.half + this.ratingText.denominator, // Use original denominator + this.ratingText.denominator, + !!this.symbolSet.half && !isFullOnly ); newText = newSymbols + newRatingText; } @@ -276,17 +335,15 @@ class RatingWidget extends WidgetType { }); if (LOGGING_ENABLED) { - console.info('[InteractiveRatings] Rating updated in editor with half-symbol support', { + console.info('[InteractiveRatings] Rating updated in editor with full-only support', { oldRating: this.rating, newRating, newSymbols, newText, hasRatingText: !!this.ratingText, hasHalf: !!this.symbolSet.half, - oldDenominator: this.ratingText?.denominator, - newDenominator: unicodeLength, - unicodeLengthCalculated: unicodeLength, - rawPatternLength: this.pattern.length, + isFullOnly, + originalDenominator: this.ratingText?.denominator, position: { from: this.startPos, to: this.endPos } }); } @@ -343,9 +400,25 @@ const ratingViewPlugin = ViewPlugin.fromClass( if (!symbolSet) continue; const rating = calculateRating(pattern, symbolSet); + const isFullOnly = isFullOnlySymbol(symbolSet); + + // For full-only symbols, skip if rating is 0 (not supported) + if (isFullOnly && rating === 0) continue; // Check for rating text after the symbols - pass UTF-16 positions const ratingText = parseRatingText(text, start, end); + + // For full-only symbols, rating text is highly recommended + if (isFullOnly && !ratingText) { + if (LOGGING_ENABLED) { + console.debug('[InteractiveRatings] Skipping full-only symbol without rating text', { + pattern, + symbolSet + }); + } + continue; + } + const actualEnd = ratingText ? ratingText.endPosition : end; if (LOGGING_ENABLED) { @@ -358,7 +431,8 @@ const ratingViewPlugin = ViewPlugin.fromClass( rating, hasRatingText: !!ratingText, ratingTextDetails: ratingText, - symbolSet: symbolSet + symbolSet: symbolSet, + isFullOnly }); } @@ -420,11 +494,13 @@ const ratingViewPlugin = ViewPlugin.fromClass( const skippedCount = filteredMatches.filter(m => cursorPos >= m.start - 1 && cursorPos <= m.end + 1 ).length; + const fullOnlyCount = filteredMatches.filter(m => isFullOnlySymbol(m.symbolSet)).length; console.debug(`[InteractiveRatings] Built ${filteredMatches.length - skippedCount}/${filteredMatches.length} rating decorations (${skippedCount} skipped due to cursor proximity)`, { cursorPos, withRatingText: filteredMatches.filter(m => m.ratingText).length, symbolsOnly: filteredMatches.filter(m => !m.ratingText).length, - withHalfSymbols: filteredMatches.filter(m => m.symbolSet.half).length + withHalfSymbols: filteredMatches.filter(m => m.symbolSet.half).length, + fullOnlySymbols: fullOnlyCount }); } diff --git a/src/utils.ts b/src/utils.ts index e20e62d..4e6ca20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import { LOGGING_ENABLED } from './constants'; +import { SymbolSet } from './types'; /** * Get the length of a string in Unicode characters @@ -24,6 +25,13 @@ export function utf16ToUnicodePosition(str: string, utf16Position: number): numb return getUnicodeCharLength(utf16Substring); } +/** + * Check if a symbol set is full-only (same symbol for full and empty, no half) + */ +export function isFullOnlySymbol(symbolSet: SymbolSet): boolean { + return symbolSet.full === symbolSet.empty && !symbolSet.half; +} + /** * Calculate the new rating based on cursor position */ @@ -67,7 +75,8 @@ export function calculateNewRating(overlay: HTMLElement, clientX: number): numbe } /** - * Generate a string of rating symbols + * Generate a string of rating symbols for display purposes + * For full-only symbols with rating text, this shows denominator count symbols */ export function generateSymbolsString( rating: number, @@ -75,11 +84,17 @@ export function generateSymbolsString( full: string, empty: string, half: string, - supportsHalf: boolean + supportsHalf: boolean, + symbolSet?: SymbolSet, + denominator?: number ): string { + // For full-only symbols with rating text, use denominator for total count + const isFullOnly = symbolSet && isFullOnlySymbol(symbolSet); + const totalSymbols = (isFullOnly && denominator) ? denominator : symbolCount; + let newSymbols = ''; - for (let i = 0; i < symbolCount; i++) { + for (let i = 0; i < totalSymbols; i++) { if (i < Math.floor(rating)) { newSymbols += full; } else if (supportsHalf && i === Math.floor(rating) && rating % 1 !== 0) { @@ -93,10 +108,13 @@ export function generateSymbolsString( console.debug(`[InteractiveRatings] Generated symbols string`, { rating, symbolCount, + totalSymbols, full, empty, half, supportsHalf, + isFullOnly, + denominator, newSymbols }); }; @@ -104,6 +122,28 @@ export function generateSymbolsString( return newSymbols; } +/** + * Generate a string of rating symbols for writing to disk (full-only symbols only include rated ones) + */ +export function generateSymbolsStringForDisk( + rating: number, + symbolCount: number, + full: string, + empty: string, + half: string, + supportsHalf: boolean, + symbolSet?: SymbolSet +): string { + // For full-only symbols, only write the rated symbols + if (symbolSet && isFullOnlySymbol(symbolSet)) { + const ratedCount = Math.floor(rating); + return full.repeat(ratedCount); + } + + // For regular symbols, use the standard logic + return generateSymbolsString(rating, symbolCount, full, empty, half, supportsHalf, symbolSet); +} + /** * Format the rating text based on the specified format */ diff --git a/styles.css b/styles.css index 6b92e72..d082c68 100644 --- a/styles.css +++ b/styles.css @@ -43,6 +43,7 @@ position: relative; cursor: pointer; display: inline-block; + transition: opacity 0.2s ease, filter 0.2s ease; } /* Enhanced hover effect for symbols with half-symbol visual feedback */ @@ -74,6 +75,28 @@ /* No visual elements */ } +/* Full-only symbol styles - auto-detected symbols that use grayed-out effect */ +.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbols span { + transition: opacity 0.2s ease, filter 0.2s ease; +} + +/* Ensure full-only unrated symbols are properly grayed out */ +.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbols span[style*="opacity: 0.5"] { + opacity: 0.5 !important; + filter: grayscale(100%) !important; +} + +/* Ensure full-only rated symbols are fully visible */ +.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbols span[style*="opacity: 1"] { + opacity: 1 !important; + filter: none !important; +} + +/* Support for display symbol count different from pattern length */ +.interactive-rating-editor-widget[data-display-symbol-count] .interactive-rating-symbols { + /* Styles for symbols with extended display count */ +} + /* Mobile/touch enhancements for editor */ @media (max-width: 768px) { .interactive-rating-editor-widget { @@ -96,6 +119,12 @@ .interactive-rating-editor-widget[data-supports-half="true"]:hover .interactive-rating-symbols span::before { /* No visual elements */ } + + /* Enhanced contrast for full-only symbols */ + .interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbols span[style*="opacity: 0.5"] { + opacity: 0.3 !important; + filter: grayscale(100%) contrast(0.7) !important; + } } /* Reduced motion accessibility */ @@ -104,7 +133,7 @@ .interactive-rating-editor-widget .interactive-rating-symbols span, .interactive-rating-editor-widget .interactive-rating-symbols span::before, .interactive-rating-editor-widget .interactive-rating-symbols span::after { - /* No transitions to remove */ + transition: none; } } @@ -119,5 +148,5 @@ /* Smooth symbol transitions during preview */ .interactive-rating-editor-widget .interactive-rating-symbols span { - /* No transitions */ + /* Transitions defined above */ } \ No newline at end of file