Compare commits

..

No commits in common. "main" and "1.0.4" have entirely different histories.
main ... 1.0.4

49 changed files with 728 additions and 1953 deletions

View file

@ -1,44 +0,0 @@
[tool.bumpversion]
current_version = "1.0.8"
# Parse pattern that understands both regular and preview versions
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(?:-(?P<release>preview)\\.(?P<preview>\\d+))?"
# Preview serialization - always includes preview suffix
serialize = [
"{major}.{minor}.{patch}-{release}.{preview}" # Preview versions: 0.9.1-preview.1
]
commit = true
tag = true
tag_name = "{new_version}"
tag_message = "Preview {new_version}"
message = "Bump version: {current_version} → {new_version}"
regex = true
# PREVIEW CONFIG: Update package.json AND sync release config
[[tool.bumpversion.files]]
filename = "package.json"
search = '"version": "{current_version}"'
replace = '"version": "{new_version}"'
# Keep release config in sync
[[tool.bumpversion.files]]
filename = ".bumpversion.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
# Keep preview config in sync with itself
[[tool.bumpversion.files]]
filename = ".bumpversion-preview.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
# Release part: always "preview" for this config
[tool.bumpversion.parts.release]
values = ["preview"]
# Preview part: for incrementing preview numbers
[tool.bumpversion.parts.preview]
first_value = "1"

View file

@ -1,51 +1,19 @@
[tool.bumpversion]
current_version = "1.0.8"
# Parse pattern that understands both regular and preview versions
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(?:-(?P<release>preview)\\.(?P<preview>\\d+))?"
# Release serialization - always removes preview suffix
serialize = [
"{major}.{minor}.{patch}" # Final releases: 0.9.1
]
current_version = "1.0.4"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
commit = true
tag = true
tag_name = "{new_version}"
tag_message = "Release {new_version}"
tag_message = "Version {new_version}"
message = "Bump version: {current_version} → {new_version}"
regex = true
# RELEASE CONFIG: Update package.json, manifest.json, AND sync preview config
[[tool.bumpversion.files]]
filename = "manifest.json"
search = '"version": "{current_version}"'
replace = '"version": "{new_version}"'
[[tool.bumpversion.files]]
filename = "package.json"
search = '"version": "{current_version}"'
replace = '"version": "{new_version}"'
[[tool.bumpversion.files]]
filename = "manifest.json"
search = '"version": "[^"]*"'
replace = '"version": "{new_version}"'
regex = true
# Keep release config in sync
[[tool.bumpversion.files]]
filename = ".bumpversion.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
# Keep preview config in sync with itself
[[tool.bumpversion.files]]
filename = ".bumpversion-preview.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
# Release part: preview -> stable (stable is hidden in serialization)
[tool.bumpversion.parts.release]
values = ["preview", "stable"]
optional_value = "stable"
# Preview part: for incrementing preview numbers
[tool.bumpversion.parts.preview]
first_value = "1"

2
.gitignore vendored
View file

@ -1,2 +1,2 @@
main.js
*.js
node_modules/

View file

@ -1,203 +0,0 @@
# CSS Customization Guide
This guide explains how to customize the appearance of interactive rating symbols using CSS snippets in Obsidian.
## ⚠️ Important: Minimal Default Styling
**The Interactive Ratings plugin applies minimal default styling to maintain functionality:**
- **Regular rating systems** (★☆): No default styling - symbols are visually distinct by character
- **Full-only rating systems** (★★★★★): Essential default styling preserved - without it, ratings would be unreadable
**To get started with regular rating systems, try the "Basic Styling" example below** to add visual distinction between filled, half, and empty symbols.
## New Feature: Distinct CSS Classes for Selected/Unselected Symbols
The Interactive Ratings plugin provides distinct CSS classes for selected (filled), half-filled, and unselected (empty) symbols, making it easy to customize their appearance.
### Available CSS Classes
#### For Regular Rating Systems (with filled/half/empty symbols):
- `.interactive-rating-symbol--rated` - Applied to selected/filled symbols (★)
- `.interactive-rating-symbol--half` - Applied to half-filled symbols (☆★)
- `.interactive-rating-symbol--empty` - Applied to unselected/empty symbols (☆)
- `.interactive-rating-symbol--normal` - Legacy class (no longer used for filled/empty distinction)
#### For Full-Only Rating Systems (symbols that don't have empty variants):
- `.interactive-rating-symbol--rated` - Applied to symbols within the rating (has default styling)
- `.interactive-rating-symbol--unrated` - Applied to symbols beyond the rating (has default styling)
**Important:** Full-only systems have essential default styling preserved because without it, all symbols would look identical and ratings would be unreadable. You can still override these defaults with your own CSS.
### Example CSS Snippets
Here are some examples of how you can customize the appearance of rating symbols:
#### 🚀 Quick Start: Basic Visual Distinction
```css
/* Add this snippet to get started with basic visual distinction for regular rating systems */
.interactive-rating-symbol--rated {
opacity: 1;
color: inherit;
}
.interactive-rating-symbol--half {
opacity: 0.7;
}
.interactive-rating-symbol--empty {
opacity: 0.4;
}
/* Note: .interactive-rating-symbol--unrated (full-only systems) already has default styling */
```
#### 2. Make Empty Symbols More Subtle
```css
.interactive-rating-symbol--empty {
opacity: 0.3;
}
```
#### 3. Add Color to Filled, Half, and Empty Symbols
```css
.interactive-rating-symbol--rated {
color: #FFD700; /* Gold color for filled stars */
}
.interactive-rating-symbol--half {
color: #FFA500; /* Orange color for half stars */
}
.interactive-rating-symbol--empty {
color: #CCCCCC; /* Light gray for empty stars */
}
```
#### 4. Create Gradient Effect for Half Symbols
```css
.interactive-rating-symbol--rated {
color: #FFD700;
}
.interactive-rating-symbol--half {
background: linear-gradient(90deg, #FFD700 50%, #CCCCCC 50%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.interactive-rating-symbol--empty {
color: #CCCCCC;
}
```
#### 5. Add Hover Effects
```css
.interactive-rating-symbol--empty:hover {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.interactive-rating-symbol--half:hover {
opacity: 0.9;
transform: scale(1.05);
transition: all 0.2s ease;
}
.interactive-rating-symbol--rated:hover {
transform: scale(1.1);
transition: transform 0.2s ease;
}
```
#### 6. Different Styling for Different Symbol Types
```css
/* For star ratings */
.interactive-rating-symbol--rated:contains("★") {
color: #FFD700;
}
.interactive-rating-symbol--half:contains("☆★") {
color: #FFA500;
}
/* For heart ratings */
.interactive-rating-symbol--rated:contains("♥") {
color: #FF6B9D;
}
.interactive-rating-symbol--half:contains("♡") {
color: #FFB3D1;
}
```
#### 7. Add Drop Shadow to Filled and Half Symbols
```css
.interactive-rating-symbol--rated {
text-shadow: 0 0 3px rgba(255, 215, 0, 0.6);
}
.interactive-rating-symbol--half {
text-shadow: 0 0 2px rgba(255, 165, 0, 0.5);
}
.interactive-rating-symbol--empty {
opacity: 0.4;
}
```
### How to Apply CSS Snippets in Obsidian
1. Open Obsidian Settings
2. Go to "Appearance" → "CSS snippets"
3. Click the folder icon to open the snippets folder
4. Create a new `.css` file (e.g., `rating-customization.css`)
5. Add your desired CSS rules
6. Enable the snippet in Obsidian's CSS snippets section
### Default Styling
**The plugin applies minimal default styling to maintain functionality:**
**Regular Rating Systems** (★☆ - with distinct filled/empty symbols):
- **Filled symbols** (`.interactive-rating-symbol--rated`): No default styling - add your own
- **Half symbols** (`.interactive-rating-symbol--half`): No default styling - add your own
- **Empty symbols** (`.interactive-rating-symbol--empty`): No default styling - add your own
**Full-Only Rating Systems** (★★★★★ - single symbol type):
- **Rated symbols** (`.interactive-rating-symbol--rated`): Default styling preserved (opacity: 1, no filter)
- **Unrated symbols** (`.interactive-rating-symbol--unrated`): Default styling preserved (opacity: 0.5, grayscale filter)
- **Reason**: Without default styling, full-only systems would be unreadable (all symbols would look identical)
**Accessibility styling is preserved:** High contrast mode will automatically apply enhanced contrast styling only when users have high contrast preferences enabled.
### Accessibility Considerations
**The plugin includes automatic high contrast support as the only built-in styling.** When users have high contrast mode enabled, empty and half symbols will automatically receive enhanced contrast styling for accessibility compliance. This accessibility styling is preserved even though no default visual styling is applied in normal viewing modes.
You can override the accessibility styling by providing your own high contrast styles:
```css
@media (prefers-contrast: high) {
.interactive-rating-symbol--empty {
opacity: 0.2 !important;
filter: grayscale(100%) contrast(1.2) !important;
}
.interactive-rating-symbol--half {
opacity: 0.5 !important;
filter: contrast(1.3) !important;
}
}
```
### Migration from Previous Versions
If you were previously using CSS that targeted `.interactive-rating-symbol` without state classes, your existing styles should still work. However, for more precise control, consider updating to use the new state-specific classes:
- Replace `.interactive-rating-symbol` with `.interactive-rating-symbol--rated` for filled symbols
- Add styles for `.interactive-rating-symbol--half` for half symbols
- Add styles for `.interactive-rating-symbol--empty` for empty symbols
This enhancement provides much more flexibility for customizing the visual appearance of your rating systems, with complete control over filled, half, and empty symbol states!

View file

