mirror of
https://github.com/peritus/obsidian-interactive-ratings.git
synced 2026-07-22 12:20:31 +00:00
Add CodeMirror editor extension to make ratings interactive in editing mode
This commit is contained in:
parent
cedf7c01da
commit
a8b30bea88
11 changed files with 1047 additions and 903 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
301
src/editor-extension.ts
Normal 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];
|
||||
|
|
@ -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
333
src/inline-rating-widget.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
10
src/main.ts
10
src/main.ts
|
|
@ -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;
|
||||
|
|
|
|||
201
src/markdown-postprocessor.ts
Normal file
201
src/markdown-postprocessor.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
47
src/types.ts
47
src/types.ts
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
188
styles.css
188
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue