Add support for full-only rating symbols like 🎥

- 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
This commit is contained in:
Filip Noetzel 2025-06-09 21:33:44 +02:00
parent 014446d672
commit 1218efd3aa
4 changed files with 221 additions and 64 deletions

View file

@ -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

View file

@ -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
});
}

View file

@ -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
*/

View file

@ -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 */
}