@ -2,42 +2,19 @@
Add interactive rating symbols to your notes that update with a click.
<img alt="demo of the Interactive Ratings Plugin for Obsidian" src="screencast.gif" width="400" />
## Supported Symbol Sets
| Symbol Type | Full | Empty | Half | Examples |
|-------------|------|-------|------|----------|
| Stars | ★ | ☆ | | `★★★☆☆ (3/5)` Book rating |
| Star Symbols | ✦ | ✧ | | `✦✦✦✧✧ (3/5)` Stargazing |
| Moon Phases | 🌕 | 🌑 | 🌗 | `🌕🌕🌗🌑🌑 (2.5/5)` Lunar observation |
| Circles | ● | ○ | ◐ | `●●●○○○○○○○ 3/10` Movie review scale |
| Squares | ■ | □ | ◧ | `■■■□ (3/4)` Recipe difficulty |
| Triangles | ▲ | △ | | `▲▲▲▲▲△△△△△△△ 5/12` Hiking difficulty |
| Red Hearts | ❤️ | 🤍 | | `❤️❤️❤️🤍🤍 (3/5)` Celebrity crush |
| Orange Hearts | 🧡 | 🤍 | | `🧡🧡🧡🧡🤍 80%` Sunset addiction |
| Yellow Hearts | 💛 | 🤍 | | `💛💛🤍🤍 2/4` Wizard vibes |
| Green Hearts | 💚 | 🤍 | | `💚💚💚🤍🤍🤍 50%` Plant guilt |
| Blue Hearts | 💙 | 🤍 | | `💙💙🤍🤍🤍 (2/5)` Ocean yearning |
| Purple Hearts | 💜 | 🤍 | | `💜💜💜💜🤍 4/5` Unicorn belief |
| Black Hearts | 🖤 | 🤍 | | `🖤🖤🖤🤍 75%` Villain sympathy |
| Brown Hearts | 🤎 | 🤍 | | `🤎🤎🤍🤍🤍 (2/5)` Chocolate dependency |
| Block Progress | █ | ▁ | | `███▁▁▁ (3/6)` Project completion |
| Braille Dots | ⣿ | ⣀ | ⡇ | `⣿⣿⡇⣀⣀⣀⣀⣀⣀⣀ 2.5/10` |
| Solid/Empty Circles | ⬤ | ○ | | `⬤⬤⬤○○○○○○○○○ 25%` Budget spending |
| Solid/Empty Squares | ■ | □ | | `■■■□□□□ 3/7` Weekly progress |
| Dotted Squares | ▰ | ▱ | | `▰▰▰▱▱▱▱▱▱▱ (3/10)` Task complexity |
| Filled/Empty Rectangles | ◼ | ▭ | | `◼◼◼▭ 75%` Reading progress |
| Vertical Bars | ▮ | ▯ | | `▮▮▮▮▮▮▯▯▯▯▯▯ 50%` Battery level |
| Bold Circles | ⬤ | ◯ | | `⬤◯◯ 1/3` Quick product review |
| Black/White Circles | ⚫ | ⚪ | | `⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪ (5/10)` Coffee strength |
| Block/Light Shade | █ | ░ | | `███░░░░░░░ 30%` Download progress |
## Usage
<img alt="demo of the Interactive Ratings Plugin for Obsidian" src="screencast.gif" width="400" />
Type any supported symbol sequence (minimum 3 identical symbols) in your note. When you hover over these symbols in edit mode, clicking on one of the symbols allowing you to update the rating.
## Installation
1. In Obsidian settings, go to Community Plugins
2. Disable Safe Mode
3. Search for "Interactive Ratings"
4. Install and enable the plugin
### Rating Text (optional)
Add numerical ratings with these formats:
@ -50,33 +27,24 @@ Add numerical ratings with these formats:
The numerical rating updates automatically when you change the symbols.
### Custom Emojis
You can configure which emojis are supported for rating interactions by going to Settings → Community Plugins → Interactive Ratings. In the "Emojis to support in ratings" setting, enter any emojis you want to use for ratings.
**Default emojis**: `🎥🏆⭐💎🔥⚡🎯🚀💰🎖️`
**ZWJ Sequence Support**: The plugin fully supports complex emojis including ZWJ (Zero Width Joiner) sequences such as:
- 🏴‍☠️ (Pirate Flag)
- 👨‍🚒 (Man Firefighter)
- 🏳️‍🌈 (Rainbow Flag)
- And other compound emojis
**Example usage**:
- `🎥🎥🎥🎥🎥 (4/5)` Movie rating
- `🏆🏆🏆 3/5` Achievement level
- `🔥🔥🔥🔥 80%` Spice level
- `🎯🎯🎯🎯🎯🎯 (5/6)` Accuracy rating
- `🏴‍☠️🏴‍☠️🏴‍☠️ (3/5)` Pirate movie rating
## Installation
1. In Obsidian settings, go to Community Plugins
2. Disable Safe Mode
3. Search for "Interactive Ratings"
4. Install and enable the plugin
## Supported Symbol Sets
| Symbol Type | Full | Empty | Half | Examples |
|-------------|------|-------|------|----------|
| Stars | ★ | ☆ | | `★★★☆☆ (3/5)` Book rating |
| Circles | ● | ○ | ◐ | `●●●○○○○○○○ 3/10` Movie review scale |
| Squares | ■ | □ | ◧ | `■■■□ (3/4)` Recipe difficulty |
| Triangles | ▲ | △ | | `▲▲▲▲▲△△△△△△△ 5/12` Hiking difficulty |
| Block Progress | █ | ▁ | | `███▁▁▁ (3/6)` Project completion |
| Braille Dots | ⣿ | ⣀ | ⡇ | `⣿⣿⡇⣀⣀⣀⣀⣀⣀⣀ 2.5/10` |
| Solid/Empty Circles | ⬤ | ○ | | `⬤⬤⬤○○○○○○○○○ 25%` Budget spending |
| Solid/Empty Squares | ■ | □ | | `■■■□□□□ 3/7` Weekly progress |
| Dotted Squares | ▰ | ▱ | | `▰▰▰▱▱▱▱▱▱▱ (3/10)` Task complexity |
| Filled/Empty Rectangles | ◼ | ▭ | | `◼◼◼▭ 75%` Reading progress |
| Vertical Bars | ▮ | ▯ | | `▮▮▮▮▮▮▯▯▯▯▯▯ 50%` Battery level |
| Bold Circles | ⬤ | ◯ | | `⬤◯◯ 1/3` Quick product review |
| Black/White Circles | ⚫ | ⚪ | | `⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪ (5/10)` Coffee strength |
| Block/Light Shade | █ | ░ | | `███░░░░░░░ 30%` Download progress |
## Use Cases

View file

@ -24,7 +24,7 @@ paths_blocked = [
build = ["npm", "run", "build"]
# Debug build
build_debug = ["npm", "run", "build:debug"]
build_debug = ["npm", "run", "build-debug"]
# Type checking
type_check = ["npm", "run", "type-check"]

View file

@ -25,7 +25,7 @@ const buildOptions = {
format: 'cjs',
banner: { js: banner },
define: {
'process.env.LOGGING_ENABLED': loggingEnabled ? '"true"' : '"false"'
'process.env.LOGGING_ENABLED': JSON.stringify(loggingEnabled)
},
external: [
'obsidian',

View file

@ -1,146 +0,0 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function getVCSChangeId() {
try {
// Try Jujutsu first
try {
// Try current working copy @
let changeId = execSync('jj log -r @ --no-graph -T change_id', { encoding: 'utf8' }).trim();
// If working copy is empty, try parent revision @-
if (!changeId || changeId === '' || changeId.includes('(empty)')) {
changeId = execSync('jj log -r @- --no-graph -T change_id', { encoding: 'utf8' }).trim();
}
return changeId;
} catch (jjError) {
// Fall back to Git
const gitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
return gitHash;
}
} catch (error) {
console.error('Warning: Could not get VCS change ID:', error.message);
return 'unknown';
}
}
function getLatestCommitMessage() {
try {
// Try Jujutsu first
try {
// Try current working copy @
let message = execSync('jj log -r @ --no-graph -T description', { encoding: 'utf8' }).trim();
// If working copy is empty, try parent revision @-
if (!message || message === '' || message.includes('(empty)')) {
message = execSync('jj log -r @- --no-graph -T description', { encoding: 'utf8' }).trim();
}
const firstLine = message.split('\n')[0];
return firstLine;
} catch (jjError) {
// Fall back to Git
const message = execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim();
const firstLine = message.split('\n')[0];
return firstLine;
}
} catch (error) {
console.error('Warning: Could not get commit message:', error.message);
return 'Development build';
}
}
function generateDevManifest() {
try {
// Read the original manifest
const manifestPath = path.join(__dirname, 'manifest.json');
const originalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Get VCS info
const changeId = getVCSChangeId();
const commitMessage = getLatestCommitMessage();
// Create dev version of manifest
const devManifest = {
...originalManifest,
id: `${originalManifest.id}-dev`,
name: `${originalManifest.name} (Dev)`,
version: `${originalManifest.version}-dev-${changeId.substring(0, 8)}`,
description: `${commitMessage} [dev-${changeId.substring(0, 8)}]`
};
// Return JSON string when used as module, output to stdout when run directly
const jsonString = JSON.stringify(devManifest, null, 2);
if (require.main === module && process.argv.includes('--preview')) {
console.log(jsonString);
process.exit(0);
}
return jsonString;
} catch (error) {
console.error('Error generating dev manifest:', error.message);
process.exit(1);
}
}
function buildAndInstall() {
try {
// Check if VAULT_PATH is provided
const vaultPath = process.env.VAULT_PATH;
if (!vaultPath) {
console.error('Usage: VAULT_PATH="/path/to/vault" npm run build-and-install');
process.exit(1);
}
console.log('📄 Generating dev manifest...');
const devManifestContent = generateDevManifest();
// Read original manifest to get plugin ID
const originalManifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
const pluginId = originalManifest.id;
// Create plugin directory path
const pluginDir = path.join(vaultPath, '.obsidian', 'plugins', pluginId);
console.log('📁 Creating plugin directory...');
fs.mkdirSync(pluginDir, { recursive: true });
console.log('📋 Copying plugin files...');
// Copy main files
const filesToCopy = ['main.js', 'styles.css'];
for (const file of filesToCopy) {
if (fs.existsSync(file)) {
fs.copyFileSync(file, path.join(pluginDir, file));
console.log(` ✓ Copied ${file}`);
}
}
// Write dev manifest
fs.writeFileSync(path.join(pluginDir, 'manifest.json'), devManifestContent);
console.log(' ✓ Copied dev manifest.json');
console.log('✅ Installed dev build successfully!');
console.log(` Plugin directory: ${pluginDir}`);
} catch (error) {
console.error('❌ Build and install failed:', error.message);
process.exit(1);
}
}
// Handle command line arguments
if (require.main === module) {
if (process.argv.includes('--preview')) {
generateDevManifest();
} else {
buildAndInstall();
}
}
module.exports = { buildAndInstall, generateDevManifest, getVCSChangeId, getLatestCommitMessage };

View file

@ -1,7 +1,7 @@
{
"id": "interactive-ratings",
"name": "Interactive Ratings",
"version": "1.0.8",
"version": "1.0.4",
"minAppVersion": "0.15.0",
"author": "peritus",
"authorUrl": "https://github.com/peritus",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-interactive-ratings",
"version": "1.0.7",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-interactive-ratings",
"version": "1.0.7",
"version": "1.0.1",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.25.1",

View file

@ -1,24 +1,13 @@
{
"name": "obsidian-interactive-ratings",
"version": "1.0.8",
"version": "1.0.4",
"description": "Edit symbol ratings in your Obsidian notes interactively.",
"main": "main.js",
"scripts": {
"build": "node esbuild.config.js",
"build:debug": "LOGGING_ENABLED=true node esbuild.config.js",
"build-debug": "LOGGING_ENABLED=true node esbuild.config.js",
"build:watch": "node esbuild.config.js --watch",
"build-and-install": "npm run build:debug && node install-dev.js",
"type-check": "tsc --noEmit",
"version:preview:patch": "bump-my-version bump patch --config-file .bumpversion-preview.toml",
"version:preview:minor": "bump-my-version bump minor --config-file .bumpversion-preview.toml ",
"version:preview:major": "bump-my-version bump major --config-file .bumpversion-preview.toml ",
"version:preview": "bump-my-version bump preview --config-file .bumpversion-preview.toml",
"version:release": "bump-my-version bump release",
"version:patch": "bump-my-version bump patch",
"version:minor": "bump-my-version bump minor",
"version:major": "bump-my-version bump major",
"version:show": "bump-my-version show-bump",
"version:preview:show": "bump-my-version show-bump --config-file .bumpversion-preview.toml"
"type-check": "tsc --noEmit"
},
"keywords": [
"obsidianmd"

View file

@ -1,30 +1,16 @@
import { Plugin } from 'obsidian';
import { LOGGING_ENABLED } from './constants';
import { ratingEditorExtension } from './editor-extension/ratingViewPlugin/ratingViewPlugin';
import { InteractiveRatingsSettings } from './types';
import { DEFAULT_SETTINGS, InteractiveRatingsSettingTab } from './settings';
import { updateSymbolPatternsFromSettings } from './utils/updateSymbolPatternsFromSettings';
import { ratingEditorExtension } from './editor-extension';
export class InteractiveRatingsPlugin extends Plugin {
settings: InteractiveRatingsSettings;
async onload(): Promise<void> {
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Plugin loading - edit mode only');
}
// Load settings
await this.loadSettings();
// Update symbol patterns based on settings
this.updateSymbolPatterns();
// Register editor extension for interactive ratings in editing mode only
this.registerEditorExtension(ratingEditorExtension);
// Add settings tab
this.addSettingTab(new InteractiveRatingsSettingTab(this.app, this));
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Plugin loaded successfully');
}
@ -35,16 +21,4 @@ export class InteractiveRatingsPlugin extends Plugin {
console.info('[InteractiveRatings] Plugin unloaded');
}
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
updateSymbolPatterns(): void {
updateSymbolPatternsFromSettings(this.settings);
}
}
}

View file

@ -1,46 +1,16 @@
import { SymbolSet } from './types';
// ============================================================================
// CRITICAL: DO NOT MODIFY THE LOGGING CONFIGURATION BELOW
// ============================================================================
//
// Logging is controlled via esbuild's sophisticated build-time mechanism:
// - Production builds: `npm run build` (logging disabled)
// - Debug builds: `npm run build:debug` (logging enabled via LOGGING_ENABLED=true)
//
// The value below is replaced at BUILD TIME by esbuild.config.js using the
// `define` feature. This ensures zero runtime overhead for production builds.
//
// ⚠️ NEVER change this to a hardcoded boolean value!
// ⚠️ Use the npm scripts to control logging instead:
// - `npm run build` for production (no logging)
// - `npm run build:debug` for debug builds (with logging)
//
// The esbuild configuration will replace `process.env.LOGGING_ENABLED` with
// the actual boolean value at compile time, so this becomes a constant in
// the final bundle with no runtime cost.
// ============================================================================
// Global logging control - set at build time via environment variable
export const LOGGING_ENABLED = process.env.LOGGING_ENABLED === 'true';
// Base symbol patterns (excluding user-configurable emojis)
export const BASE_SYMBOL_PATTERNS: SymbolSet[] = [
// Define symbol patterns as a global constant
export const SYMBOL_PATTERNS: SymbolSet[] = [
{ full: '★', empty: '☆', half: null }, // Symbols
{ full: '✦', empty: '✧', half: null }, // Star symbols
{ full: '🌕', empty: '🌑', half: '🌗' }, // Moon phases
{ full: '●', empty: '○', half: '◐' }, // Circles
{ full: '■', empty: '□', half: '◧' }, // Squares
{ full: '▲', empty: '△', half: null }, // Triangles (no half)
// Heart symbols
{ full: '❤️', empty: '🤍', half: null }, // Red hearts
{ full: '🧡', empty: '🤍', half: null }, // Orange hearts
{ full: '💛', empty: '🤍', half: null }, // Yellow hearts
{ full: '💚', empty: '🤍', half: null }, // Green hearts
{ full: '💙', empty: '🤍', half: null }, // Blue hearts
{ full: '💜', empty: '🤍', half: null }, // Purple hearts
{ full: '🖤', empty: '🤍', half: null }, // Black hearts
{ full: '🤎', empty: '🤍', half: null }, // Brown hearts
// Progress bar patterns
{ full: '█', empty: '▁', half: null }, // Block progress
{ full: '⣿', empty: '⣀', half: '⡇' }, // Braille dots
@ -54,20 +24,6 @@ export const BASE_SYMBOL_PATTERNS: SymbolSet[] = [
{ full: '█', empty: '░', half: null }, // Block/light shade
];
// Mutable symbol patterns - starts with base patterns and user-configurable emojis are added dynamically
export let SYMBOL_PATTERNS: SymbolSet[] = [
...BASE_SYMBOL_PATTERNS,
// User-configurable emojis are added dynamically from settings
];
/**
* Update the global symbol patterns array
*/
export function updateSymbolPatterns(newPatterns: SymbolSet[]): void {
SYMBOL_PATTERNS.length = 0; // Clear the array
SYMBOL_PATTERNS.push(...newPatterns); // Add new patterns
}
// Interaction constants
export const INTERACTION_BUFFER = 5; // Buffer for interaction detection
export const OVERLAY_VERTICAL_ADJUSTMENT = 2.1; // Vertical alignment for overlay

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

@ -0,0 +1,424 @@
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 with full half-symbol support
*/
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());
container.setAttribute('data-supports-half', (!!this.symbolSet.half).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.style.position = 'relative';
span.setAttribute('data-symbol-index', index.toString());
// Add click handler with half-symbol support
span.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const newRating = this.calculateRatingFromClick(e, span, index);
this.updateRating(view, newRating);
});
// Add hover preview with half-symbol support
span.addEventListener('mouseenter', (e) => {
const previewRating = this.calculateRatingFromHover(e, span, index);
this.previewRating(previewRating, container);
});
// Add mousemove for fine-grained half-symbol preview
span.addEventListener('mousemove', (e) => {
const previewRating = this.calculateRatingFromHover(e, span, index);
this.previewRating(previewRating, 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);
});
// Initial render
this.renderRating(this.rating, container);
return container;
}
/**
* Calculate rating from click position with half-symbol support
*/
private calculateRatingFromClick(event: MouseEvent, span: HTMLElement, symbolIndex: number): number {
if (!this.symbolSet.half) {
// No half-symbol support, return full symbol rating
return symbolIndex + 1;
}
const rect = span.getBoundingClientRect();
const relativeX = event.clientX - rect.left;
const symbolWidth = rect.width;
const position = relativeX / symbolWidth;
// If clicked on left half, use half symbol; if right half, use full symbol
if (position <= 0.5) {
return symbolIndex + 0.5;
} else {
return symbolIndex + 1;
}
}
/**
* Calculate rating from hover position with half-symbol support
*/
private calculateRatingFromHover(event: MouseEvent, span: HTMLElement, symbolIndex: number): number {
if (!this.symbolSet.half) {
// No half-symbol support, return full symbol rating
return symbolIndex + 1;
}
const rect = span.getBoundingClientRect();
const relativeX = event.clientX - rect.left;
const symbolWidth = rect.width;
const position = relativeX / symbolWidth;
// More responsive half-symbol detection for hover
if (position <= 0.5) {
return symbolIndex + 0.5;
} else {
return symbolIndex + 1;
}
}
/**
* Preview rating with proper half-symbol rendering
*/
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) => {
const symbolRating = index + 1;
const halfRating = index + 0.5;
if (symbolRating <= newRating) {
// Full symbol
span.textContent = this.symbolSet.full;
} else if (this.symbolSet.half && halfRating <= newRating && halfRating > newRating - 0.5) {
// Half symbol
span.textContent = this.symbolSet.half;
} else {
// Empty symbol
span.textContent = this.symbolSet.empty;
}
});
// Preview rating text if it exists
if (this.ratingText) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
// Use the current pattern length as the denominator for preview
const previewText = formatRatingText(
this.ratingText.format,
newRating,
this.pattern.length,
this.pattern.length, // Use symbol count as denominator
!!this.symbolSet.half
);
textContainer.textContent = previewText;
}
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Preview rating with half-symbol support', {
newRating,
hasHalf: !!this.symbolSet.half,
symbolSet: this.symbolSet,
denominator: this.pattern.length
});
}
}
/**
* Render rating with proper half-symbol display
*/
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) => {
const symbolRating = index + 1;
const halfRating = index + 0.5;
if (symbolRating <= rating) {
// Full symbol
span.textContent = this.symbolSet.full;
} else if (this.symbolSet.half && halfRating <= rating && halfRating > rating - 0.5) {
// Half symbol
span.textContent = this.symbolSet.half;
} else {
// Empty symbol
span.textContent = this.symbolSet.empty;
}
});
// Restore original rating text
if (this.ratingText) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
textContainer.textContent = this.ratingText.text;
}
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Render rating with half-symbol support', {
rating,
hasHalf: !!this.symbolSet.half,
symbolSet: this.symbolSet
});
}
}
/**
* Update rating in the document with half-symbol support
*/
private updateRating(view: EditorView, newRating: number): void {
try {
// Generate new symbol string with half-symbol support
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) {
// Use the current pattern length as the denominator for final update
const newRatingText = formatRatingText(
this.ratingText.format,
newRating,
this.pattern.length,
this.pattern.length, // Use symbol count as 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.info('[InteractiveRatings] Rating updated in editor with half-symbol support', {
oldRating: this.rating,
newRating,
newSymbols,
newText,
hasRatingText: !!this.ratingText,
hasHalf: !!this.symbolSet.half,
oldDenominator: this.ratingText?.denominator,
newDenominator: this.pattern.length,
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 || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
try {
const text = view.state.doc.toString();
const cursorPos = view.state.selection.main.head;
// 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, but skip if cursor is nearby
for (const match of filteredMatches) {
// Skip creating widget if cursor is near this rating
if (cursorPos >= match.start - 1 && cursorPos <= match.end + 1) {
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Skipping widget creation due to nearby cursor', {
cursorPos,
ratingStart: match.start,
ratingEnd: match.end
});
}
continue;
}
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) {
const skippedCount = filteredMatches.filter(m =>
cursorPos >= m.start - 1 && cursorPos <= m.end + 1
).length;
console.debug(`[InteractiveRatings] Built ${filteredMatches.length - skippedCount}/${filteredMatches.length} rating decorations (${skippedCount} skipped due to cursor proximity)`, {
cursorPos,
withRatingText: filteredMatches.filter(m => m.ratingText).length,
symbolsOnly: filteredMatches.filter(m => !m.ratingText).length,
withHalfSymbols: filteredMatches.filter(m => m.symbolSet.half).length
});
}
} 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,11 +0,0 @@
/**
* Interface for rating match data with optional rating text
*/
export interface RatingMatch {
pattern: string;
start: number;
end: number;
symbolSet: any;
rating: number;
ratingText?: any | null;
}

View file

@ -1,101 +0,0 @@
import { EditorView, WidgetType } from "@codemirror/view";
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { RatingText } from '../../types';
import { calculateRatingFromClick } from './calculateRatingFromClick';
import { calculateRatingFromHover } from './calculateRatingFromHover';
import { previewRating } from './previewRating';
import { renderRating } from './renderRating';
import { updateRating } from './updateRating';
/**
* Rating widget for inline editing in CodeMirror
* Handles both symbols and rating text with full half-symbol support, full-only symbols, and simplified HTML comments
*/
export 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());
const unicodeLength = getUnicodeCharLength(this.pattern);
container.setAttribute('data-pattern-length', unicodeLength.toString());
container.setAttribute('data-supports-half', (!!this.symbolSet.half).toString());
const isFullOnly = isFullOnlySymbol(this.symbolSet);
container.setAttribute('data-full-only', isFullOnly.toString());
// For full-only symbols, use denominator from rating text if available, otherwise use pattern length
const displaySymbolCount = (isFullOnly && this.ratingText) ? this.ratingText.denominator : unicodeLength;
container.setAttribute('data-display-symbol-count', displaySymbolCount.toString());
// Check if rating text is HTML comment format (only supported for full-only symbols)
const isCommentFormat = isFullOnly && this.ratingText && this.ratingText.format === 'comment-fraction';
container.setAttribute('data-comment-format', isCommentFormat ? 'true' : 'false');
// Create symbols container
const symbolsContainer = document.createElement('span');
symbolsContainer.className = 'interactive-rating-symbols';
// Create clickable symbols based on display count
for (let i = 0; i < displaySymbolCount; i++) {
const span = document.createElement('span');
span.textContent = this.symbolSet.full;
span.className = 'interactive-rating-symbol';
span.setAttribute('data-symbol-index', i.toString());
// Add click handler with full-only validation
span.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const newRating = calculateRatingFromClick(e, span, i, this.symbolSet);
updateRating(view, newRating, this.symbolSet, this.pattern, this.startPos, this.endPos, this.rating, this.ratingText);
});
// Add hover preview
span.addEventListener('mouseenter', (e) => {
const previewRatingValue = calculateRatingFromHover(e, span, i, this.symbolSet);
previewRating(previewRatingValue, container, this.symbolSet, this.pattern, this.ratingText);
});
// Add mousemove for fine-grained preview
span.addEventListener('mousemove', (e) => {
const previewRatingValue = calculateRatingFromHover(e, span, i, this.symbolSet);
previewRating(previewRatingValue, container, this.symbolSet, this.pattern, this.ratingText);
});
symbolsContainer.appendChild(span);
}
container.appendChild(symbolsContainer);
// Create rating text container if rating text exists and is not HTML comment format
if (this.ratingText && !isCommentFormat) {
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', () => {
renderRating(this.rating, container, this.symbolSet, this.ratingText);
});
// Initial render
renderRating(this.rating, container, this.symbolSet, this.ratingText);
return container;
}
}

View file

@ -1,10 +0,0 @@
/**
* Apply the appropriate CSS class to a symbol based on its state
*/
export function applySymbolState(span: HTMLElement, state: 'rated' | 'unrated' | 'normal' | 'empty' | 'half'): void {
// Remove all state classes
span.classList.remove('interactive-rating-symbol--rated', 'interactive-rating-symbol--unrated', 'interactive-rating-symbol--normal', 'interactive-rating-symbol--empty', 'interactive-rating-symbol--half');
// Add the appropriate state class
span.classList.add(`interactive-rating-symbol--${state}`);
}

View file

@ -1,35 +0,0 @@
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
/**
* Calculate rating from click position with full-only validation
*/
export function calculateRatingFromClick(
event: MouseEvent,
span: HTMLElement,
symbolIndex: number,
symbolSet: any
): number {
const isFullOnly = isFullOnlySymbol(symbolSet);
// For full-only symbols, don't support half ratings and enforce minimum rating of 1
if (isFullOnly) {
return Math.max(1, symbolIndex + 1);
}
if (!symbolSet.half) {
// No half-symbol support, return full symbol rating
return symbolIndex + 1;
}
const rect = span.getBoundingClientRect();
const relativeX = event.clientX - rect.left;
const symbolWidth = rect.width;
const position = relativeX / symbolWidth;
// If clicked on left half, use half symbol; if right half, use full symbol
if (position <= 0.5) {
return symbolIndex + 0.5;
} else {
return symbolIndex + 1;
}
}

View file

@ -1,35 +0,0 @@
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
/**
* Calculate rating from hover position with full-only validation
*/
export function calculateRatingFromHover(
event: MouseEvent,
span: HTMLElement,
symbolIndex: number,
symbolSet: any
): number {
const isFullOnly = isFullOnlySymbol(symbolSet);
// For full-only symbols, don't support half ratings and enforce minimum rating of 1
if (isFullOnly) {
return Math.max(1, symbolIndex + 1);
}
if (!symbolSet.half) {
// No half-symbol support, return full symbol rating
return symbolIndex + 1;
}
const rect = span.getBoundingClientRect();
const relativeX = event.clientX - rect.left;
const symbolWidth = rect.width;
const position = relativeX / symbolWidth;
// More responsive half-symbol detection for hover
if (position <= 0.5) {
return symbolIndex + 0.5;
} else {
return symbolIndex + 1;
}
}

View file

@ -1,82 +0,0 @@
import { formatRatingText } from '../../utils/formatRatingText';
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { LOGGING_ENABLED } from '../../constants';
import { RatingText } from '../../types';
import { applySymbolState } from './applySymbolState';
/**
* Preview rating with proper half-symbol rendering and full-only symbol support
*/
export function previewRating(
newRating: number,
container: HTMLElement,
symbolSet: any,
pattern: string,
ratingText?: RatingText | null
): void {
const symbolsContainer = container.querySelector('.interactive-rating-symbols');
if (!symbolsContainer) return;
const spans = symbolsContainer.querySelectorAll('span');
const isFullOnly = isFullOnlySymbol(symbolSet);
const isCommentFormat = isFullOnly && ratingText && ratingText.format === 'comment-fraction';
spans.forEach((span, index) => {
const symbolRating = index + 1;
const halfRating = index + 0.5;
if (isFullOnly) {
// For full-only symbols: show full symbol for rated, grey for unrated
span.textContent = symbolSet.full;
if (symbolRating <= newRating) {
applySymbolState(span, 'rated');
} else {
applySymbolState(span, 'unrated');
}
} else {
// Regular symbol behavior with distinct states for filled/half/empty
if (symbolRating <= newRating) {
// Full symbol - apply 'rated' state
span.textContent = symbolSet.full;
applySymbolState(span, 'rated');
} else if (symbolSet.half && halfRating <= newRating && halfRating > newRating - 0.5) {
// Half symbol - apply 'half' state
span.textContent = symbolSet.half;
applySymbolState(span, 'half');
} else {
// Empty symbol - apply 'empty' state
span.textContent = symbolSet.empty;
applySymbolState(span, 'empty');
}
}
});
// Preview rating text if it exists and is not HTML comment format
if (ratingText && !isCommentFormat) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
const unicodeLength = getUnicodeCharLength(pattern);
const previewText = formatRatingText(
ratingText.format,
newRating,
unicodeLength, // Always use actual pattern length for percentage calculations
ratingText.denominator, // Use original denominator for fraction displays
!!symbolSet.half && !isFullOnly,
isFullOnly
);
textContainer.textContent = previewText;
}
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Preview rating with simplified comment format support', {
newRating,
hasHalf: !!symbolSet.half,
isFullOnly,
isCommentFormat,
symbolSet: symbolSet,
denominator: ratingText?.denominator
});
}
}

