Add CodeMirror editor extension to make ratings interactive in editing mode

This commit is contained in:
Filip Noetzel 2025-06-06 12:37:38 +02:00
parent cedf7c01da
commit a8b30bea88
11 changed files with 1047 additions and 903 deletions

View file

@ -27,7 +27,34 @@ const buildOptions = {
define: {
'process.env.LOGGING_ENABLED': JSON.stringify(loggingEnabled)
},
external: ['obsidian'],
external: [
'obsidian',
'electron',
'codemirror',
'@codemirror/autocomplete',
'@codemirror/closebrackets',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/comment',
'@codemirror/fold',
'@codemirror/gutter',
'@codemirror/highlight',
'@codemirror/history',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/matchbrackets',
'@codemirror/panel',
'@codemirror/rangeset',
'@codemirror/rectangular-selection',
'@codemirror/search',
'@codemirror/state',
'@codemirror/stream-parser',
'@codemirror/text',
'@codemirror/tooltip',
'@codemirror/view',
'@lezer/common',
'@lezer/lr'
],
logLevel: 'info',
minify: false,
treeShaking: true,

View file

@ -1,153 +1,28 @@
import { App, MarkdownView, Plugin } from 'obsidian';
import { Plugin } from 'obsidian';
import { LOGGING_ENABLED } from './constants';
import { handleEditorInteraction, handlePointerMove, handlePointerUp, isInSourceMode } from './event-handlers';
import { calculateNewRating } from './utils';
import { adaptEvent, ExtendedEditor } from './types';
import { addInteractionListeners, applyRatingUpdate, createEditorOverlay, removeRatingsOverlay, updateOverlayDisplay } from './ratings-overlay';
import { processRatings } from './markdown-postprocessor';
import { ratingEditorExtension } from './editor-extension';
export class InteractiveRatingsPlugin extends Plugin {
app: App;
ratingsOverlay: HTMLElement | null;
async onload() {
async onload(): Promise<void> {
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Plugin loading`);
console.info('[InteractiveRatings] Plugin loading with inline ratings system');
}
// Use pointer events for all interactions
this.registerDomEvent(document, 'pointermove', (evt: PointerEvent) => {
const { isSourceMode, editor } = isInSourceMode(this.app);
// First handle potential rating detection
if (isSourceMode && editor) {
this.handleInteraction(evt, editor);
}
// Then handle overlay movement if it exists
handlePointerMove(evt, this.ratingsOverlay, this.updateOverlayDisplay.bind(this));
});
// Register editor extension for interactive ratings in editing mode
this.registerEditorExtension(ratingEditorExtension);
// Add pointer down for initial detection and pointer capture
this.registerDomEvent(document, 'pointerdown', (evt: PointerEvent) => {
const { isSourceMode, editor } = isInSourceMode(this.app);
if (isSourceMode && editor) {
this.handleInteraction(evt, editor);
}
// If we have an overlay and the event is within it, capture the pointer
if (this.ratingsOverlay && evt.target instanceof HTMLElement) {
const rect = this.ratingsOverlay.getBoundingClientRect();
if (
evt.clientX >= rect.left &&
evt.clientX <= rect.right &&
evt.clientY >= rect.top &&
evt.clientY <= rect.bottom
) {
try {
this.ratingsOverlay.setPointerCapture(evt.pointerId);
} catch (e) {
// Ignore errors with pointer capture
}
}
}
});
// Pointer up to finalize the selection
this.registerDomEvent(document, 'pointerup', (evt: PointerEvent) => {
handlePointerUp(
evt,
this.ratingsOverlay,
this.applyRatingUpdate.bind(this),
this.removeRatingsOverlay.bind(this)
);
});
// Also handle pointer cancel to clean up
this.registerDomEvent(document, 'pointercancel', (evt: PointerEvent) => {
this.removeRatingsOverlay();
});
// Keep markdown postprocessor for reading mode (optional - can be disabled if desired)
this.registerMarkdownPostProcessor(processRatings);
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Plugin loaded successfully`);
console.info('[InteractiveRatings] Plugin loaded successfully');
}
}
/**
* Unified handler for all interactions to detect rating patterns
*/
handleInteraction(event: PointerEvent, editor: ExtendedEditor): void {
handleEditorInteraction(
adaptEvent(event),
editor,
this.ratingsOverlay,
this.removeRatingsOverlay.bind(this),
this.createEditorOverlay.bind(this)
);
onunload(): void {
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Plugin unloaded');
}
}
/**
* Create an editor overlay for ratings interaction
*/
createEditorOverlay(
editor: ExtendedEditor,
line: number,
start: number,
pattern: string,
originalRating: number,
symbolSet: any,
ratingText: any
): void {
this.ratingsOverlay = createEditorOverlay(
editor,
line,
start,
pattern,
originalRating,
symbolSet,
ratingText,
this.addInteractionListeners.bind(this)
);
}
/**
* Update the overlay display based on user interaction
*/
updateOverlayDisplay(overlay: HTMLElement, rating: number): void {
updateOverlayDisplay(overlay, rating);
}
/**
* Remove the ratings overlay
*/
removeRatingsOverlay(): void {
this.ratingsOverlay = removeRatingsOverlay(this.ratingsOverlay);
}
/**
* Apply the rating update to the document
*/
applyRatingUpdate(rating: number): void {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
const editor = markdownView?.editor as ExtendedEditor;
if (!editor || !this.ratingsOverlay) return;
applyRatingUpdate(editor, this.ratingsOverlay, rating);
}
/**
* Add interaction listeners to the overlay
*/
addInteractionListeners(container: HTMLElement): void {
addInteractionListeners(
container,
this.applyRatingUpdate.bind(this),
this.removeRatingsOverlay.bind(this)
);
}
onunload() {
const styleEl = document.getElementById('interactive-ratings-style');
if (styleEl) styleEl.remove();
this.removeRatingsOverlay();
}
}
}

301
src/editor-extension.ts Normal file
View file

