Compare commits

..

60 commits
1.0.2 ... 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
Filip Noetzel
b86ba20f2d Bump version: 1.0.3 → 1.0.4 2025-06-09 09:41:24 +02:00
Filip Noetzel
c3abe0f9ee Update rating widget to use symbol count as denominator
- Modified previewRating() to show updated denominator during hover
- Modified updateRating() to save the symbol count as the new denominator
- When clicking on ●●●●●●●◐ (7.5/23), it now updates to (7.5/8) instead of keeping 23
- Enhanced logging to track old vs new denominators
2025-06-09 09:38:06 +02:00
Filip Noetzel
4b4cef482c Remove all visual indicators, margins, borders while keeping CSS classes and cursor pointer functionality 2025-06-09 09:38:06 +02:00
Filip Noetzel
0a65dbba48 Implement cursor-based widget toggle for editable ratings
- Add cursor position tracking to ViewPlugin update method
- Skip widget creation when cursor is within 1 character of rating
- Allows normal text editing when cursor is near ratings
- Automatically restores widgets when cursor moves away
- Enhanced debug logging for cursor proximity detection
2025-06-09 08:37:27 +02:00
Filip Noetzel
1c929aff48 Add support for ✦✧✧ symbols
Fixes #2.
2025-06-08 10:45:54 +02:00
Filip Noetzel
4a5e7d65f8 Remove support for editing ratings in reading mode - make widgets display-only while keeping edit mode interactive
Remove all reading mode support - edit mode only plugin

- Deleted markdown-postprocessor.ts (reading mode widgets)
- Deleted inline-rating-widget.ts (reading mode custom elements)  
- Updated plugin to only register editor extension
- Cleaned CSS to only include editor widget styles
- Updated README to clarify edit-mode-only functionality
- Bundle size reduced from 24.1kb to 16.1kb
2025-06-08 10:45:54 +02:00
Filip Noetzel
f7aa86a5bb Add half-symbol support to CodeMirror implementation
- Enhanced editor widget with click position detection for half vs full symbols
- Added hover preview with proper half-symbol rendering  
- Expanded symbol patterns with more half-symbol definitions
- Improved CSS styling with visual feedback for half-symbol interactions
- Added comprehensive test file with examples
2025-06-08 10:45:54 +02:00
Filip Noetzel
a8b30bea88 Add CodeMirror editor extension to make ratings interactive in editing mode 2025-06-08 10:45:54 +02:00
Filip Noetzel
cedf7c01da Add build-time LOGGING_ENABLED environment variable support
- Modified esbuild.config.js to read LOGGING_ENABLED from environment and pass as define option
- Updated src/constants.ts to use process.env.LOGGING_ENABLED instead of hardcoded value
- Added 'npm run build-debug' script to package.json for debug builds
- Logging is now controlled at build time via environment variable, simplifying the debug workflow
2025-06-08 10:45:54 +02:00
Filip Noetzel
68ed60976c setup codemcp.toml 2025-06-08 10:45:54 +02:00
Filip Noetzel
45883a0d13 Bump version: 1.0.2 → 1.0.3 2025-04-02 17:40:47 +02:00
Filip Noetzel
bc8ea924b8 refactor: move static CSS to styles.css file
move all the static css (not the one calculated at runtime) to the styles.css file

```git-revs
77478de  (Base revision)
5b3d66e  Remove static CSS from ratings-overlay.ts and only keep dynamic styles
142a142  Remove static CSS from symbol span elements
c72fd23  Update the overlay CSS with static styles that were previously in JS
HEAD     Update symbol styles with static styles that were previously in JS
```

codemcp-id: 52-refactor-move-static-css-to-styles-css-file
2025-04-02 17:26:00 +02:00
51 changed files with 2134 additions and 1200 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.2"
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

36
codemcp.toml Normal file
View file

@ -0,0 +1,36 @@
prompt = """
You are an expert software developer.
Development instructions:
- ...
Commit messages:
- Write a commit message in imperative mood (e.g., "Refactor X", not "Refactored X").
- Do not describe success or effort (e.g., avoid "Successfully", "Cleaned up", etc.).
- Just state what the commit does, not how well or completely it does it.
"""
paths_blocked = [
# Version control internals (major space wasters)
".jj/**",
".git/**",
# Node modules
"node_modules/**",
]
[commands]
# Standard build
build = ["npm", "run", "build"]
# Debug build
build_debug = ["npm", "run", "build:debug"]
# Type checking
type_check = ["npm", "run", "type-check"]
# Install packages
npm_install = ["npm", "install"]
# bump version
bump_version_patch = ["bump-my-version", "bump", "patch", "-vv"]

View file