View file

@ -1,69 +0,0 @@
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { LOGGING_ENABLED } from '../../constants';
import { RatingText } from '../../types';
import { applySymbolState } from './applySymbolState';
/**
* Render rating with proper half-symbol display and full-only symbol support
*/
export function renderRating(
rating: number,
container: HTMLElement,
symbolSet: any,
ratingText?: RatingText | null
): void {
const symbolsContainer = container.querySelector('.interactive-rating-symbols');
if (!symbolsContainer) return;
const spans = symbolsContainer.querySelectorAll('span');
const isFullOnly = isFullOnlySymbol(symbolSet);
const isCommentFormat = isFullOnly && ratingText && ratingText.format === 'comment-fraction';
spans.forEach((span, index) => {
const symbolRating = index + 1;
const halfRating = index + 0.5;
if (isFullOnly) {
// For full-only symbols: show full symbol for rated, grey for unrated
span.textContent = symbolSet.full;
if (symbolRating <= rating) {
applySymbolState(span, 'rated');
} else {
applySymbolState(span, 'unrated');
}
} else {
// Regular symbol behavior with distinct states for filled/half/empty
if (symbolRating <= rating) {
// Full symbol - apply 'rated' state
span.textContent = symbolSet.full;
applySymbolState(span, 'rated');
} else if (symbolSet.half && halfRating <= rating && halfRating > rating - 0.5) {
// Half symbol - apply 'half' state
span.textContent = symbolSet.half;
applySymbolState(span, 'half');
} else {
// Empty symbol - apply 'empty' state
span.textContent = symbolSet.empty;
applySymbolState(span, 'empty');
}
}
});
// Restore original rating text (only if not HTML comment format)
if (ratingText && !isCommentFormat) {
const textContainer = container.querySelector('.interactive-rating-text');
if (textContainer) {
textContainer.textContent = ratingText.text;
}
}
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Render rating with simplified comment format support', {
rating,
hasHalf: !!symbolSet.half,
isFullOnly,
isCommentFormat,
symbolSet: symbolSet
});
}
}

View file

@ -1,94 +0,0 @@
import { EditorView } from "@codemirror/view";
import { generateSymbolsStringForDisk } from '../../utils/generateSymbolsStringForDisk';
import { formatRatingText } from '../../utils/formatRatingText';
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { LOGGING_ENABLED } from '../../constants';
import { RatingText } from '../../types';
/**
* Update rating in the document with half-symbol support and full-only symbols
*/
export function updateRating(
view: EditorView,
newRating: number,
symbolSet: any,
pattern: string,
startPos: number,
endPos: number,
rating: number,
ratingText?: RatingText | null
): void {
try {
const isFullOnly = isFullOnlySymbol(symbolSet);
const unicodeLength = getUnicodeCharLength(pattern);
// For full-only symbols, use the disk-safe function that only includes rated symbols
const newSymbols = generateSymbolsStringForDisk(
newRating,
unicodeLength,
symbolSet.full,
symbolSet.empty,
symbolSet.half || '',
!!symbolSet.half && !isFullOnly,
symbolSet
);
// Generate new rating text
let newText = newSymbols;
let newRatingFormat = '';
if (ratingText) {
// Use existing rating text format
newRatingFormat = ratingText.format;
} else if (isFullOnly) {
// Auto-add HTML comment rating text for full-only symbols without rating text
newRatingFormat = 'comment-fraction';
}
if (newRatingFormat) {
const denominator = ratingText ? ratingText.denominator : unicodeLength;
const newRatingText = formatRatingText(
newRatingFormat,
newRating,
unicodeLength, // Always use actual pattern length for percentage calculations
denominator, // Use original denominator for fraction displays
!!symbolSet.half && !isFullOnly,
isFullOnly
);
newText = newSymbols + newRatingText;
}
// Update the document (replace both symbols and rating text)
view.dispatch({
changes: {
from: startPos,
to: endPos,
insert: newText
}
});
// Prevent keyboard from appearing on mobile by blurring the editor
view.contentDOM.blur();
if (LOGGING_ENABLED) {
console.info('[InteractiveRatings] Rating updated with auto-add HTML comment support', {
oldRating: rating,
newRating,
newSymbols,
newText,
hasRatingText: !!ratingText,
hasHalf: !!symbolSet.half,
isFullOnly,
autoAddedComment: !ratingText && isFullOnly,
newRatingFormat,
denominator: ratingText ? ratingText.denominator : unicodeLength,
position: { from: startPos, to: endPos }
});
}
} catch (error) {
if (LOGGING_ENABLED) {
console.error('[InteractiveRatings] Error updating rating', error);
}
}
}

View file

@ -1,65 +0,0 @@
import { Decoration } from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { LOGGING_ENABLED } from '../../constants';
import { RatingMatch } from '../RatingMatch';
import { RatingWidget } from '../RatingWidget/RatingWidget';
/**
* Build decorations from filtered matches, skipping those near the cursor
*/
export function buildDecorationsFromMatches(
matches: RatingMatch[],
cursorPos: number,
builder: RangeSetBuilder<Decoration>
): void {
// Add decorations in sorted order, but skip if cursor is nearby
for (const match of matches) {
// Skip creating widget if cursor is near this rating
if (cursorPos >= match.start - 1 && cursorPos <= match.end + 1) {
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Skipping widget creation due to nearby cursor', {
cursorPos,
ratingStart: match.start,
ratingEnd: match.end
});
}
continue;
}
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 && matches.length > 0) {
const skippedCount = matches.filter(m =>
cursorPos >= m.start - 1 && cursorPos <= m.end + 1
).length;
const fullOnlyCount = matches.filter(m => isFullOnlySymbol(m.symbolSet)).length;
const commentCount = matches.filter(m => m.ratingText && m.ratingText.format === 'comment-fraction').length;
const autoAddCandidates = matches.filter(m => isFullOnlySymbol(m.symbolSet) && !m.ratingText).length;
const shortPatternCount = matches.filter(m => getUnicodeCharLength(m.pattern) < 3).length;
console.debug(`[InteractiveRatings] Built ${matches.length - skippedCount}/${matches.length} rating decorations (${skippedCount} skipped due to cursor proximity)`, {
cursorPos,
withRatingText: matches.filter(m => m.ratingText).length,
symbolsOnly: matches.filter(m => !m.ratingText).length,
withHalfSymbols: matches.filter(m => m.symbolSet.half).length,
fullOnlySymbols: fullOnlyCount,
commentFormatSymbols: commentCount,
autoAddCandidates,
shortPatterns: shortPatternCount
});
}
}

View file

@ -1,75 +0,0 @@
import { generateSymbolRegexPatterns } from '../../ratings-parser/generateSymbolRegexPatterns';
import { getSymbolSetForPattern } from '../../ratings-parser/getSymbolSetForPattern';
import { calculateRating } from '../../ratings-parser/calculateRating';
import { parseRatingText } from '../../ratings-parser/parseRatingText/parseRatingText';
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { isFullOnlySymbol } from '../../utils/isFullOnlySymbol';
import { LOGGING_ENABLED } from '../../constants';
import { RatingMatch } from '../RatingMatch';
/**
* Collect all rating matches from the text
*/
export function collectMatches(text: string): RatingMatch[] {
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;
const symbolSet = getSymbolSetForPattern(pattern);
if (!symbolSet) continue;
const rating = calculateRating(pattern, symbolSet);
const isFullOnly = isFullOnlySymbol(symbolSet);
// For full-only symbols, skip if rating is 0 (not supported)
if (isFullOnly && rating === 0) continue;
// Check for rating text after the symbols - pass UTF-16 positions
const ratingText = parseRatingText(text, start, end);
// Simple rule: if no rating text, require minimum 3 symbols to avoid false positives
const unicodeLength = getUnicodeCharLength(pattern);
if (!ratingText && unicodeLength < 3) {
continue;
}
const actualEnd = ratingText ? ratingText.endPosition : end;
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Found rating match', {
pattern,
start,
end,
actualEnd,
unicodeLength,
rating,
hasRatingText: !!ratingText,
ratingTextDetails: ratingText,
symbolSet: symbolSet,
isFullOnly,
isCommentFormat: ratingText && ratingText.format === 'comment-fraction',
canAutoAddComment: isFullOnly && !ratingText
});
}
matches.push({
pattern,
start,
end: actualEnd,
symbolSet,
rating,
ratingText
});
}
}
return matches;
}

View file

@ -1,22 +0,0 @@
import { RatingMatch } from '../RatingMatch';
/**
* Filter overlapping matches, keeping the first one in each overlapping group
*/
export function filterOverlappingMatches(matches: RatingMatch[]): RatingMatch[] {
// Sort matches by start position (required by RangeSetBuilder)
const sortedMatches = [...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 sortedMatches) {
if (match.start >= lastEnd) {
filteredMatches.push(match);
lastEnd = match.end;
}
}
return filteredMatches;
}

View file