@ -0,0 +1,301 @@
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 } from './utils';
import { LOGGING_ENABLED } from './constants';
import { RatingText } from './types';
/**
* Interface for rating match data with optional rating text
*/
interface RatingMatch {
pattern: string;
start: number;
end: number;
symbolSet: any;
rating: number;
ratingText?: RatingText | null;
}
/**
* Rating widget for inline editing in CodeMirror
* Handles both symbols and rating text
*/
class RatingWidget extends WidgetType {
constructor(
private pattern: string,
private rating: number,
private symbolSet: any,
private startPos: number,
private endPos: number,
private ratingText?: RatingText | null
) {
super();
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('span');
container.className = 'interactive-rating-editor-widget';
container.setAttribute('data-rating', this.rating.toString());
container.setAttribute('data-pattern-length', this.pattern.length.toString());
// Create symbols container
const symbolsContainer = document.createElement('span');
symbolsContainer.className = 'interactive-rating-symbols';
// Create clickable symbols
[...this.pattern].forEach((symbol, index) => {
const span = document.createElement('span');
span.textContent = symbol;
span.style.cursor = 'pointer';
span.setAttribute('data-symbol-index', index.toString());
// Add click handler
span.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.updateRating(view, index + 1);
});
// Add hover preview
span.addEventListener('mouseenter', () => {
this.previewRating(index + 1, container);
});
symbolsContainer.appendChild(span);
});
container.appendChild(symbolsContainer);
// Create rating text container if rating text exists
if (this.ratingText) {
const textContainer = document.createElement('span');
textContainer.className = 'interactive-rating-text';
textContainer.textContent = this.ratingText.text;
container.appendChild(textContainer);
}
// Reset on mouse leave
container.addEventListener('mouseleave', () => {
this.renderRating(this.rating, container);
});
return container;
}
private previewRating(newRating: number, container: HTMLElement): void {
const symbolsContainer = container.querySelector('.interactive-rating-symbols');
if (!symbolsContainer) return;
const spans = symbolsContainer.querySelectorAll('span');
spans.forEach((span, index) => {
if (index < newRating) {
span.textContent = this.symbolSet.full;
} else {
span.textContent = this.symbolSet.empty;
}
});
// Preview rating text if it exists
if (this.ratingText) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
const previewText = formatRatingText(
this.ratingText.format,
newRating,
this.pattern.length,
this.ratingText.denominator,
!!this.symbolSet.half
);
textContainer.textContent = previewText;
}
}
}
private renderRating(rating: number, container: HTMLElement): void {
const symbolsContainer = container.querySelector('.interactive-rating-symbols');
if (!symbolsContainer) return;
const spans = symbolsContainer.querySelectorAll('span');
spans.forEach((span, index) => {
if (index < rating) {
span.textContent = this.symbolSet.full;
} else {
span.textContent = this.symbolSet.empty;
}
});
// Restore original rating text
if (this.ratingText) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
textContainer.textContent = this.ratingText.text;
}
}
}
private updateRating(view: EditorView, newRating: number): void {
try {
// Generate new symbol string
const newSymbols = generateSymbolsString(
newRating,
this.pattern.length,
this.symbolSet.full,
this.symbolSet.empty,
this.symbolSet.half || '',
!!this.symbolSet.half
);
// Generate new rating text if it exists
let newText = newSymbols;
if (this.ratingText) {
const newRatingText = formatRatingText(
this.ratingText.format,
newRating,
this.pattern.length,
this.ratingText.denominator,
!!this.symbolSet.half
);
newText = newSymbols + newRatingText;
}
// Update the document (replace both symbols and rating text)
view.dispatch({
changes: {
from: this.startPos,
to: this.endPos,
insert: newText
}
});
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Rating updated in editor', {
oldRating: this.rating,
newRating,
newSymbols,
newText,
hasRatingText: !!this.ratingText,
position: { from: this.startPos, to: this.endPos }
});
}
} catch (error) {
if (LOGGING_ENABLED) {
console.error('[InteractiveRatings] Error updating rating', error);
}
}
}
}
/**
* ViewPlugin to detect and replace rating patterns with interactive widgets
*/
const ratingViewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
try {
const text = view.state.doc.toString();
// Collect all matches first
const matches: RatingMatch[] = [];
const symbolRegexes = generateSymbolRegexPatterns();
for (const regex of symbolRegexes) {
let match;
regex.lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
const pattern = match[0];
const start = match.index;
const end = start + pattern.length;
// Skip if too short (minimum 3 symbols)
if (pattern.length < 3) continue;
const symbolSet = getSymbolSetForPattern(pattern);
if (!symbolSet) continue;
const rating = calculateRating(pattern, symbolSet);
// Check for rating text after the symbols
const ratingText = parseRatingText(text, start, end);
const actualEnd = ratingText ? ratingText.endPosition : end;
matches.push({
pattern,
start,
end: actualEnd,
symbolSet,
rating,
ratingText
});
}
}
// Sort matches by start position (required by RangeSetBuilder)
matches.sort((a, b) => a.start - b.start);
// Remove overlapping matches (keep the first one)
const filteredMatches: RatingMatch[] = [];
let lastEnd = -1;
for (const match of matches) {
if (match.start >= lastEnd) {
filteredMatches.push(match);
lastEnd = match.end;
}
}
// Add decorations in sorted order
for (const match of filteredMatches) {
const decoration = Decoration.replace({
widget: new RatingWidget(
match.pattern,
match.rating,
match.symbolSet,
match.start,
match.end,
match.ratingText
),
inclusive: false
});
builder.add(match.start, match.end, decoration);
}
if (LOGGING_ENABLED && filteredMatches.length > 0) {
console.debug(`[InteractiveRatings] Built ${filteredMatches.length} rating decorations`, {
withRatingText: filteredMatches.filter(m => m.ratingText).length,
symbolsOnly: filteredMatches.filter(m => !m.ratingText).length
});
}
} catch (error) {
if (LOGGING_ENABLED) {
console.error('[InteractiveRatings] Error building decorations', error);
}
}
return builder.finish();
}
},
{
decorations: v => v.decorations
}
);
// Export the extension array
export const ratingEditorExtension = [ratingViewPlugin];

View file