@ -11,6 +11,10 @@ Interactive Ratings Plugin for Obsidian (https://obsidian.md)
// Check if watch flag is provided
const isWatch = process.argv.includes('--watch');
// Get logging configuration from environment variable
const loggingEnabled = process.env.LOGGING_ENABLED === 'true';
console.log(`Building with logging ${loggingEnabled ? 'ENABLED' : 'DISABLED'}`);
// Build configuration
const buildOptions = {
entryPoints: ['src/main.ts'],
@ -20,7 +24,37 @@ const buildOptions = {
target: 'es2016',
format: 'cjs',
banner: { js: banner },
external: ['obsidian'],
define: {
'process.env.LOGGING_ENABLED': loggingEnabled ? '"true"' : '"false"'
},
external: [
'obsidian',
'electron',
'codemirror',
'@codemirror/autocomplete',
'@codemirror/closebrackets',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/comment',
'@codemirror/fold',
'@codemirror/gutter',
'@codemirror/highlight',
'@codemirror/history',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/matchbrackets',
'@codemirror/panel',
'@codemirror/rangeset',
'@codemirror/rectangular-selection',
'@codemirror/search',
'@codemirror/state',
'@codemirror/stream-parser',
'@codemirror/text',
'@codemirror/tooltip',
'@codemirror/view',
'@lezer/common',
'@lezer/lr'
],
logLevel: 'info',
minify: false,
treeShaking: true,
@ -55,4 +89,4 @@ async function build() {
}
// Run the build
build();
build();

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.2",
"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,12 +1,24 @@
{
"name": "obsidian-interactive-ratings",
"version": "1.0.2",
"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: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"
@ -15,7 +27,7 @@
"license": "MIT",
"devDependencies": {
"esbuild": "^0.25.1",
"obsidian": "^1.8.7",
"obsidian": "^1.8.7",
"typescript": "^5.8.2"
}
}

View file

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

View file

@ -1,15 +1,46 @@
import { SymbolSet } from './types';
// Global logging control
export const LOGGING_ENABLED = false;
// ============================================================================
// 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
@ -23,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
export const OVERLAY_VERTICAL_ADJUSTMENT = 2.1; // Vertical alignment for overlay

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

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

View file

@ -1,13 +1,31 @@
import { InteractiveRatingsPlugin } from './InteractiveRatingsPlugin';
// Re-export from all modules
// Re-export from core modules
export * from './constants';
export * from './utils';
export * from './types';
export * from './ratings-parser';
export * from './ratings-calculator';
export * from './ratings-overlay';
export * from './event-handlers';
// 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;

View file

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

View file

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

View file

@ -1,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

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

@ -1,26 +1,250 @@
.interactive-ratings-container {
display: inline-block;
/* Interactive Ratings Plugin - Editor Mode Only */
/* Editor Widget Styles - For Editing Mode Only */
.interactive-rating-editor-widget {
display: inline;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.interactive-rating-editor-widget:hover {
/* No visual changes on hover */
}
/* Editor widget symbols container */
.interactive-rating-editor-widget .interactive-rating-symbols {
display: inline;
cursor: pointer;
padding: 0;
margin: 0;
background: transparent;
}
.interactive-ratings-symbol {
display: inline-block;
transition: transform 0.1s ease;
/* Editor widget rating text */
.interactive-rating-editor-widget .interactive-rating-text {
display: inline;
cursor: default;
padding: 0;
margin: 0;
color: inherit;
}
.interactive-ratings-editor-overlay {
display: inline-block;
/* Base symbol styling */
.interactive-rating-symbol {
position: relative;
cursor: pointer;
touch-action: none; /* Prevent browser handling of touch gestures */
-webkit-user-select: none; /* Prevent text selection on iOS */
user-select: none; /* Prevent text selection */
-webkit-touch-callout: none; /* Disable callout on long press */
display: inline-block;
transition: opacity 0.2s ease, filter 0.2s ease;
}
/* Make touch targets larger on mobile */
/*
* 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 */
.interactive-rating-editor-widget .interactive-rating-symbols span:hover {
/* No visual changes on hover */
}
/* Half-symbol visual feedback - subtle indicator for symbols that support halves */
.interactive-rating-editor-widget[data-supports-half="true"] .interactive-rating-symbols span {
/* No visual changes */
}
.interactive-rating-editor-widget[data-supports-half="true"] .interactive-rating-symbols span:hover {
/* No visual changes */
}
/* Visual hint for half-symbol positioning */
.interactive-rating-editor-widget[data-supports-half="true"] .interactive-rating-symbols span::before {
/* No visual elements */
}
/* Show the half-symbol divider on hover when half symbols are supported */
.interactive-rating-editor-widget[data-supports-half="true"]:hover .interactive-rating-symbols span::before {
/* No visual elements */
}
/* Additional visual enhancement for half-symbol interactions */
.interactive-rating-editor-widget[data-supports-half="true"] .interactive-rating-symbols span:hover::after {
/* 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-ratings-symbol {
min-height: 24px;
padding: 2px;
.interactive-rating-editor-widget {
padding: 0;
display: inline;
}
}
/* High contrast accessibility for editor */
@media (prefers-contrast: high) {
.interactive-rating-editor-widget:hover {
/* No visual changes */
}
/* Enhanced visibility for half-symbol indicators in high contrast */
.interactive-rating-editor-widget[data-supports-half="true"] .interactive-rating-symbols span::before {
/* No visual elements */
}
.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 */
@media (prefers-reduced-motion: reduce) {
.interactive-rating-editor-widget,
.interactive-rating-editor-widget .interactive-rating-symbols span,
.interactive-rating-editor-widget .interactive-rating-symbols span::before,
.interactive-rating-editor-widget .interactive-rating-symbols span::after,
.interactive-rating-symbol {
transition: none;
}
}
/* Animation for rating transitions */
.interactive-rating-editor-widget .interactive-rating-symbols {
/* No transitions */
}
.interactive-rating-editor-widget .interactive-rating-text {
/* No transitions */
}
/* Smooth symbol transitions during preview */
.interactive-rating-editor-widget .interactive-rating-symbols span {
/* Transitions defined above */
}