@ -1,56 +0,0 @@
import { EditorView, DecorationSet, ViewPlugin, ViewUpdate, Decoration } from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
import { LOGGING_ENABLED } from '../../constants';
import { collectMatches } from './collectMatches';
import { filterOverlappingMatches } from './filterOverlappingMatches';
import { buildDecorationsFromMatches } from './buildDecorationsFromMatches';
/**
* ViewPlugin to detect and replace rating patterns with interactive widgets
*/
export const ratingViewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
try {
const text = view.state.doc.toString();
const cursorPos = view.state.selection.main.head;
// Collect all matches first
const matches = collectMatches(text);
// Filter overlapping matches
const filteredMatches = filterOverlappingMatches(matches);
// Build decorations from filtered matches
buildDecorationsFromMatches(filteredMatches, cursorPos, builder);
} 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

@ -2,30 +2,10 @@ import { InteractiveRatingsPlugin } from './InteractiveRatingsPlugin';
// Re-export from core modules
export * from './constants';
export * from './utils';
export * from './types';
// Re-export from utils
export { getUnicodeCharLength } from './utils/getUnicodeCharLength';
export { getUnicodeSubstring } from './utils/getUnicodeSubstring';
export { utf16ToUnicodePosition } from './utils/utf16ToUnicodePosition';
export { isFullOnlySymbol } from './utils/isFullOnlySymbol';
export { generateSymbolsString } from './utils/generateSymbolsString';
export { generateSymbolsStringForDisk } from './utils/generateSymbolsStringForDisk';
export { calculateNewRating } from './utils/calculateNewRating';
export { formatRatingText } from './utils/formatRatingText';
export { updateSymbolPatternsFromSettings } from './utils/updateSymbolPatternsFromSettings';
// Re-export from ratings-parser
export { parseRatingText } from './ratings-parser/parseRatingText/parseRatingText';
export { getSymbolSetForPattern } from './ratings-parser/getSymbolSetForPattern';
export { calculateRating } from './ratings-parser/calculateRating';
export { escapeRegexChar } from './ratings-parser/escapeRegexChar';
export { generateSymbolRegexPatterns } from './ratings-parser/generateSymbolRegexPatterns';
// Re-export from editor-extension
export { ratingViewPlugin, ratingEditorExtension } from './editor-extension/ratingViewPlugin/ratingViewPlugin';
export { RatingWidget } from './editor-extension/RatingWidget/RatingWidget';
export { RatingMatch } from './editor-extension/RatingMatch';
export * from './ratings-parser';
export * from './editor-extension';
// Export the main plugin class
export default InteractiveRatingsPlugin;
export default InteractiveRatingsPlugin;

100
src/ratings-parser.ts Normal file
View file

@ -0,0 +1,100 @@
import { LOGGING_ENABLED, SYMBOL_PATTERNS } from './constants';
import { RatingText, SymbolSet } from './types';
import { getUnicodeCharLength, getUnicodeSubstring } from './utils';
/**
* Parse rating text from a line
*/
export function parseRatingText(line: string, start: number, end: number): RatingText | null {
// 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;
const result: RatingText = {
format,
numerator,
denominator,
text: ratingTextMatch[0],
endPosition: endPosition
};
return result;
}
return null;
}
/**
* Get the appropriate symbol set for a given rating pattern
*/
export function getSymbolSetForPattern(pattern: string): SymbolSet | null {
// 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;
}
/**
* Calculate the rating value from a pattern string
*/
export function calculateRating(pattern: string, symbolSet: SymbolSet): number {
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;
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Calculated pattern rating`, {
pattern,
full: symbolSet.full,
empty: symbolSet.empty,
half: symbolSet.half,
rating
});
};
return rating;
}
/**
* Generate regex patterns for detecting rating symbols in text
*/
export function generateSymbolRegexPatterns(): RegExp[] {
return SYMBOL_PATTERNS.map(symbols => {
const pattern = `[${symbols.full}${symbols.empty}${symbols.half ? symbols.half : ''}]{3,}`;
return new RegExp(pattern, 'g');
});
}

View file

@ -1,27 +0,0 @@
import { LOGGING_ENABLED } from '../constants';
import { SymbolSet } from '../types';
/**
* Calculate the rating value from a pattern string
*/
export function calculateRating(pattern: string, symbolSet: SymbolSet): number {
let rating = 0;
// Use Intl.Segmenter to properly iterate over grapheme clusters (handles ZWJ sequences)
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
for (const {segment: char} of segmenter.segment(pattern)) {
if (char === symbolSet.full) rating += 1.0;
else if (symbolSet.half && char === symbolSet.half) rating += 0.5;
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Calculated pattern rating`, {
pattern,
full: symbolSet.full,
empty: symbolSet.empty,
half: symbolSet.half,
rating
});
};
return rating;
}

View file

