Compare commits

...

48 commits
1.0.4 ... main

Author SHA1 Message Date
Filip Noetzel
3bf43556e9 Fix bumpversion config to generate tag names without "v" prefix 2025-09-10 07:32:24 +02:00
Filip Noetzel
3816304fb8 Bump version: 1.0.7 → 1.0.8 2025-09-09 15:51:49 +02:00
Filip Noetzel
6e28f2a726 bumpversion config 2025-09-09 15:50:18 +02:00
Filip Noetzel
3c5ef7595b fix LOGGING_ENABLED propagation 2025-09-09 15:35:45 +02:00
Filip Noetzel
6d3323601c Add logging to emoji settings parsing
- Add console.log output to dump parsed emoji array when LOGGING_ENABLED
- No changes to functionality or output, just debug visibility
- Shows exactly how ZWJ sequences and other emojis are being parsed
2025-09-09 14:31:42 +02:00
Filip Noetzel
fb5c9496f1 Fix ZWJ sequence handling in calculateRating function
Replaced [...pattern] iteration with Intl.Segmenter to properly handle ZWJ sequences like the pirate flag emoji 🏴‍☠️. This ensures that complex emojis are treated as single grapheme clusters rather than being split into component Unicode code points, fixing the issue where pirate flag emojis were getting a rating of 0 instead of the correct value.

This brings the calculateRating function in line with the rest of the codebase which already uses Intl.Segmenter for proper Unicode handling.
2025-09-09 14:29:35 +02:00
Filip Noetzel
c8b6605e7a Fix ZWJ sequence emoji support in custom emoji settings
- Add getGraphemeClusters.ts utility for proper emoji segmentation
- Update updateSymbolPatternsFromSettings.ts to handle ZWJ sequences correctly  
- Enhance Unicode utilities to use grapheme clusters instead of UTF-16 code units
- Support complex emojis like 🏴‍☠️ (Pirate Flag), 👨‍🚒 (Man Firefighter), etc.
- Uses Intl.Segmenter when available, with regex fallback for older environments
- Fully backward compatible with existing emoji configurations
- Update documentation and add changelog entry

Fixes bug where ZWJ sequence emojis were split into component parts instead of being treated as single emojis.

Simplify ZWJ sequence fix by inlining Intl.Segmenter

- Remove overengineered getGraphemeClusters.ts utility 
- Inline simple Intl.Segmenter usage directly where needed
- Assume Intl.Segmenter is available in all target environments
- Reduced bundle size and simplified codebase
- Update documentation to reflect simplified approach
2025-09-09 14:29:18 +02:00
Filip Noetzel
ae6d438929 add build-and-install command 2025-09-06 15:52:56 +02:00
Filip Noetzel
0dd7b2ec75 only add main.js to gitignore 2025-09-06 15:09:43 +02:00
Filip Noetzel
04c19b818a Restore default styling for full-only unrated symbols 2025-06-24 08:14:42 +02:00
Filip Noetzel
5cd8cd32b9 Bump version: 1.0.6 → 1.0.7 2025-06-24 08:14:42 +02:00
Filip Noetzel
ebcb212610 Replace default visual styles with sophisticated theming placeholder comments
- Replaced all default visual styling (opacity, filters) with comprehensive theming hook comments
- Updated main symbol state classes with detailed documentation for their purpose and usage
- Updated full-only symbol system styling with sophisticated placeholder comments
- Preserved accessibility styling for high contrast mode only
- Updated CSS-CUSTOMIZATION.md to reflect no default styling approach
- Added prominent warning about no default styling and provided quick start example
- Updated example numbering and added basic visual distinction example
- Enhanced accessibility section to clarify it's the only built-in styling