@ -1,192 +0,0 @@
import { Editor, MarkdownView } from 'obsidian';
import { LOGGING_ENABLED } from './constants';
import { isInteractionWithinRegion, isInteractingWithElement } from './ratings-calculator';
import { calculateRating, generateSymbolRegexPatterns, getSymbolSetForPattern, parseRatingText } from './ratings-parser';
import { EditorInteractionEvent, ExtendedEditor, RatingText } from './types';
import { calculateNewRating, getUnicodeCharLength } from './utils';
/**
* Handle editor interactions to detect and process rating patterns
*/
export function handleEditorInteraction(
event: EditorInteractionEvent,
editor: ExtendedEditor,
ratingsOverlay: HTMLElement | null,
removeRatingsOverlayFn: () => void,
createEditorOverlayFn: (
editor: ExtendedEditor,
line: number,
start: number,
symbols: string,
originalRating: number,
symbolSet: any,
ratingText: RatingText | null
) => void
): void {
// Clear any existing overlay if not interacting with it
if (ratingsOverlay && !isInteractingWithElement(event, ratingsOverlay)) {
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Removing overlay as interaction moved away`);
}
removeRatingsOverlayFn();
}
const target = event.target;
if (!(target instanceof HTMLElement)) return;
// Get editor position using the original event
const editorPos = editor.posAtMouse(event.originalEvent);
if (!editorPos) return;
// Get the line at the current position
const line = editor.getLine(editorPos.line);
// Generate regex patterns from the symbol sets
const symbolRegexes = generateSymbolRegexPatterns();
// Check for all symbol patterns
for (const regex of symbolRegexes) {
let match;
while ((match = regex.exec(line)) !== null) {
const start = match.index;
const end = start + getUnicodeCharLength(match[0]);
// Parse rating text if present
const ratingText = parseRatingText(line, start, end);
const textEndPosition = ratingText ? ratingText.endPosition : end;
// Calculate character position range
const startPos = { line: editorPos.line, ch: start };
const endPos = { line: editorPos.line, ch: textEndPosition };
// Get screen coordinates for start and end of pattern
const startCoords = editor.coordsAtPos(startPos);
const endCoords = editor.coordsAtPos(endPos);
if (!startCoords || !endCoords) continue;
// Check if interaction is inside the region
if (isInteractionWithinRegion(event, startCoords, endCoords)) {
// Determine pattern type by checking characters
const pattern = match[0];
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Detected rating pattern`, {
pattern,
line: editorPos.line,
start,
end,
ratingText: ratingText ? JSON.stringify(ratingText) : 'none'
});
};
const symbolSet = getSymbolSetForPattern(pattern);
if (!symbolSet) continue;
// Calculate original rating (count full symbols as 1.0 and half symbols as 0.5)
const originalRating = calculateRating(pattern, symbolSet);
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Detected original rating`, {
originalRating,
pattern,
symbolSet: JSON.stringify(symbolSet)
});
}
// Create overlay if it doesn't exist or is for a different pattern
if (!ratingsOverlay || ratingsOverlay.dataset.linePosition !== `${editorPos.line}-${start}`) {
createEditorOverlayFn(editor, editorPos.line, start, pattern, originalRating, symbolSet, ratingText);
}
return;
}
}
}
}
/**
* Handle pointer move event for rating overlays
*/
export function handlePointerMove(
event: PointerEvent,
ratingsOverlay: HTMLElement | null,
updateOverlayDisplayFn: (overlay: HTMLElement, rating: number) => void
): void {
if (ratingsOverlay) {
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Pointer move event while overlay exists`, {
pointerType: event.pointerType,
clientX: event.clientX,
clientY: event.clientY,
pointerId: event.pointerId
});
}
const rating = calculateNewRating(ratingsOverlay, event.clientX);
updateOverlayDisplayFn(ratingsOverlay, rating);
}
}
/**
* Handle pointer up event for rating overlays
*/
export function handlePointerUp(
event: PointerEvent,
ratingsOverlay: HTMLElement | null,
applyRatingUpdateFn: (rating: number) => void,
removeRatingsOverlayFn: () => void
): void {
if (ratingsOverlay) {
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Pointer up event while overlay exists`, {
pointerType: event.pointerType,
pointerId: event.pointerId
});
}
const rating = parseFloat(ratingsOverlay.dataset.currentRating);
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Finalizing rating`, { rating });
}
applyRatingUpdateFn(rating);
removeRatingsOverlayFn();
// Release pointer capture if it was captured
try {
const element = event.target as HTMLElement;
if (element && element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
} catch (e) {
// Ignore errors with pointer capture
}
}
}
/**
* Check if we are in source mode in an editor view
*/
export function isInSourceMode(app: any): {isSourceMode: boolean, editor: ExtendedEditor | null, markdownView: MarkdownView | null} {
// Check if we're in editor mode using getActiveViewOfType
const markdownView = app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
return { isSourceMode: false, editor: null, markdownView: null };
}
// Check if the view is in source mode
const isSourceMode = markdownView.getMode() !== 'preview';
if (!isSourceMode) {
return { isSourceMode: false, editor: null, markdownView: null };
}
// Get the editor using the markdownView
const editor = markdownView.editor as ExtendedEditor;
if (!editor) {
return { isSourceMode: false, editor: null, markdownView: null };
}
return { isSourceMode: true, editor, markdownView };
}

333
src/inline-rating-widget.ts Normal file
View file

