peritus_obsidian-interactiv.../main.ts
2025-03-20 07:00:34 +01:00

456 lines
No EOL
16 KiB
TypeScript

import { MarkdownView, Plugin } from 'obsidian';
function getUnicodeCharLength(str) {
return [...str].length;
}
function getUnicodeSubstring(str, start, end) {
return [...str].slice(start, end).join('');
}
function getUnicodeCharAt(str, index) {
return [...str][index];
}
// Define symbol patterns as a global constant
const SYMBOL_PATTERNS = [
{ full: '★', empty: '☆', half: null }, // Symbols
{ full: '●', empty: '○', half: '◐' }, // Circles
{ full: '■', empty: '□', half: '◧' }, // Squares
{ full: '▲', empty: '△', half: null }, // Triangles (no half)
// Progress bar patterns
{ full: '█', empty: '▁', half: null }, // Block progress
{ full: '⣿', empty: '⣀', half: '⡇' }, // Braille dots
{ full: '⬤', empty: '○', half: null }, // Solid/empty circles
{ full: '■', empty: '□', half: null }, // Solid/empty squares
{ full: '▰', empty: '▱', half: null }, // Dotted squares
{ full: '◼', empty: '▭', half: null }, // Filled/empty rectangles
{ full: '▮', empty: '▯', half: null }, // Vertical bars
{ full: '⬤', empty: '◯', half: null }, // Bold circles
{ full: '⚫', empty: '⚪', half: null }, // Black/white circles
{ full: '█', empty: '░', half: null }, // Block/light shade
];
class InteractiveRatingsPlugin extends Plugin {
ratingsOverlay: HTMLElement | null;
async onload() {
// For editing mode, add event listener to the app's workspace
this.registerDomEvent(document, 'mousemove', (evt) => {
// Check if we're in editor mode using getActiveViewOfType instead of activeLeaf
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) return;
// Check if the view is in source mode
const isSourceMode = markdownView.getMode() !== 'preview';
if (!isSourceMode) return;
// Get the editor using the markdownView
const editor = markdownView.editor;
if (!editor) return;
// Process the event
this.handleEditorHover(evt, editor);
});
}
parseRatingText(line, start, end) {
// Get the substring after the symbols using Unicode-aware functions
const afterSymbols = getUnicodeSubstring(line, end, getUnicodeCharLength(line));
// Check for rating patterns
const ratingTextMatch = afterSymbols.match(/^\s*(?:\(([\d\.]+)\/(\d+)\)|([\d\.]+)\/(\d+)|(?:\()?(\d+)%(?:\))?)/);
if (ratingTextMatch) {
let format = '';
let numerator = 0;
let denominator = 0;
if (ratingTextMatch[1] && ratingTextMatch[2]) {
// (14.5/33) format
format = 'fraction-parentheses';
numerator = parseFloat(ratingTextMatch[1]);
denominator = parseInt(ratingTextMatch[2]);
} else if (ratingTextMatch[3] && ratingTextMatch[4]) {
// 14.5/33 format
format = 'fraction';
numerator = parseFloat(ratingTextMatch[3]);
denominator = parseInt(ratingTextMatch[4]);
} else if (ratingTextMatch[5]) {
// 60% or (60%) format
format = afterSymbols.includes('(') ? 'percent-parentheses' : 'percent';
numerator = parseInt(ratingTextMatch[5]);
denominator = 100;
}
// Calculate the end position correctly with Unicode-aware calculations
const endPosition = end + ratingTextMatch[0].length;
return {
format,
numerator,
denominator,
text: ratingTextMatch[0],
endPosition: endPosition
};
}
return null;
}
handleEditorHover(event, editor) {
// Clear any existing overlay
if (this.ratingsOverlay && !this.isMouseOverElement(event, this.ratingsOverlay)) {
this.removeRatingsOverlay();
}
const target = event.target;
if (!(target instanceof HTMLElement)) return;
// Get the editor's info at the current position
const editorPos = editor.posAtMouse(event);
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 = SYMBOL_PATTERNS.map(symbols => {
const pattern = `[${symbols.full}${symbols.empty}${symbols.half ? symbols.half : ''}]{3,}`;
return new RegExp(pattern, 'g');
});
// 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 = this.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 mouse is inside the region
if (this.isMouseWithinRegion(event, startCoords, endCoords)) {
// Determine pattern type by checking characters
const pattern = match[0];
const symbolSet = this.getSymbolSetForPattern(pattern);
if (!symbolSet) continue;
// Calculate original rating (count full symbols as 1.0 and half symbols as 0.5)
const originalRating = this.calculateRating(pattern, symbolSet);
// Create overlay if it doesn't exist or is for a different pattern
if (!this.ratingsOverlay || this.ratingsOverlay.dataset.linePosition !== `${editorPos.line}-${start}`) {
this.createEditorOverlay(editor, editorPos.line, start, pattern, originalRating, symbolSet, ratingText);
}
return;
}
}
}
}
getSymbolSetForPattern(pattern) {
// Find the symbol set that matches the pattern
for (const symbolSet of SYMBOL_PATTERNS) {
if (pattern.includes(symbolSet.full) || pattern.includes(symbolSet.empty) ||
(symbolSet.half && pattern.includes(symbolSet.half))) {
return symbolSet;
}
}
return null;
}
calculateRating(pattern, symbolSet) {
let rating = 0;
// Use array spread to properly iterate over Unicode characters
for (const char of [...pattern]) {
if (char === symbolSet.full) rating += 1.0;
else if (symbolSet.half && char === symbolSet.half) rating += 0.5;
}
return rating;
}
isMouseWithinRegion(event, startCoords, endCoords) {
// Add a small buffer to make clicking easier
const buffer = 2;
return (
event.clientX >= startCoords.left - buffer &&
event.clientX <= endCoords.right + buffer &&
event.clientY >= startCoords.top - buffer &&
event.clientY <= endCoords.bottom + buffer
);
}
isMouseOverElement(event, element) {
const rect = element.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
createEditorOverlay(editor, line, start, symbols, originalRating, symbolSet, ratingText) {
// Get coordinates for the position
const posCoords = editor.coordsAtPos({line: line, ch: start});
if (!posCoords) return;
// Create container for the overlay
const overlay = document.createElement('div');
overlay.className = 'interactive-ratings-editor-overlay';
overlay.style.position = 'fixed';
overlay.style.zIndex = '1000';
overlay.style.backgroundColor = 'var(--background-primary)';
overlay.style.left = `${posCoords.left}px`;
// TODO Calculate a more precise vertical position
const verticalAdjustment = 2.1;
overlay.style.top = `${posCoords.top - verticalAdjustment}px`;
// Store position information for comparison
overlay.dataset.linePosition = `${line}-${start}`;
// Store original rating and symbol information
overlay.dataset.originalRating = originalRating;
overlay.dataset.full = symbolSet.full;
overlay.dataset.empty = symbolSet.empty;
overlay.dataset.half = symbolSet.half || '';
overlay.dataset.supportsHalf = symbolSet.half ? 'true' : 'false';
if (ratingText) {
overlay.dataset.hasRatingText = 'true';
overlay.dataset.ratingFormat = ratingText.format;
overlay.dataset.ratingNumerator = ratingText.numerator;
overlay.dataset.ratingDenominator = ratingText.denominator;
overlay.dataset.ratingText = ratingText.text;
overlay.dataset.ratingEndPosition = ratingText.endPosition;
} else {
overlay.dataset.hasRatingText = 'false';
}
// Track current hover position
overlay.dataset.currentRating = "0";
// Get the editor element and compute its styles
const editorEl = editor.editorComponent.editorEl;
const editorStyles = window.getComputedStyle(editorEl);
// Also, ensure vertical alignment matches
overlay.style.verticalAlign = editorStyles.verticalAlign || 'baseline';
// Match editor font properties exactly
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.cursor = 'pointer';
// Apply exact padding and margins to match editor
overlay.style.padding = '0';
overlay.style.margin = '0';
overlay.style.border = 'none';
overlay.style.boxSizing = 'border-box';
// Prevent any text selection that might cause movement
overlay.style.userSelect = 'none';
// Use Unicode-aware character counting
const symbolCount = getUnicodeCharLength(symbols);
overlay.dataset.symbolCount = symbolCount.toString();
// 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];
symbolSpan.style.padding = '0';
symbolSpan.style.margin = '0';
symbolSpan.style.height = 'auto';
symbolSpan.style.display = 'inline-block';
symbolSpan.style.width = `${100 / symbolCount}%`; // Make each star take equal width
overlay.appendChild(symbolSpan);
}
// Add event listeners
this.addHoverListeners(overlay);
// Add click listener to update text
overlay.addEventListener('click', (e) => {
const containerRect = overlay.getBoundingClientRect();
const symbolCount = parseInt(overlay.dataset.symbolCount);
const symbolWidth = containerRect.width / symbolCount;
const relativeX = e.clientX - containerRect.left;
const clickedSymbolIndex = Math.floor(relativeX / symbolWidth);
const positionWithinSymbol = (relativeX % symbolWidth) / symbolWidth;
// Determine if we should use half symbol or full symbol based on where within the symbol was clicked
const supportsHalf = overlay.dataset.supportsHalf === 'true';
const useHalfSymbol = supportsHalf && positionWithinSymbol < 0.5;
let newRating = clickedSymbolIndex + (useHalfSymbol ? 0.5 : 1);
if (newRating < 0) {
newRating = 0;
}
const full = overlay.dataset.full;
const empty = overlay.dataset.empty;
const half = overlay.dataset.half;
let newSymbols = '';
for (let i = 0; i < symbolCount; i++) {
if (i < Math.floor(newRating)) {
newSymbols += full;
} else if (supportsHalf && i === Math.floor(newRating) && newRating % 1 !== 0) {
newSymbols += half;
} else {
newSymbols += empty;
}
}
// Handle rating text update
let updatedRatingText = '';
if (overlay.dataset.hasRatingText === 'true') {
const format = overlay.dataset.ratingFormat;
const denominator = parseInt(overlay.dataset.ratingDenominator);
// Calculate new numerator based on rating
let newNumerator;
if (format.includes('percent')) {
newNumerator = Math.round((newRating / symbolCount) * 100);
} else {
newNumerator = newRating;
if (overlay.dataset.supportsHalf !== 'true') {
newNumerator = Math.round(newNumerator);
}
}
// Format the text based on the original format
switch (format) {
case 'fraction':
updatedRatingText = ` ${newNumerator}/${denominator}`;
break;
case 'fraction-parentheses':
updatedRatingText = ` (${newNumerator}/${denominator})`;
break;
case 'percent':
updatedRatingText = ` ${newNumerator}%`;
break;
case 'percent-parentheses':
updatedRatingText = ` (${newNumerator}%)`;
break;
}
}
// IMPORTANT: Make sure we're replacing the entire content, including the rating text
let startPos = {line: line, ch: start};
let endPos;
if (overlay.dataset.hasRatingText === 'true') {
// Use the stored rating end position
endPos = {line: line, ch: parseInt(overlay.dataset.ratingEndPosition)};
} else {
// If there's no rating text, just replace the symbols
endPos = {line: line, ch: start + getUnicodeCharLength(symbols)};
}
// Replace the entire content
editor.replaceRange(
newSymbols + updatedRatingText,
startPos,
endPos
);
this.removeRatingsOverlay();
});
// Add to document
document.body.appendChild(overlay);
this.ratingsOverlay = overlay;
}
removeRatingsOverlay() {
if (this.ratingsOverlay && this.ratingsOverlay.parentNode) {
this.ratingsOverlay.parentNode.removeChild(this.ratingsOverlay);
this.ratingsOverlay = null;
}
}
addHoverListeners(container) {
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';
container.addEventListener('mousemove', (e) => {
const containerRect = container.getBoundingClientRect();
const symbolCount = parseInt(container.dataset.symbolCount);
const symbolWidth = containerRect.width / symbolCount;
const relativeX = e.clientX - containerRect.left;
const hoveredSymbolIndex = Math.floor(relativeX / symbolWidth);
const positionWithinSymbol = (relativeX % symbolWidth) / symbolWidth;
// Determine if we're in the first or second half of the star
const useHalfSymbol = supportsHalf && positionWithinSymbol < 0.5;
// Calculate current rating with half-symbol precision if supported
const currentRating = hoveredSymbolIndex + (useHalfSymbol ? 0.5 : 1);
container.dataset.currentRating = currentRating.toString();
symbols.forEach((symbol, index) => {
if (index < Math.floor(currentRating)) {
symbol.textContent = full;
} else if (supportsHalf && index === Math.floor(currentRating) && currentRating % 1 !== 0) {
symbol.textContent = half;
} else {
symbol.textContent = empty;
}
});
});
container.addEventListener('mouseleave', () => {
// Reset to original state
symbols.forEach((symbol) => {
const originalChar = symbol.dataset.originalChar || empty;
symbol.textContent = originalChar;
});
});
}
onunload() {
const styleEl = document.getElementById('interactive-ratings-style');
if (styleEl) styleEl.remove();
this.removeRatingsOverlay();
}
}
module.exports = InteractiveRatingsPlugin;