The plugin now provides pure theming hooks without imposing any visual choices, while maintaining comprehensive accessibility support and detailed documentation for users and developers.
2025-06-24 06:46:27 +02:00
Filip Noetzel
9b21e5e6bd Add distinct CSS class for half symbols and enhance customization
- Updated applySymbolState.ts to support 'half' state for half-filled symbols
- Modified renderRating.ts to apply 'half' state to half symbols (instead of 'rated')
- Updated previewRating.ts to use distinct states during hover preview for consistency
- Added CSS styling for .interactive-rating-symbol--half class with 80% opacity
- Added high contrast accessibility support for half symbols (60% opacity with enhanced contrast)
- Enhanced CSS-CUSTOMIZATION.md with comprehensive examples for half symbols including:
  * Basic color styling for filled/half/empty symbols
  * Gradient effects for half symbols
  * Enhanced hover effects for all three states
  * Different styling examples for various symbol types
  * Updated default styling documentation
  * Enhanced accessibility considerations
  * Updated migration guide

This completes the full CSS customization system with distinct classes for:
- .interactive-rating-symbol--rated (filled symbols)
- .interactive-rating-symbol--half (half symbols) 
- .interactive-rating-symbol--empty (empty symbols)
- .interactive-rating-symbol--unrated (full-only grayed symbols)
2025-06-24 06:36:18 +02:00
Filip Noetzel
eadace6854 Add distinct CSS classes for selected and non-selected rating symbols
- Updated applySymbolState.ts to support 'empty' state for unselected symbols
- Modified renderRating.ts to apply 'rated' state to filled symbols and 'empty' state to empty symbols
- Added CSS styling for .interactive-rating-symbol--empty class with 50% opacity
- Added high contrast accessibility support for empty symbols
- Created comprehensive CSS-CUSTOMIZATION.md guide with examples