@ -0,0 +1,333 @@
import { LOGGING_ENABLED } from './constants';
import { calculateNewRating, formatRatingText, generateSymbolsString } from './utils';
/**
* Inline Rating Widget - Custom HTML Element with improved structure
* Container: <interactive-rating>
* - <interactive-rating-data-pattern> (symbols only, interactive)
* - <interactive-rating-text> (text label, non-interactive)
*/
export class InlineRatingWidget extends HTMLElement {
private originalPattern: string = '';
private originalRating: number = 0;
private currentRating: number = 0;
private symbolCount: number = 0;
private isInteracting: boolean = false;
// DOM elements
private symbolsElement: HTMLElement | null = null;
private textElement: HTMLElement | null = null;
// Symbol set properties
private fullSymbol: string = '';
private emptySymbol: string = '';
private halfSymbol: string = '';
private supportsHalf: boolean = false;
// Rating text properties
private hasRatingText: boolean = false;
private ratingFormat: string = '';
private ratingNumerator: number = 0;
private ratingDenominator: number = 0;
private originalRatingText: string = '';
connectedCallback() {
this.className = 'interactive-ratings-inline-widget';
this.initializeFromDataset();
this.createStructure();
this.setupEventListeners();
this.render();
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Inline rating widget connected', {
originalPattern: this.originalPattern,
originalRating: this.originalRating,
hasRatingText: this.hasRatingText
});
}
}
disconnectedCallback() {
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Inline rating widget disconnected');
}
}
/**
* Initialize widget properties from dataset
*/
private initializeFromDataset(): void {
this.originalPattern = this.dataset.pattern || '';
this.originalRating = parseFloat(this.dataset.originalRating || '0');
this.currentRating = this.originalRating;
// Symbol set
this.fullSymbol = this.dataset.symbolSetFull || '★';
this.emptySymbol = this.dataset.symbolSetEmpty || '☆';
this.halfSymbol = this.dataset.symbolSetHalf || '';
this.supportsHalf = this.dataset.supportsHalf === 'true';
// Calculate symbol count from original pattern
this.symbolCount = [...this.originalPattern].length;
// Rating text
this.hasRatingText = this.dataset.hasRatingText === 'true';
if (this.hasRatingText) {
this.ratingFormat = this.dataset.ratingFormat || '';
this.ratingNumerator = parseFloat(this.dataset.ratingNumerator || '0');
this.ratingDenominator = parseInt(this.dataset.ratingDenominator || '5');
this.originalRatingText = this.dataset.ratingText || '';
}
}
/**
* Create the improved DOM structure
*/
private createStructure(): void {
// Clear any existing content
this.innerHTML = '';
// Create symbols element (interactive)
this.symbolsElement = document.createElement('interactive-rating-data-pattern');
this.symbolsElement.className = 'interactive-rating-symbols';
this.appendChild(this.symbolsElement);
// Create text element (non-interactive) if needed
if (this.hasRatingText) {
this.textElement = document.createElement('interactive-rating-text');
this.textElement.className = 'interactive-rating-text';
this.appendChild(this.textElement);
}
}
/**
* Setup event listeners for interaction
*/
private setupEventListeners(): void {
// Only attach interactive events to the symbols element
if (this.symbolsElement) {
this.symbolsElement.addEventListener('pointerenter', this.handlePointerEnter.bind(this));
this.symbolsElement.addEventListener('pointerleave', this.handlePointerLeave.bind(this));
this.symbolsElement.addEventListener('pointermove', this.handlePointerMove.bind(this));
this.symbolsElement.addEventListener('pointerdown', this.handlePointerDown.bind(this));
this.symbolsElement.addEventListener('pointerup', this.handlePointerUp.bind(this));
// Touch events for better mobile support
this.symbolsElement.style.touchAction = 'none';
}
// Keyboard accessibility on the container
this.tabIndex = 0;
this.setAttribute('role', 'slider');
this.setAttribute('aria-valuemin', '0');
this.setAttribute('aria-valuemax', this.symbolCount.toString());
this.setAttribute('aria-valuenow', this.currentRating.toString());
this.addEventListener('keydown', this.handleKeyDown.bind(this));
}
/**
* Handle pointer enter - start interaction mode
*/
private handlePointerEnter(event: PointerEvent): void {
if (this.symbolsElement) {
this.symbolsElement.classList.add('interactive-ratings-hover');
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Widget pointer enter');
}
}
/**
* Handle pointer leave - end interaction mode
*/
private handlePointerLeave(event: PointerEvent): void {
if (!this.isInteracting && this.symbolsElement) {
this.symbolsElement.classList.remove('interactive-ratings-hover');
this.currentRating = this.originalRating;
this.render();
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Widget pointer leave');
}
}
/**
* Handle pointer move - update rating preview
*/
private handlePointerMove(event: PointerEvent): void {
if (this.symbolsElement && this.symbolsElement.classList.contains('interactive-ratings-hover')) {
const rating = this.calculateRatingFromPosition(event.clientX);
this.currentRating = rating;
this.render();
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Widget rating preview', { rating });
}
}
}
/**
* Handle pointer down - start interaction
*/
private handlePointerDown(event: PointerEvent): void {
this.isInteracting = true;
event.preventDefault();
if (this.symbolsElement) {
try {
this.symbolsElement.setPointerCapture(event.pointerId);
} catch (e) {
// Ignore pointer capture errors
}
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Widget interaction started');
}
}
/**
* Handle pointer up - finalize rating
*/
private handlePointerUp(event: PointerEvent): void {
if (this.isInteracting) {
const finalRating = this.calculateRatingFromPosition(event.clientX);
this.applyRating(finalRating);
this.isInteracting = false;
if (this.symbolsElement) {
this.symbolsElement.classList.remove('interactive-ratings-hover');
try {
if (this.symbolsElement.hasPointerCapture(event.pointerId)) {
this.symbolsElement.releasePointerCapture(event.pointerId);
}
} catch (e) {
// Ignore pointer capture errors
}
}
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Widget rating applied', { finalRating });
}
}
}
/**
* Handle keyboard navigation
*/
private handleKeyDown(event: KeyboardEvent): void {
let newRating = this.currentRating;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowDown':
newRating = Math.max(0, this.currentRating - (this.supportsHalf ? 0.5 : 1));
break;
case 'ArrowRight':
case 'ArrowUp':
newRating = Math.min(this.symbolCount, this.currentRating + (this.supportsHalf ? 0.5 : 1));
break;
case 'Home':
newRating = 0;
break;
case 'End':
newRating = this.symbolCount;
break;
case 'Enter':
case ' ':
this.applyRating(this.currentRating);
return;
default:
return; // Don't prevent default for other keys
}
event.preventDefault();
this.currentRating = newRating;
this.render();
this.setAttribute('aria-valuenow', this.currentRating.toString());
}
/**
* Calculate rating from mouse/touch position (only considers symbols element)
*/
private calculateRatingFromPosition(clientX: number): number {
if (!this.symbolsElement) return this.currentRating;
const rect = this.symbolsElement.getBoundingClientRect();
const symbolWidth = rect.width / this.symbolCount;
const relativeX = clientX - rect.left;
let rating = Math.min(Math.max(0, relativeX / symbolWidth), this.symbolCount);
if (this.supportsHalf) {
// Round to nearest 0.5
rating = Math.round(rating * 2) / 2;
} else {
// Round to nearest integer
rating = Math.round(rating);
}
return rating;
}
/**
* Apply the rating and update the source document
*/
private applyRating(rating: number): void {
// TODO: Implement document update
// This will need access to the editor and source location
this.originalRating = rating;
this.currentRating = rating;
this.render();
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Rating applied to widget', { rating });
}
}
/**
* Render the current rating display
*/
private render(): void {
// Generate symbols string
const symbolsString = generateSymbolsString(
this.currentRating,
this.symbolCount,
this.fullSymbol,
this.emptySymbol,
this.halfSymbol,
this.supportsHalf
);
// Update symbols element
if (this.symbolsElement) {
this.symbolsElement.textContent = symbolsString;
}
// Generate and update rating text if applicable
if (this.hasRatingText && this.textElement) {
let adjustedRating = this.currentRating;
if (this.currentRating > this.ratingDenominator) {
adjustedRating = this.ratingDenominator;
}
const ratingTextString = formatRatingText(
this.ratingFormat,
adjustedRating,
this.symbolCount,
this.ratingDenominator,
this.supportsHalf
);
this.textElement.textContent = ratingTextString;
}
// Update accessibility
this.setAttribute('aria-valuenow', this.currentRating.toString());
this.setAttribute('aria-valuetext', `${this.currentRating} out of ${this.symbolCount} stars`);
}
}