@ -1,6 +0,0 @@
/**
* Escape special regex characters, handling Unicode properly
*/
export function escapeRegexChar(char: string): string {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -1,33 +0,0 @@
import { LOGGING_ENABLED, SYMBOL_PATTERNS } from '../constants';
import { escapeRegexChar } from './escapeRegexChar';
/**
* Generate regex patterns for detecting rating symbols in text
* Fixed to handle multibyte Unicode characters (emojis) properly
* Now supports 1+ symbols to detect low ratings
*/
export function generateSymbolRegexPatterns(): RegExp[] {
return SYMBOL_PATTERNS.map(symbols => {
// Build alternation pattern instead of character class for emoji support
const symbolChars = [symbols.full, symbols.empty];
if (symbols.half) {
symbolChars.push(symbols.half);
}
// Escape each symbol for regex and create alternation
const escapedSymbols = symbolChars.map(escapeRegexChar);
// Changed from {3,} to {1,} to detect single symbols (fixes low rating bug)
const pattern = `(?:${escapedSymbols.join('|')})+`;
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Generated regex pattern for symbol set`, {
symbolSet: symbols,
pattern,
escapedSymbols,
allowsSingleSymbol: true
});
}
return new RegExp(pattern, 'g');
});
}

View file

@ -1,16 +0,0 @@
import { SYMBOL_PATTERNS } from '../constants';
import { SymbolSet } from '../types';
/**
* Get the appropriate symbol set for a given rating pattern
*/
export function getSymbolSetForPattern(pattern: string): SymbolSet | null {
// 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;
}

View file

@ -1,75 +0,0 @@
import { LOGGING_ENABLED } from '../../constants';
import { RatingText } from '../../types';
/**
* Build the final RatingText result from matched patterns
*/
export function buildRatingTextResult(
visibleMatch: RegExpMatchArray | null,
htmlCommentMatch: RegExpMatchArray | null,
totalMatchLength: number,
utf16End: number
): RatingText | null {
// Use visible rating if present, otherwise use comment
const match = visibleMatch || htmlCommentMatch;
const isComment = !visibleMatch && !!htmlCommentMatch;
const hasBothFormats = !!(visibleMatch && htmlCommentMatch);
if (match) {
let format = '';
let numerator = 0;
let denominator = 0;
if (isComment && htmlCommentMatch) {
// Simplified HTML comment format: <!-- 3/5 -->
format = 'comment-fraction';
numerator = parseFloat(htmlCommentMatch[1]);
denominator = parseInt(htmlCommentMatch[2]);
} else if (match[1] && match[2]) {
// (14.5/33) format
format = 'fraction-parentheses';
numerator = parseFloat(match[1]);
denominator = parseInt(match[2]);
} else if (match[3] && match[4]) {
// 14.5/33 format
format = 'fraction';
numerator = parseFloat(match[3]);
denominator = parseInt(match[4]);
} else if (match[5]) {
// 60% or (60%) format
const hasParens = match[0].includes('(') && match[0].includes(')');
format = hasParens ? 'percent-parentheses' : 'percent';
numerator = parseInt(match[5]);
denominator = 100;
}
// Calculate the end position correctly - use total length when both formats present
const endPosition = utf16End + totalMatchLength;
const result: RatingText = {
format,
numerator,
denominator,
text: match[0],
endPosition: endPosition
};
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] Found rating text', {
result,
matchedText: match[0],
isComment,
hasPrecedence: !isComment,
hasBothFormats,
visibleMatchLength: visibleMatch ? visibleMatch[0].length : 0,
commentMatchLength: htmlCommentMatch ? htmlCommentMatch[0].length : 0,
totalMatchLength,
calculatedEndPosition: endPosition
});
}
return result;
}
return null;
}

View file

@ -1,34 +0,0 @@
import { getUnicodeCharLength } from '../../utils/getUnicodeCharLength';
import { getUnicodeSubstring } from '../../utils/getUnicodeSubstring';
import { utf16ToUnicodePosition } from '../../utils/utf16ToUnicodePosition';
import { LOGGING_ENABLED } from '../../constants';
/**
* Convert UTF-16 positions to Unicode and extract text after symbols
*/
export function convertPositions(line: string, utf16Start: number, utf16End: number): {
unicodeStart: number;
unicodeEnd: number;
afterSymbols: string;
} {
// Convert UTF-16 positions to Unicode character positions
const unicodeStart = utf16ToUnicodePosition(line, utf16Start);
const unicodeEnd = utf16ToUnicodePosition(line, utf16End);
// Get the substring after the symbols using Unicode-aware functions
const afterSymbols = getUnicodeSubstring(line, unicodeEnd, getUnicodeCharLength(line));
if (LOGGING_ENABLED) {
console.debug('[InteractiveRatings] convertPositions debug', {
utf16Start,
utf16End,
unicodeStart,
unicodeEnd,
afterSymbols: afterSymbols.substring(0, 40) + '...',
lineLength: line.length,
unicodeLineLength: getUnicodeCharLength(line)
});
}
return { unicodeStart, unicodeEnd, afterSymbols };
}

View file

@ -1,37 +0,0 @@
/**
* Find rating patterns in text after symbols
*/
export function findRatingPatterns(afterSymbols: string): {
visibleMatch: RegExpMatchArray | null;
htmlCommentMatch: RegExpMatchArray | null;
totalMatchLength: number;
} {
// Check for regular rating patterns first (takes precedence over HTML comments)
const ratingTextMatch = afterSymbols.match(/^\s*(?:\(([\d\.]+)\/(\d+)\)|([\d\.]+)\/(\d+)|(?:\()?(\d+)%(?:\))?)/);
// Check for simplified HTML comment rating patterns (only basic fraction format)
const commentMatch = afterSymbols.match(/^\s*<!--\s*([\d\.]+)\/(\d+)\s*-->/);
// Look for both formats to handle precedence properly
let visibleMatch = null;
let htmlCommentMatch = null;
let totalMatchLength = 0;
if (ratingTextMatch) {
visibleMatch = ratingTextMatch;
totalMatchLength = ratingTextMatch[0].length;
// Check if there's also an HTML comment after the visible rating
const afterVisible = afterSymbols.substring(ratingTextMatch[0].length);
const followingCommentMatch = afterVisible.match(/^\s*<!--\s*([\d\.]+)\/(\d+)\s*-->/);
if (followingCommentMatch) {
htmlCommentMatch = followingCommentMatch;
totalMatchLength += followingCommentMatch[0].length;
}
} else if (commentMatch) {
htmlCommentMatch = commentMatch;
totalMatchLength = commentMatch[0].length;
}
return { visibleMatch, htmlCommentMatch, totalMatchLength };
}

View file

@ -1,21 +0,0 @@
import { RatingText } from '../../types';
import { convertPositions } from './convertPositions';
import { findRatingPatterns } from './findRatingPatterns';
import { buildRatingTextResult } from './buildRatingTextResult';
/**
* Parse rating text from a line, including HTML comment formats with precedence
* @param line The full line of text
* @param utf16Start UTF-16 byte position where symbols start
* @param utf16End UTF-16 byte position where symbols end
*/
export function parseRatingText(line: string, utf16Start: number, utf16End: number): RatingText | null {
// Convert UTF-16 positions to Unicode and extract text after symbols
const { afterSymbols } = convertPositions(line, utf16Start, utf16End);
// Find rating patterns in the text
const { visibleMatch, htmlCommentMatch, totalMatchLength } = findRatingPatterns(afterSymbols);
// Build and return the result
return buildRatingTextResult(visibleMatch, htmlCommentMatch, totalMatchLength, utf16End);
}

View file

@ -1,39 +0,0 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import { InteractiveRatingsPlugin } from './InteractiveRatingsPlugin';
import { InteractiveRatingsSettings } from './types';
const DEFAULT_SUPPORTED_EMOJIS = '🎥🏆⭐💎🔥⚡🎯🚀💰🎖️';
export const DEFAULT_SETTINGS: InteractiveRatingsSettings = {
supportedEmojis: DEFAULT_SUPPORTED_EMOJIS
};
export class InteractiveRatingsSettingTab extends PluginSettingTab {
plugin: InteractiveRatingsPlugin;
constructor(app: App, plugin: InteractiveRatingsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Interactive Ratings Settings' });
new Setting(containerEl)
.setName('Emojis to support in ratings')
.setDesc('Emojis to use for ratings.')
.addTextArea(text => text
.setPlaceholder(DEFAULT_SUPPORTED_EMOJIS)
.setValue(this.plugin.settings.supportedEmojis)
.onChange(async (value) => {
this.plugin.settings.supportedEmojis = value;
await this.plugin.saveSettings();
// Update the symbol patterns with new emojis
this.plugin.updateSymbolPatterns();
}));
}
}

View file

@ -11,7 +11,3 @@ export interface RatingText {
text: string;
endPosition: number;
}
export interface InteractiveRatingsSettings {
supportedEmojis: string;
}

149
src/utils.ts Normal file
View file

@ -0,0 +1,149 @@
import { LOGGING_ENABLED } from './constants';
/**
* Get the length of a string in Unicode characters
*/
export function getUnicodeCharLength(str: string): number {
return [...str].length;
}
/**
* Get a substring with proper Unicode character handling
*/
export function getUnicodeSubstring(str: string, start: number, end: number): string {
return [...str].slice(start, end).join('');
}
/**
* Calculate the new rating based on cursor position
*/
export function calculateNewRating(overlay: HTMLElement, clientX: number): number {
const containerRect = overlay.getBoundingClientRect();
const symbolCount = parseInt(overlay.dataset.symbolCount);
const symbolWidth = containerRect.width / symbolCount;
const relativeX = clientX - containerRect.left;
const hoveredSymbolIndex = Math.floor(relativeX / symbolWidth);
const positionWithinSymbol = (relativeX % symbolWidth) / symbolWidth;
// Determine if we should use half symbol or full symbol
const supportsHalf = overlay.dataset.supportsHalf === 'true';
const useHalfSymbol = supportsHalf && positionWithinSymbol < 0.5;
// Calculate rating
let newRating = hoveredSymbolIndex + (useHalfSymbol ? 0.5 : 1);
if (newRating < 0) {
newRating = 0;
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Calculated new rating`, {
clientX,
containerRect: {
left: containerRect.left,
width: containerRect.width
},
symbolCount,
symbolWidth,
relativeX,
hoveredSymbolIndex,
positionWithinSymbol,
supportsHalf,
useHalfSymbol,
newRating
});
}
return newRating;
}
/**
* Generate a string of rating symbols
*/
export function generateSymbolsString(
rating: number,
symbolCount: number,
full: string,
empty: string,
half: string,
supportsHalf: boolean
): string {
let newSymbols = '';
for (let i = 0; i < symbolCount; i++) {
if (i < Math.floor(rating)) {
newSymbols += full;
} else if (supportsHalf && i === Math.floor(rating) && rating % 1 !== 0) {
newSymbols += half;
} else {
newSymbols += empty;
}
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Generated symbols string`, {
rating,
symbolCount,
full,
empty,
half,
supportsHalf,
newSymbols
});
};
return newSymbols;
}
/**
* Format the rating text based on the specified format
*/
export function formatRatingText(
format: string,
newRating: number,
symbolCount: number,
denominator: number,
supportsHalf: boolean
): string {
let newNumerator;
if (format.includes('percent')) {
newNumerator = Math.round((newRating / symbolCount) * 100);
} else {
newNumerator = newRating;
if (!supportsHalf) {
newNumerator = Math.round(newNumerator);
}
}
// Format the text based on the original format
let formattedText = '';
switch (format) {
case 'fraction':
formattedText = ` ${newNumerator}/${denominator}`;
break;
case 'fraction-parentheses':
formattedText = ` (${newNumerator}/${denominator})`;
break;
case 'percent':
formattedText = ` ${newNumerator}%`;
break;
case 'percent-parentheses':
formattedText = ` (${newNumerator}%)`;
break;
default:
formattedText = '';
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Formatted rating text`, {
format,
newRating,
symbolCount,
denominator,
supportsHalf,
newNumerator,
formattedText
});
};
return formattedText;
}

View file

@ -1,43 +0,0 @@
import { LOGGING_ENABLED } from '../constants';
/**
* Calculate the new rating based on cursor position
*/
export function calculateNewRating(overlay: HTMLElement, clientX: number): number {
const containerRect = overlay.getBoundingClientRect();
const symbolCount = parseInt(overlay.dataset.symbolCount);
const symbolWidth = containerRect.width / symbolCount;
const relativeX = clientX - containerRect.left;
const hoveredSymbolIndex = Math.floor(relativeX / symbolWidth);
const positionWithinSymbol = (relativeX % symbolWidth) / symbolWidth;
// Determine if we should use half symbol or full symbol
const supportsHalf = overlay.dataset.supportsHalf === 'true';
const useHalfSymbol = supportsHalf && positionWithinSymbol < 0.5;
// Calculate rating
let newRating = hoveredSymbolIndex + (useHalfSymbol ? 0.5 : 1);
if (newRating < 0) {
newRating = 0;
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Calculated new rating`, {
clientX,
containerRect: {
left: containerRect.left,
width: containerRect.width
},
symbolCount,
symbolWidth,
relativeX,
hoveredSymbolIndex,
positionWithinSymbol,
supportsHalf,
useHalfSymbol,
newRating
});
}
return newRating;
}

View file

@ -1,69 +0,0 @@
import { LOGGING_ENABLED } from '../constants';
/**
* Format the rating text based on the specified format, including simplified HTML comments
*/
export function formatRatingText(
format: string,
newRating: number,
symbolCount: number,
denominator: number,
supportsHalf: boolean,
isFullOnlySymbol: boolean = false
): string {
let newNumerator;
if (format.includes('percent')) {
newNumerator = Math.round((newRating / symbolCount) * 100);
} else {
newNumerator = newRating;
if (!supportsHalf) {
newNumerator = Math.round(newNumerator);
}
}
// For full-only symbols with HTML comment format, remove comment if perfect rating
if (isFullOnlySymbol && format === 'comment-fraction' && newNumerator === denominator) {
return ''; // Remove HTML comment for perfect ratings
}
// Format the text based on the original format
let formattedText = '';
switch (format) {
case 'fraction':
formattedText = ` ${newNumerator}/${denominator}`;
break;
case 'fraction-parentheses':
formattedText = ` (${newNumerator}/${denominator})`;
break;
case 'percent':
formattedText = ` ${newNumerator}%`;
break;
case 'percent-parentheses':
formattedText = ` (${newNumerator}%)`;
break;
// Simplified HTML comment format (only basic fraction)
case 'comment-fraction':
formattedText = `<!-- ${newNumerator}/${denominator} -->`;
break;
default:
formattedText = '';
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Formatted rating text`, {
format,
newRating,
symbolCount,
denominator,
supportsHalf,
isFullOnlySymbol,
newNumerator,
formattedText,
isComment: format.startsWith('comment-'),
isPerfectRating: newNumerator === denominator,
removedComment: isFullOnlySymbol && format === 'comment-fraction' && newNumerator === denominator
});
};
return formattedText;
}

View file