This enables users to customize the appearance of filled vs empty symbols using CSS snippets, addressing the original request for better visual distinction between selected and non-selected symbols.
2025-06-24 06:29:55 +02:00
Filip Noetzel
5fe87e5c50 Fix hardcoded logging: use esbuild's sophisticated mechanism with guarding comments
- Replace hardcoded LOGGING_ENABLED=false with process.env.LOGGING_ENABLED
- Add extensive guarding comments to prevent manual changes
- Ensure runtime logging is controlled via npm scripts (build vs build-debug)
- Maintain zero runtime overhead for production builds via esbuild's define feature
2025-06-20 05:43:46 +02:00
Filip Noetzel
3e32fc1d97 Fix mobile keyboard appearing when clicking ratings by blurring editor after updates 2025-06-10 12:56:07 +02:00
Filip Noetzel
97263123b3 WORKS 2025-06-10 12:44:23 +02:00
Filip Noetzel
b48938ae01 Successfully tested types and build after refactoring - all tests pass 2025-06-10 12:01:12 +02:00
Filip Noetzel
f5f2ab6537 Fix all import paths after refactoring large files 2025-06-10 11:56:50 +02:00
Filip Noetzel
aa45cf217f Refactor parseRatingText.ts into modular functions and delete original file 2025-06-10 11:55:30 +02:00
Filip Noetzel
607913f700 Extract buildRatingTextResult function from parseRatingText.ts 2025-06-10 11:55:09 +02:00
Filip Noetzel
1349c7ac41 Extract findRatingPatterns function from parseRatingText.ts 2025-06-10 11:54:42 +02:00
Filip Noetzel
cd9806b428 Extract convertPositions function from parseRatingText.ts 2025-06-10 11:54:22 +02:00
Filip Noetzel
fea9a30577 Refactor ratingViewPlugin.ts into modular functions and delete original file 2025-06-10 11:53:20 +02:00
Filip Noetzel
6b4275da08 Extract buildDecorationsFromMatches function from ratingViewPlugin.ts 2025-06-10 11:52:58 +02:00
Filip Noetzel
785efc88ed Extract filterOverlappingMatches function from ratingViewPlugin.ts 2025-06-10 11:52:35 +02:00
Filip Noetzel
7c9ac9d5cd Extract collectMatches function from ratingViewPlugin.ts 2025-06-10 11:52:21 +02:00
Filip Noetzel
d42786db60 Refactor RatingWidget.ts into modular functions and delete original file 2025-06-10 11:51:41 +02:00
Filip Noetzel
c73557fee9 Extract updateRating function from RatingWidget.ts 2025-06-10 11:51:07 +02:00
Filip Noetzel
b0590c959a Extract renderRating function from RatingWidget.ts 2025-06-10 11:50:36 +02:00
Filip Noetzel
c43886f062 Extract previewRating function from RatingWidget.ts 2025-06-10 11:50:15 +02:00
Filip Noetzel
784e2f905e Extract applySymbolState function from RatingWidget.ts 2025-06-10 11:49:48 +02:00
Filip Noetzel
2e53c1e164 Extract calculateRatingFromHover function from RatingWidget.ts 2025-06-10 11:49:06 +02:00
Filip Noetzel
ca5cd0fd46 Extract calculateRatingFromClick function from RatingWidget.ts 2025-06-10 11:48:50 +02:00
Filip Noetzel
0a7997187a Bump version: 1.0.5 → 1.0.6 2025-06-10 09:25:20 +02:00
Filip Noetzel
cc7ea8ead9 Refactor inline styles to semantic CSS classes
- Removed all inline style assignments from RatingWidget.ts
- Added semantic CSS classes for symbol states (rated, unrated, normal)
- Implemented clean CSS class management with applySymbolState() method
- Updated styles.css with proper semantic class definitions
2025-06-10 09:19:58 +02:00
Filip Noetzel
f163bf7e60 Correct percentage calculation for rating text
Fixed formatRatingText to use actual pattern length instead of parsed denominator for percentage calculations. This fixes ◼◼◼▭ showing 3% instead of 75%. The issue was that percentage parsing sets denominator=100, but calculations need actual symbol count (4), so (3/4)*100=75%.
2025-06-10 09:16:32 +02:00
Filip Noetzel
f0b00e11e3 Removed index.ts files and updated all imports to reference specific files directly. Updated imports in InteractiveRatingsPlugin.ts, ratingViewPlugin.ts, RatingWidget.ts, parseRatingText.ts, and main.ts. All type checks and builds pass successfully. 2025-06-10 09:02:11 +02:00
Filip Noetzel
b60874efdc Refactored large TypeScript files (utils.ts, ratings-parser.ts, editor-extension.ts) by splitting them into smaller, organized files within directories. Each function now has its own file with proper imports/exports through index files.
Fixed import issue in editor-extension/index.ts - added missing import for ratingViewPlugin before using it in the export array. All type checks and builds now pass successfully.
2025-06-10 08:56:19 +02:00
Filip Noetzel
40b05f606a Re-arrange README 2025-06-10 08:36:37 +02:00
Filip Noetzel
c17ec8be7a Bump version: 1.0.4 → 1.0.5 2025-06-10 08:23:36 +02:00
Filip Noetzel
6ad882397a Add Configuration section to README explaining custom emoji settings and usage 2025-06-10 08:22:36 +02:00
Filip Noetzel
2ea7af7bbd Add setting for customizable emoji support in ratings
Added InteractiveRatingsSettings interface, settings tab, and dynamic symbol pattern updates. Users can now customize supported emojis through plugin settings.

Default: 🎥🏆💎🔥🎯🚀💰🎖️

Fix regex detection bug for low ratings (1-2 stars)

Modified regex to detect 1+ symbols instead of 3+, and updated editor logic to intelligently handle short patterns with rating text or full-only symbols. Fixes issue where reducing ratings to 1-2 stars made them uneditable.

Simplify pattern detection logic - remove complex special cases

Replaced complex logic with simple rule: if no rating text, require 3+ symbols to avoid false positives. If rating text exists, any symbol count is valid. This generic approach handles all cases without special full-only symbol logic.

Remove hardcoded configurable emojis from constants.ts since they're now managed through settings
2025-06-10 08:22:36 +02:00
Filip Noetzel
3c0cf4029a Add HTML comment rating text support for clean display
- Parse HTML comment patterns: <!-- 3/5 -->
- New comment formats: comment-fraction, comment-percent, etc.
- Hide comment text in visual display while preserving functionality
- Full-only symbols work with comments for clean reading experience