View file

@ -1,13 +1,13 @@
import { InteractiveRatingsPlugin } from './InteractiveRatingsPlugin';
// Re-export from all modules
// Re-export from core modules
export * from './constants';
export * from './utils';
export * from './types';
export * from './ratings-parser';
export * from './ratings-calculator';
export * from './ratings-overlay';
export * from './event-handlers';
export * from './markdown-postprocessor';
export * from './inline-rating-widget';
export * from './editor-extension';
// Export the main plugin class
export default InteractiveRatingsPlugin;
export default InteractiveRatingsPlugin;

View file

@ -0,0 +1,201 @@
import { MarkdownPostProcessorContext } from 'obsidian';
import { SYMBOL_PATTERNS, LOGGING_ENABLED } from './constants';
import { generateSymbolRegexPatterns, getSymbolSetForPattern, parseRatingText, calculateRating } from './ratings-parser';
import { getUnicodeCharLength } from './utils';
import { InlineRatingWidget } from './inline-rating-widget';
/**
* Build detection regex once at load time from existing symbol catalog
*/
function createRatingDetectionRegex(): RegExp {
const allSymbols = new Set<string>();
SYMBOL_PATTERNS.forEach(pattern => {
allSymbols.add(pattern.full);
allSymbols.add(pattern.empty);
if (pattern.half) {
allSymbols.add(pattern.half);
}
});
// Escape special regex characters and create character class
const escapedSymbols = Array.from(allSymbols)
.map(symbol => symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('');
return new RegExp(`[${escapedSymbols}]{3,}`, 'g');
}
// Create detection regex once at module load
const RATING_DETECTION_REGEX = createRatingDetectionRegex();
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Inline ratings system loaded - detection regex created from symbol patterns');
}
/**
* Register inline rating widget custom element
*/
function registerInlineRatingWidget() {
if (customElements.get('interactive-rating')) {
return; // Already registered
}
customElements.define('interactive-rating', InlineRatingWidget);
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Inline rating widget registered');
}
}
/**
* Process rating elements in a text node and replace with widgets
*/
function processTextNodeForRatings(textNode: Text): boolean {
const text = textNode.textContent || '';
let hasReplacements = false;
// Generate detailed regex patterns for actual processing
const symbolRegexes = generateSymbolRegexPatterns();
for (const regex of symbolRegexes) {
let match;
regex.lastIndex = 0; // Reset regex state
while ((match = regex.exec(text)) !== null) {
const pattern = match[0];
const start = match.index;
const end = start + getUnicodeCharLength(pattern);
// Parse any rating text after the symbols
const ratingText = parseRatingText(text, start, end);
const textEndPosition = ratingText ? ratingText.endPosition : end;
// Get symbol set for this pattern
const symbolSet = getSymbolSetForPattern(pattern);
if (!symbolSet) continue;
// Calculate original rating
const originalRating = calculateRating(pattern, symbolSet);
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Found rating in text node', {
pattern,
originalRating,
ratingText: ratingText ? JSON.stringify(ratingText) : 'none',
start,
end: textEndPosition
});
}
// Create inline rating widget with improved structure
const widget = document.createElement('interactive-rating') as InlineRatingWidget;
widget.dataset.pattern = pattern;
widget.dataset.originalRating = originalRating.toString();
widget.dataset.symbolSetFull = symbolSet.full;
widget.dataset.symbolSetEmpty = symbolSet.empty;
widget.dataset.symbolSetHalf = symbolSet.half || '';
widget.dataset.supportsHalf = symbolSet.half ? 'true' : 'false';
if (ratingText) {
widget.dataset.hasRatingText = 'true';
widget.dataset.ratingFormat = ratingText.format;
widget.dataset.ratingNumerator = ratingText.numerator.toString();
widget.dataset.ratingDenominator = ratingText.denominator.toString();
widget.dataset.ratingText = ratingText.text;
} else {
widget.dataset.hasRatingText = 'false';
}
// Note: Don't set initial textContent - the widget will handle its own structure
// Replace the text in the DOM
const parentNode = textNode.parentNode;
if (parentNode) {
const beforeText = text.substring(0, start);
const afterText = text.substring(textEndPosition);
// Replace with: [beforeText][widget][afterText]
if (beforeText) {
parentNode.insertBefore(document.createTextNode(beforeText), textNode);
}
parentNode.insertBefore(widget, textNode);
if (afterText) {
parentNode.insertBefore(document.createTextNode(afterText), textNode);
}
parentNode.removeChild(textNode);
hasReplacements = true;
break; // Process one replacement per text node
}
}
}
return hasReplacements;
}
/**
* Process rating elements in an HTML element
*/
function processRatingElements(element: HTMLElement): void {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Only process text nodes that might contain ratings
const text = node.textContent || '';
return RATING_DETECTION_REGEX.test(text) ?
NodeFilter.FILTER_ACCEPT :
NodeFilter.FILTER_REJECT;
}
}
);
const textNodesToProcess: Text[] = [];
let node;
while (node = walker.nextNode()) {
textNodesToProcess.push(node as Text);
}
// Reset regex state
RATING_DETECTION_REGEX.lastIndex = 0;
// Process text nodes (in reverse order to avoid DOM position issues)
for (let i = textNodesToProcess.length - 1; i >= 0; i--) {
processTextNodeForRatings(textNodesToProcess[i]);
}
}
/**
* Main markdown postprocessor function
* Ultra-fast early exit for documents without ratings
*/
export function processRatings(element: HTMLElement, context: MarkdownPostProcessorContext): void {
const textContent = element.textContent;
// ULTRA-FAST EXIT: No potential rating symbols
if (!textContent || !RATING_DETECTION_REGEX.test(textContent)) {
return; // ~99% of documents exit here in <1ms
}
// Reset regex state for actual processing
RATING_DETECTION_REGEX.lastIndex = 0;
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Processing element with potential ratings', {
elementType: element.tagName,
textLength: textContent.length
});
}
// Ensure widget is registered
registerInlineRatingWidget();
// Only proceed with detailed processing if ratings detected
processRatingElements(element);
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Rating processing completed');
}
}

View file

@ -1,57 +0,0 @@
import { LOGGING_ENABLED } from './constants';
import { Coordinates, EditorInteractionEvent } from './types';
/**
* Check if an event is interacting with a specific DOM element
*/
export function isInteractingWithElement(
event: EditorInteractionEvent,
element: HTMLElement
): boolean {
const rect = element.getBoundingClientRect();
const { clientX, clientY, pointerType } = event;
const isInteracting = (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Checking interaction with element (${pointerType || 'pointer'})`, {
clientX,
clientY,
rect: {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom
},
isInteracting
});
};
return isInteracting;
}
/**
* Check if an interaction event is within a specific region defined by coordinates
*/
export function isInteractionWithinRegion(
event: EditorInteractionEvent,
startCoords: Coordinates,
endCoords: Coordinates,
buffer: number = 5
): boolean {
const { clientX, clientY } = event;
const isWithin = (
clientX >= startCoords.left - buffer &&
clientX <= endCoords.right + buffer &&
clientY >= startCoords.top - buffer &&
clientY <= endCoords.bottom + buffer
);
return isWithin;
}

View file

@ -1,437 +0,0 @@
import { Editor } from 'obsidian';
import { LOGGING_ENABLED, OVERLAY_VERTICAL_ADJUSTMENT } from './constants';
import { ExtendedEditor, RatingText, SymbolSet } from './types';
import { calculateNewRating, formatRatingText, generateSymbolsString, getUnicodeCharLength } from './utils';
/**
* Create an overlay for interactive rating editing
*/
export function createEditorOverlay(
editor: ExtendedEditor,
line: number,
start: number,
symbols: string,
originalRating: number,
symbolSet: SymbolSet,
ratingText: RatingText | null,
addInteractionListeners: (container: HTMLElement) => void
): HTMLElement | null {
if (LOGGING_ENABLED) {
console.group(`[InteractiveRatings] Creating editor overlay`);
};
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Creating editor overlay`, {
line,
start,
symbols,
originalRating,
symbolSet,
ratingText: ratingText ? JSON.stringify(ratingText) : 'none'
});
};
// First, remove all existing overlays
document.querySelectorAll('.interactive-ratings-editor-overlay').forEach(el => {
el.remove();
});
// Get coordinates for the position
const posCoords = editor.coordsAtPos({ line: line, ch: start });
if (!posCoords) {
if (LOGGING_ENABLED) {
console.warn(`[InteractiveRatings] Could not get coordinates for position`, { line, start });
console.groupEnd();
};
return null;
}
// Create container for the overlay
const overlay = document.createElement('div');
overlay.tabIndex = 0;
overlay.className = 'interactive-ratings-editor-overlay';
// Set dynamic positioning based on editor coordinates
overlay.style.left = `${posCoords.left}px`;
overlay.style.top = `${posCoords.top - OVERLAY_VERTICAL_ADJUSTMENT}px`;
// Store position information for comparison
overlay.dataset.linePosition = `${line}-${start}`;
// Store original rating and symbol information
overlay.dataset.originalRating = originalRating.toString();
overlay.dataset.full = symbolSet.full;
overlay.dataset.empty = symbolSet.empty;
overlay.dataset.half = symbolSet.half || '';
overlay.dataset.supportsHalf = symbolSet.half ? 'true' : 'false';
overlay.dataset.originalSymbols = symbols;
if (ratingText) {
overlay.dataset.hasRatingText = 'true';
overlay.dataset.ratingFormat = ratingText.format;
overlay.dataset.ratingNumerator = ratingText.numerator.toString();
overlay.dataset.ratingDenominator = ratingText.denominator.toString();
overlay.dataset.ratingText = ratingText.text;
overlay.dataset.ratingEndPosition = ratingText.endPosition.toString();
} else {
overlay.dataset.hasRatingText = 'false';
}
// Track current hover position
overlay.dataset.currentRating = "0";
// Get the editor element and compute its styles that need to be applied dynamically
const editorEl = editor.editorComponent.editorEl;
const editorStyles = window.getComputedStyle(editorEl);
// Apply editor-specific font properties which need to be computed at runtime
overlay.style.fontFamily = editorStyles.fontFamily;
overlay.style.fontSize = editorStyles.fontSize;
overlay.style.fontWeight = editorStyles.fontWeight;
overlay.style.letterSpacing = editorStyles.letterSpacing;
overlay.style.lineHeight = editorStyles.lineHeight;
overlay.style.verticalAlign = editorStyles.verticalAlign || 'baseline';
// Use Unicode-aware character counting
const symbolCount = getUnicodeCharLength(symbols);
overlay.dataset.symbolCount = symbolCount.toString();
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Constructed overlay element`, {
position: `${posCoords.left}px, ${posCoords.top - OVERLAY_VERTICAL_ADJUSTMENT}px`,
symbolCount,
datasets: { ...overlay.dataset }
});
}
// Add symbols to the overlay - properly iterate over Unicode characters
const symbolsArray = [...symbols];
for (let i = 0; i < symbolCount; i++) {
const symbolSpan = document.createElement('span');
symbolSpan.className = 'interactive-ratings-symbol';
symbolSpan.textContent = symbolsArray[i];
symbolSpan.dataset.position = i.toString();
symbolSpan.dataset.originalChar = symbolsArray[i];
overlay.appendChild(symbolSpan);
}
// Add event listeners for mouse interactions
addInteractionListeners(overlay);
// Add to document
document.body.appendChild(overlay);
overlay.focus();
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Editor overlay created successfully`);
console.groupEnd();
}
return overlay;
}
/**
* Update the rating overlay display based on the current rating
*/
export function updateOverlayDisplay(overlay: HTMLElement, rating: number): void {
overlay.dataset.currentRating = rating.toString();
const symbols = overlay.querySelectorAll('.interactive-ratings-symbol');
const full = overlay.dataset.full;
const empty = overlay.dataset.empty;
const half = overlay.dataset.half;
const supportsHalf = overlay.dataset.supportsHalf === 'true';
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Updating overlay display`, {
rating,
full,
empty,
half,
supportsHalf,
symbolCount: symbols.length
});
}
symbols.forEach((symbol, index) => {
const oldContent = symbol.textContent;
let newContent;
if (index < Math.floor(rating)) {
newContent = full;
} else if (supportsHalf && index === Math.floor(rating) && rating % 1 !== 0) {
newContent = half;
} else {
newContent = empty;
}
if (oldContent !== newContent) {
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Symbol ${index} changing from ${oldContent} to ${newContent}`);
}
symbol.textContent = newContent;
}
});
}
/**
* Remove the ratings overlay from the DOM
*/
export function removeRatingsOverlay(ratingsOverlay: HTMLElement | null): HTMLElement | null {
if (LOGGING_ENABLED) {
console.group(`[InteractiveRatings] Removing ratings overlay`);
}
if (ratingsOverlay) {
if (ratingsOverlay.parentNode) {
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Removing ratings overlay from DOM`, {
linePosition: ratingsOverlay.dataset.linePosition,
currentRating: ratingsOverlay.dataset.currentRating
});
}
ratingsOverlay.blur();
ratingsOverlay.parentNode.removeChild(ratingsOverlay);
return null;
} else {
console.warn("Ratings overlay exists but is not in DOM");
}
} else {
console.debug("No ratings overlay to remove");
}
if (LOGGING_ENABLED) {
console.groupEnd();
}
return ratingsOverlay;
}
/**
* Update the rating in the editor
*/
export function updateRatingInEditor(
editor: ExtendedEditor,
line: number,
start: number,
newSymbols: string,
updatedRatingText: string,
originalSymbols: string,
hasRatingText: string,
ratingEndPosition: string
): void {
let startPos = { line: line, ch: start };
let endPos;
if (hasRatingText === 'true') {
// Use the stored rating end position
endPos = { line: line, ch: parseInt(ratingEndPosition) };
} else {
// If there's no rating text, just replace the symbols
endPos = { line: line, ch: start + getUnicodeCharLength(originalSymbols) };
}
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Updating document in editor`, {
line,
start,
startPos,
endPos,
newSymbols,
updatedRatingText,
originalSymbols,
hasRatingText,
ratingEndPosition,
finalContent: newSymbols + updatedRatingText
});
};
// Replace the entire content
editor.replaceRange(
newSymbols + updatedRatingText,
startPos,
endPos
);
}
/**
* Apply the rating update to the editor
*/
export function applyRatingUpdate(
editor: ExtendedEditor,
overlay: HTMLElement,
rating: number
): void {
if (!overlay) return;
const line = parseInt(overlay.dataset.linePosition.split('-')[0]);
const start = parseInt(overlay.dataset.linePosition.split('-')[1]);
const symbolCount = parseInt(overlay.dataset.symbolCount);
const full = overlay.dataset.full;
const empty = overlay.dataset.empty;
const half = overlay.dataset.half;
const supportsHalf = overlay.dataset.supportsHalf === 'true';
const hasRatingText = overlay.dataset.hasRatingText;
const originalSymbols = overlay.dataset.originalSymbols;
if (LOGGING_ENABLED) {
console.group(`[InteractiveRatings] Applying rating update`);
};
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Rating update details`, {
rating,
line,
start,
symbolCount,
full,
empty,
half,
supportsHalf,
hasRatingText,
originalSymbols
});
};
// Generate new symbols string
const newSymbols = generateSymbolsString(rating, symbolCount, full, empty, half, supportsHalf);
// Handle rating text update
let updatedRatingText = '';
if (hasRatingText === 'true') {
const format = overlay.dataset.ratingFormat;
const denominator = parseInt(overlay.dataset.ratingDenominator);
let adjustedRating = rating;
if (rating > denominator) {
adjustedRating = denominator;
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Rating capped to denominator`, { rating: adjustedRating, denominator });
};
}
updatedRatingText = formatRatingText(format, adjustedRating, symbolCount, denominator, supportsHalf);
}
// Update the document
updateRatingInEditor(
editor,
line,
start,
newSymbols,
updatedRatingText,
originalSymbols,
hasRatingText,
overlay.dataset.ratingEndPosition
);
if (LOGGING_ENABLED) {
console.groupEnd();
}
}
/**
* Add interaction listeners to the overlay
*/
export function addInteractionListeners(
container: HTMLElement,
applyRatingFn: (rating: number) => void,
removeOverlayFn: () => void
): void {
if (LOGGING_ENABLED) {
console.group(`[InteractiveRatings] Adding interaction listeners`);
console.info("Adding interaction listeners to overlay", {
linePosition: container.dataset.linePosition,
symbolCount: container.dataset.symbolCount,
supportsHalf: container.dataset.supportsHalf
});
}
const symbols = container.querySelectorAll('.interactive-ratings-symbol');
const full = container.dataset.full;
const empty = container.dataset.empty;
const half = container.dataset.half;
const supportsHalf = container.dataset.supportsHalf === 'true';
// Use pointer events for all interactions
container.addEventListener('pointermove', (e) => {
e.preventDefault(); // Prevent default behavior
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Pointer move event on overlay`, {
pointerType: e.pointerType,
clientX: e.clientX,
clientY: e.clientY,
pointerId: e.pointerId
});
}
const rating = calculateNewRating(container, e.clientX);
updateOverlayDisplay(container, rating);
});
// Reset on pointer leave
container.addEventListener('pointerleave', () => {
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Pointer leave event on overlay`);
}
// Reset to original state
symbols.forEach((symbol) => {
const originalChar = (symbol as HTMLElement).dataset.originalChar || empty;
symbol.textContent = originalChar;
});
});
// Pointer down to capture the pointer
container.addEventListener('pointerdown', (e) => {
e.preventDefault(); // Prevent default behavior like cursor movement
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Pointer down event on overlay`, {
pointerType: e.pointerType,
pointerId: e.pointerId
});
}
// Capture pointer to ensure we get all events even if finger moves outside the element
try {
container.setPointerCapture(e.pointerId);
} catch (e) {
// Ignore errors with pointer capture
}
});
// Pointer up to finalize the selection
container.addEventListener('pointerup', (e) => {
if (LOGGING_ENABLED) {
console.info(`[InteractiveRatings] Pointer up event on overlay`, {
pointerType: e.pointerType,
clientX: e.clientX,
clientY: e.clientY,
pointerId: e.pointerId
});
}
const rating = calculateNewRating(container, e.clientX);
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Rating on pointer up`, { rating });
}
applyRatingFn(rating);
removeOverlayFn();
// Release pointer capture
try {
if (container.hasPointerCapture(e.pointerId)) {
container.releasePointerCapture(e.pointerId);
}
} catch (e) {
// Ignore errors with pointer capture
}
});
if (LOGGING_ENABLED) {
console.info("Interaction listeners added successfully");
console.groupEnd();
}
}