@ -1,51 +0,0 @@
import { LOGGING_ENABLED } from '../constants';
import { SymbolSet } from '../types';
import { isFullOnlySymbol } from './isFullOnlySymbol';
/**
* Generate a string of rating symbols for display purposes
* For full-only symbols with rating text, this shows denominator count symbols
*/
export function generateSymbolsString(
rating: number,
symbolCount: number,
full: string,
empty: string,
half: string,
supportsHalf: boolean,
symbolSet?: SymbolSet,
denominator?: number
): string {
// For full-only symbols with rating text, use denominator for total count
const isFullOnly = symbolSet && isFullOnlySymbol(symbolSet);
const totalSymbols = (isFullOnly && denominator) ? denominator : symbolCount;
let newSymbols = '';
for (let i = 0; i < totalSymbols; i++) {
if (i < Math.floor(rating)) {
newSymbols += full;
} else if (supportsHalf && i === Math.floor(rating) && rating % 1 !== 0) {
newSymbols += half;
} else {
newSymbols += empty;
}
}
if (LOGGING_ENABLED) {
console.debug(`[InteractiveRatings] Generated symbols string`, {
rating,
symbolCount,
totalSymbols,
full,
empty,
half,
supportsHalf,
isFullOnly,
denominator,
newSymbols
});
};
return newSymbols;
}

View file

@ -1,25 +0,0 @@
import { SymbolSet } from '../types';
import { isFullOnlySymbol } from './isFullOnlySymbol';
import { generateSymbolsString } from './generateSymbolsString';
/**
* Generate a string of rating symbols for writing to disk (full-only symbols only include rated ones)
*/
export function generateSymbolsStringForDisk(
rating: number,
symbolCount: number,
full: string,
empty: string,
half: string,
supportsHalf: boolean,
symbolSet?: SymbolSet
): string {
// For full-only symbols, only write the rated symbols
if (symbolSet && isFullOnlySymbol(symbolSet)) {
const ratedCount = Math.floor(rating);
return full.repeat(ratedCount);
}
// For regular symbols, use the standard logic
return generateSymbolsString(rating, symbolCount, full, empty, half, supportsHalf, symbolSet);
}

View file

@ -1,8 +0,0 @@
/**
* Get the length of a string in Unicode grapheme clusters
* This properly handles ZWJ sequences, variation selectors, and complex emoji
*/
export function getUnicodeCharLength(str: string): number {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return Array.from(segmenter.segment(str)).length;
}

View file

@ -1,8 +0,0 @@
/**
* Get a substring with proper Unicode grapheme cluster handling
* This properly handles ZWJ sequences, variation selectors, and complex emoji
*/
export function getUnicodeSubstring(str: string, start: number, end: number): string {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return Array.from(segmenter.segment(str), segment => segment.segment).slice(start, end).join('');
}

View file

@ -1,8 +0,0 @@
import { SymbolSet } from '../types';
/**
* Check if a symbol set is full-only (same symbol for full and empty, no half)
*/
export function isFullOnlySymbol(symbolSet: SymbolSet): boolean {
return symbolSet.full === symbolSet.empty && !symbolSet.half;
}

View file

@ -1,43 +0,0 @@
import { LOGGING_ENABLED, BASE_SYMBOL_PATTERNS, updateSymbolPatterns } from '../constants';
import { InteractiveRatingsSettings } from '../types';
/**
* Update symbol patterns based on user settings
*/
export function updateSymbolPatternsFromSettings(settings: InteractiveRatingsSettings): void {
// Start with base patterns (all the existing ones except the customizable emojis)
const newPatterns = [...BASE_SYMBOL_PATTERNS];
// Add emoji patterns from settings
if (settings.supportedEmojis) {
// Split the emoji string into individual grapheme clusters (handling ZWJ sequences properly)
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emojis = Array.from(segmenter.segment(settings.supportedEmojis), segment => segment.segment);
if (LOGGING_ENABLED) {
console.log('[InteractiveRatings] Parsed emojis from settings:', emojis);
}
for (const emoji of emojis) {
if (emoji.trim()) { // Skip empty characters and whitespace
newPatterns.push({
full: emoji,
empty: emoji,
half: null
});
}
}
}
// Update the global symbol patterns
updateSymbolPatterns(newPatterns);
if (LOGGING_ENABLED) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
console.debug('[InteractiveRatings] Updated symbol patterns from settings', {
supportedEmojis: settings.supportedEmojis,
parsedEmojis: settings.supportedEmojis ? Array.from(segmenter.segment(settings.supportedEmojis), segment => segment.segment) : [],
totalPatterns: newPatterns.length
});
}
}

View file

@ -1,11 +0,0 @@
import { getUnicodeCharLength } from './getUnicodeCharLength';
/**
* Convert UTF-16 byte position to Unicode character position
*/
export function utf16ToUnicodePosition(str: string, utf16Position: number): number {
// Get the substring up to the UTF-16 position
const utf16Substring = str.substring(0, utf16Position);
// Return the Unicode character length of that substring
return getUnicodeCharLength(utf16Substring);
}

View file

@ -38,78 +38,11 @@
color: inherit;
}
/* Base symbol styling */
.interactive-rating-symbol {
position: relative;
cursor: pointer;
display: inline-block;
transition: opacity 0.2s ease, filter 0.2s ease;
}
/*
* THEMING HOOKS - Symbol State Classes
* ===================================
* The following CSS classes are provided as theming hooks for customizing
* the appearance of rating symbols. No default visual styling is applied
* to maintain theme neutrality. Add your own styles to customize appearance.
*
* For comprehensive styling examples and documentation, see CSS-CUSTOMIZATION.md
*/
/*
* Filled/Active symbol state ( in regular systems, active in full-only systems)
* Applied to: symbols representing the filled portion of a rating
* Use for: styling fully selected symbols (e.g., color, opacity, effects)
*/
.interactive-rating-symbol--rated {
/* Add your custom styles here */
}
/*
* Unrated symbol state ( beyond rating in full-only systems)
* Applied to: symbols in full-only systems that are beyond the current rating
* Use for: styling inactive symbols in systems that only have one symbol type
* Note: This is different from 'empty' - these are the same symbol but inactive
*/
.interactive-rating-symbol--unrated {
/* Add your custom styles here */
}
/*
* Normal symbol state (legacy compatibility)
* Applied to: symbols in legacy mode (deprecated)
* Note: This class is maintained for backward compatibility only
*/
.interactive-rating-symbol--normal {
/* Add your custom styles here */
}
/*
* Empty symbol state ( in regular rating systems)
* Applied to: empty/outline symbols representing unselected portions of rating
* Use for: styling empty symbols in systems with distinct filled/empty symbols
* Note: This is different from 'unrated' - these are different symbol characters
*/
.interactive-rating-symbol--empty {
/* Add your custom styles here */
}
/*
* Half symbol state ( or similar in regular rating systems)
* Applied to: half-filled symbols representing partial ratings
* Use for: styling half-symbols (e.g., gradients, special colors, opacity)
* Common styling: gradients to show half-fill effect
*/
.interactive-rating-symbol--half {
/* Add your custom styles here */
}
/* Individual symbol spans in editor widget with half-symbol support */
.interactive-rating-editor-widget .interactive-rating-symbols span {
position: relative;
cursor: pointer;
display: inline-block;
transition: opacity 0.2s ease, filter 0.2s ease;
}
/* Enhanced hover effect for symbols with half-symbol visual feedback */
@ -141,47 +74,6 @@
/* No visual elements */
}
/*
* FULL-ONLY SYMBOL SYSTEM THEMING HOOKS
* =====================================
* The following selectors target symbols in full-only rating systems specifically.
* These provide more granular control when you want different styling for symbols
* in full-only systems (which only have one symbol type) vs regular systems.
* No default styling is applied - add your own styles as needed.
*/
/* Full-only symbol styles - structural transitions only */
.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbols span {
transition: opacity 0.2s ease, filter 0.2s ease;
}
/*
* Full-only unrated symbols ( beyond rating in full-only systems)
* Applied to: symbols beyond the current rating in systems with only one symbol type
* Use for: custom styling of inactive symbols in full-only systems
* Default styling: Essential for functionality - without this, full-only ratings would be unreadable
*/
.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbol--unrated {
opacity: 0.5 !important;
filter: grayscale(100%) !important;
}
/*
* Full-only rated symbols ( within rating in full-only systems)
* Applied to: symbols within the current rating in systems with only one symbol type
* Use for: custom styling of active symbols in full-only systems
* Default styling: Ensures rated symbols remain fully visible
*/
.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbol--rated {
opacity: 1 !important;
filter: none !important;
}
/* Support for display symbol count different from pattern length */
.interactive-rating-editor-widget[data-display-symbol-count] .interactive-rating-symbols {
/* Styles for symbols with extended display count */
}
/* Mobile/touch enhancements for editor */
@media (max-width: 768px) {
.interactive-rating-editor-widget {
@ -204,24 +96,6 @@
.interactive-rating-editor-widget[data-supports-half="true"]:hover .interactive-rating-symbols span::before {
/* No visual elements */
}
/* Enhanced contrast for full-only symbols using class-based approach */
.interactive-rating-editor-widget[data-full-only="true"] .interactive-rating-symbol--unrated {
opacity: 0.3 !important;
filter: grayscale(100%) contrast(0.7) !important;
}
/* Enhanced contrast for empty symbols in regular rating systems */
.interactive-rating-symbol--empty {
opacity: 0.3 !important;
filter: grayscale(100%) contrast(0.7) !important;
}
/* Enhanced contrast for half symbols in regular rating systems */
.interactive-rating-symbol--half {
opacity: 0.6 !important;
filter: contrast(1.2) !important;
}
}
/* Reduced motion accessibility */
@ -229,9 +103,8 @@
.interactive-rating-editor-widget,
.interactive-rating-editor-widget .interactive-rating-symbols span,
.interactive-rating-editor-widget .interactive-rating-symbols span::before,
.interactive-rating-editor-widget .interactive-rating-symbols span::after,
.interactive-rating-symbol {
transition: none;
.interactive-rating-editor-widget .interactive-rating-symbols span::after {
/* No transitions to remove */
}
}
@ -246,5 +119,5 @@
/* Smooth symbol transitions during preview */
.interactive-rating-editor-widget .interactive-rating-symbols span {
/* Transitions defined above */
/* No transitions */
}