- Auto-add HTML comments when clicking full-only symbols without rating text
- Auto-remove HTML comments when numerator equals denominator (perfect ratings)
- Simplified to basic <!-- 3/5 --> format only (no parentheses/percentages)
- HTML comments restricted to full-only symbols only
- Visible rating text takes precedence over HTML comments

Remove HTML comment when both visible and comment rating text present

- Enhanced parseRatingText to detect both visible and HTML comment formats
- Visible rating text takes precedence for calculation
- Mixed format like "💎💎💎 (3/5)<!-- 4/5 -->" becomes "💎💎💎💎 (4/5)" on change
- Clean, predictable precedence behavior with automatic cleanup
2025-06-09 22:55:01 +02:00
Filip Noetzel
1218efd3aa Add support for full-only rating symbols like 🎥
- Added fullOnly flag to SymbolSet interface
- Added 10 new full-only symbols (🎥, 🏆, , 💎, 🔥, , 🎯, 🚀, 💰, 🎖️)
- Visual: rated symbols full color, unrated grey 50% opacity
- Disk: only rated symbols saved (e.g., 🎥🎥🎥 (3/5))
- No 0 ratings, no half ratings for full-only symbols
- Updated CSS, tests, and documentation

Refactor full-only symbols: auto-detect, show denominator count, require rating text

- Remove fullOnly flag, auto-detect with isFullOnlySymbol()
- Visual: show denominator count (numerator color, rest grey)  
- Require rating text for denominator info
- Disk: save only rated symbols
- Updated docs and tests

Clarify and verify full-only symbol visual behavior

- Added debug test files to verify expected behavior
- Confirmed visual display always shows denominator count symbols
- Rated symbols in full color, unrated in grey 50% opacity
- Implementation already correct: displaySymbolCount uses denominator
- Added debug logging capability for troubleshooting
2025-06-09 22:54:41 +02:00
Filip Noetzel
014446d672 Add support for colored hearts
- Add red hearts (❤️/🤍)
- Add orange, yellow, green, blue, purple, black, and brown hearts
- All heart patterns use white heart (🤍) as empty symbol
- No half-heart support (half: null) for all heart patterns

Add heart rating examples to README
2025-06-09 21:28:59 +02:00
Filip Noetzel
a792713e3c Add moon phase emoji symbol set (🌕🌑🌗) with Unicode-aware parsing support
Fix multibyte emoji handling: replace pattern.length with Unicode-aware length calculation and fix regex generation for emojis

Fix emoji rating text parsing by converting UTF-16 positions to Unicode positions
2025-06-09 21:04:54 +02:00
Filip Noetzel
22b4fbd9d4 Add missing Star Symbols (✦/✧) to README documentation table 2025-06-09 14:03:45 +02:00
49 changed files with 1953 additions and 728 deletions

44
.bumpversion-preview.toml Normal file
View file

@ -0,0 +1,44 @@
[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,19 +1,51 @@
[tool.bumpversion]
current_version = "1.0.4"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
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
]
commit = true
tag = true
tag_name = "{new_version}"
tag_message = "Version {new_version}"
tag_message = "Release {new_version}"
message = "Bump version: {current_version} → {new_version}"
regex = true
[[tool.bumpversion.files]]
filename = "manifest.json"
search = '"version": "{current_version}"'
replace = '"version": "{new_version}"'
# RELEASE CONFIG: Update package.json, manifest.json, AND sync preview config
[[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 @@
*.js
main.js
node_modules/

203
CSS-CUSTOMIZATION.md Normal file
View file

@ -0,0 +1,203 @@
# 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,19 +2,42 @@
Add interactive rating symbols to your notes that update with a click.
## Usage
<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
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:
@ -27,24 +50,33 @@ Add numerical ratings with these formats:
The numerical rating updates automatically when you change the symbols.
## Supported Symbol Sets
### 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
| 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': JSON.stringify(loggingEnabled)
'process.env.LOGGING_ENABLED': loggingEnabled ? '"true"' : '"false"'
},
external: [
'obsidian',

146
install-dev.js Normal file
View file

@ -0,0 +1,146 @@
#!/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.4",
"version": "1.0.8",
"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.1",
"version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-interactive-ratings",
"version": "1.0.1",
"version": "1.0.7",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.25.1",

View file

@ -1,13 +1,24 @@
{
"name": "obsidian-interactive-ratings",
"version": "1.0.4",
"version": "1.0.8",
"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",
"type-check": "tsc --noEmit"
"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"
},
"keywords": [
"obsidianmd"

View file

@ -1,16 +1,30 @@
import { Plugin } from 'obsidian';
import { LOGGING_ENABLED } from './constants';
import { ratingEditorExtension } from './editor-extension';
import { ratingEditorExtension } from './editor-extension/ratingViewPlugin/ratingViewPlugin';
import { InteractiveRatingsSettings } from './types';
import { DEFAULT_SETTINGS, InteractiveRatingsSettingTab } from './settings';
import { updateSymbolPatternsFromSettings } from './utils/updateSymbolPatternsFromSettings';
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');
}
@ -21,4 +35,16 @@ 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,16 +1,46 @@
import { SymbolSet } from './types';
// Global logging control - set at build time via environment variable
// ============================================================================
// 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.
// ============================================================================
export const LOGGING_ENABLED = process.env.LOGGING_ENABLED === 'true';
// Define symbol patterns as a global constant
export const SYMBOL_PATTERNS: SymbolSet[] = [
// Base symbol patterns (excluding user-configurable emojis)
export const BASE_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
@ -24,6 +54,20 @@ export const 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

View file

@ -1,424 +0,0 @@
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

@ -0,0 +1,11 @@
/**
* 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

@ -0,0 +1,101 @@
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

@ -0,0 +1,10 @@
/**
* 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

@ -0,0 +1,35 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,82 @@
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

@ -0,0 +1,69 @@
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

@ -0,0 +1,94 @@
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

@ -0,0 +1,65 @@
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,22 @@
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

@ -0,0 +1,56 @@
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,10 +2,30 @@ import { InteractiveRatingsPlugin } from './InteractiveRatingsPlugin';
// Re-export from core modules
export * from './constants';
export * from './utils';
export * from './types';
export * from './ratings-parser';
export * from './editor-extension';
// 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 the main plugin class
export default InteractiveRatingsPlugin;
export default InteractiveRatingsPlugin;

View file

@ -1,100 +0,0 @@
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

@ -0,0 +1,27 @@
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

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

View file

@ -0,0 +1,33 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,37 @@
/**
* 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

@ -0,0 +1,21 @@
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);
}

39
src/settings.ts Normal file
View file

@ -0,0 +1,39 @@
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,3 +11,7 @@ export interface RatingText {
text: string;
endPosition: number;
}
export interface InteractiveRatingsSettings {
supportedEmojis: string;
}

View file

@ -1,149 +0,0 @@
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

@ -0,0 +1,43 @@
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

@ -0,0 +1,69 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1,8 @@
/**
* 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

@ -0,0 +1,8 @@
/**
* 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

@ -0,0 +1,8 @@
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

@ -0,0 +1,43 @@
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

@ -0,0 +1,11 @@
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,11 +38,78 @@
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 */
@ -74,6 +141,47 @@
/* 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 {
@ -96,6 +204,24 @@
.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 */
@ -103,8 +229,9 @@
.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 {
/* No transitions to remove */
.interactive-rating-editor-widget .interactive-rating-symbols span::after,
.interactive-rating-symbol {
transition: none;
}
}
@ -119,5 +246,5 @@
/* Smooth symbol transitions during preview */
.interactive-rating-editor-widget .interactive-rating-symbols span {
/* No transitions */
/* Transitions defined above */
}