View file

@ -1,15 +1,3 @@
import { Editor, MarkdownView } from 'obsidian';
// Extend the Editor interface to include CM6 specific methods
export interface ExtendedEditor extends Editor {
posAtMouse(event: MouseEvent): Position;
posAtCoords(coords: {left: number, top: number}): Position;
coordsAtPos(pos: Position): Coordinates;
editorComponent: {
editorEl: HTMLElement;
};
}
export interface SymbolSet {
full: string;
empty: string;
@ -23,38 +11,3 @@ export interface RatingText {
text: string;
endPosition: number;
}
export interface Position {
line: number;
ch: number;
}
export interface Coordinates {
left: number;
right?: number;
top: number;
bottom?: number;
}
export type InteractionType = 'mouse' | 'touch';
export interface EditorInteractionEvent {
clientX: number;
clientY: number;
target: EventTarget | null;
originalEvent: PointerEvent;
pointerId?: number;
pointerType?: string;
}
// Simplified adapter for pointer events
export function adaptEvent(event: PointerEvent): EditorInteractionEvent {
return {
clientX: event.clientX,
clientY: event.clientY,
target: event.target,
originalEvent: event,
pointerId: event.pointerId,
pointerType: event.pointerType
};
}

View file

@ -1,35 +1,175 @@
.interactive-ratings-container {
display: inline-block;
cursor: pointer;
}
.interactive-ratings-symbol {
display: inline-block;
transition: transform 0.1s ease;
/* Inline Rating Widget Styles - Container */
.interactive-ratings-inline-widget {
display: inline;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
height: auto;
outline: none;
}
.interactive-ratings-editor-overlay {
display: inline-block;
position: fixed;
z-index: 1000;
background-color: var(--background-primary);
/* Symbols Element - Interactive Part */
.interactive-rating-symbols {
display: inline;
cursor: pointer;
padding: 1px 2px;
margin: 0;
border: none;
box-sizing: border-box;
touch-action: none; /* Prevent browser handling of touch gestures */
-webkit-user-select: none; /* Prevent text selection on iOS */
user-select: none; /* Prevent text selection */
-webkit-touch-callout: none; /* Disable callout on long press */
border-radius: 2px;
transition: background-color 0.15s ease;
touch-action: none;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Make touch targets larger on mobile */
/* Text Element - Non-Interactive Label */
.interactive-rating-text {
display: inline;
cursor: default;
padding: 0;
margin: 0;
color: inherit;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Hover state for symbols only */
.interactive-rating-symbols.interactive-ratings-hover {
background-color: var(--background-modifier-hover);
}
/* Focus state for accessibility - applied to container */
.interactive-ratings-inline-widget:focus {
outline: 1px solid var(--background-modifier-border-focus);
outline-offset: 1px;
border-radius: 2px;
}
.interactive-ratings-inline-widget:focus .interactive-rating-symbols {
background-color: var(--background-modifier-hover);
}
/* Active state for symbols */
.interactive-rating-symbols:active {
background-color: var(--background-modifier-active);
}
/* Editor Widget Styles - For Editing Mode */
.interactive-rating-editor-widget {
display: inline;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: 1px solid transparent;
border-radius: 2px;
padding: 1px;
margin: 0;
transition: background-color 0.15s ease, border-color 0.15s ease;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.interactive-rating-editor-widget:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-border-hover);
}
/* Editor widget symbols container */
.interactive-rating-editor-widget .interactive-rating-symbols {
display: inline;
cursor: pointer;
padding: 0;
margin: 0;
border-radius: 0;
transition: none;
background: transparent;
}
/* Editor widget rating text */
.interactive-rating-editor-widget .interactive-rating-text {
display: inline;
cursor: default;
padding: 0;
margin: 0;
color: inherit;
}
/* Individual symbol spans in editor widget */
.interactive-rating-editor-widget .interactive-rating-symbols span {
cursor: pointer;
transition: opacity 0.1s ease;
}
.interactive-rating-editor-widget .interactive-rating-symbols span:hover {
opacity: 0.8;
}
/* Mobile/touch enhancements */
@media (max-width: 768px) {
.interactive-ratings-symbol {
min-height: 24px;
padding: 2px;
.interactive-rating-symbols {
padding: 3px 4px;
min-height: 28px;
display: inline-flex;
align-items: center;
}
.interactive-rating-text {
min-height: 28px;
display: inline-flex;
align-items: center;
}
.interactive-rating-editor-widget {
padding: 3px 4px;
min-height: 28px;
display: inline-flex;
align-items: center;
}
}
/* High contrast accessibility */
@media (prefers-contrast: high) {
.interactive-ratings-inline-widget:focus {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.interactive-rating-editor-widget:hover {
outline: 1px solid currentColor;
}
}
/* Reduced motion accessibility */
@media (prefers-reduced-motion: reduce) {
.interactive-rating-symbols,
.interactive-rating-editor-widget,
.interactive-rating-editor-widget .interactive-rating-symbols span {
transition: none;
}
}
/* Ensure proper inline flow */
interactive-rating,
interactive-rating-data-pattern,
interactive-rating-text {
display: inline;
}
/* Debugging styles - only visible when logging is enabled */
.interactive-rating-editor-widget[data-debug="true"] {
outline: 1px dashed rgba(255, 0, 0, 0.3);
}
.interactive-rating-editor-widget[data-debug="true"]:after {
content: " [DEBUG]";
font-size: 0.8em;
color: rgba(255, 0, 0, 0.5);
}