feat(snippets): implement dynamic placeholders and modifiers

This commit implements a system for dynamic snippet placeholders, including support for date/time manipulation, clipboard history, and nested snippets. It also adds a flexible modifier system (`uppercase`, `trim`, `percent-encode`, `json-stringify`) that can be chained and applied to any placeholder.
This commit is contained in:
ByteAtATime 2025-06-26 15:34:33 -07:00
parent e2316cf949
commit 2437ad7d61
No known key found for this signature in database
8 changed files with 509 additions and 111 deletions

1
src-tauri/Cargo.lock generated
View file

@ -4593,6 +4593,7 @@ dependencies = [
"notify",
"notify-debouncer-full",
"once_cell",
"percent-encoding",
"rand 0.9.1",
"rayon",
"rdev",

View file

@ -62,6 +62,7 @@ tauri-plugin-fs = "2"
walkdir = "2.5.0"
notify = "6.1.1"
notify-debouncer-full = "0.3.1"
percent-encoding = "2.3.1"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"

View file

@ -6,7 +6,7 @@ use super::{
use crate::error::AppError;
use chrono::Utc;
use once_cell::sync::Lazy;
use rusqlite::{params, Connection, Result as RusqliteResult};
use rusqlite::{params, Connection, OptionalExtension, Result as RusqliteResult};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
@ -18,6 +18,31 @@ pub struct ClipboardHistoryManager {
pub image_dir: PathBuf,
}
fn row_to_clipboard_item(row: &rusqlite::Row, key: &[u8; 32]) -> RusqliteResult<ClipboardItem> {
let conditional_encrypted_content: Option<String> = row.get(10)?;
let content_value = conditional_encrypted_content.and_then(|cec| decrypt(&cec, key).ok());
let encrypted_preview: Option<String> = row.get(9)?;
let preview = encrypted_preview.and_then(|ep| decrypt(&ep, key).ok());
let first_ts: i64 = row.get(4)?;
let last_ts: i64 = row.get(5)?;
Ok(ClipboardItem {
id: row.get(0)?,
hash: row.get(1)?,
content_type: ContentType::from_str(&row.get::<_, String>(2)?).unwrap_or(ContentType::Text),
content_value,
preview,
content_size_bytes: row.get(8)?,
source_app_name: row.get(3)?,
first_copied_at: chrono::DateTime::from_timestamp(first_ts, 0).unwrap_or_default(),
last_copied_at: chrono::DateTime::from_timestamp(last_ts, 0).unwrap_or_default(),
times_copied: row.get(6)?,
is_pinned: row.get::<_, i32>(7)? == 1,
})
}
impl ClipboardHistoryManager {
fn new(app_handle: AppHandle) -> Result<Self, AppError> {
let data_dir = app_handle
@ -136,32 +161,8 @@ impl ClipboardHistoryManager {
let params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
let mut stmt = db.prepare(&query)?;
let items_iter = stmt.query_map(&params_ref[..], |row| {
let conditional_encrypted_content: Option<String> = row.get(10)?;
let content_value =
conditional_encrypted_content.and_then(|cec| decrypt(&cec, &self.key).ok());
let encrypted_preview: Option<String> = row.get(9)?;
let preview = encrypted_preview.and_then(|ep| decrypt(&ep, &self.key).ok());
let first_ts: i64 = row.get(4)?;
let last_ts: i64 = row.get(5)?;
Ok(ClipboardItem {
id: row.get(0)?,
hash: row.get(1)?,
content_type: ContentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ContentType::Text),
content_value,
preview,
content_size_bytes: row.get(8)?,
source_app_name: row.get(3)?,
first_copied_at: chrono::DateTime::from_timestamp(first_ts, 0).unwrap_or_default(),
last_copied_at: chrono::DateTime::from_timestamp(last_ts, 0).unwrap_or_default(),
times_copied: row.get(6)?,
is_pinned: row.get::<_, i32>(7)? == 1,
})
})?;
let key = self.key;
let items_iter = stmt.query_map(&params_ref[..], |row| row_to_clipboard_item(row, &key))?;
let mut all_items = items_iter.collect::<Result<Vec<_>, _>>()?;
@ -183,6 +184,21 @@ impl ClipboardHistoryManager {
Ok(all_items)
}
pub fn get_content_by_offset(&self, offset: u32) -> Result<Option<String>, AppError> {
let db = self.db.lock().unwrap();
let res: rusqlite::Result<String> = db.query_row(
"SELECT encrypted_content FROM clipboard_history ORDER BY last_copied_at DESC LIMIT 1 OFFSET ?",
params![offset],
|row| row.get(0),
);
match res {
Ok(encrypted) => Ok(Some(decrypt(&encrypted, &self.key)?)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn get_item_content(&self, id: i64) -> Result<String, AppError> {
let db = self.db.lock().unwrap();
let encrypted_content: String = db.query_row(
@ -241,4 +257,4 @@ pub fn init(app_handle: AppHandle) {
Err(e) => eprintln!("Failed to create ClipboardHistoryManager: {:?}", e),
}
}
}
}

View file

@ -6,13 +6,13 @@ pub mod clipboard_history;
mod desktop;
mod error;
mod extensions;
mod file_search;
mod filesystem;
mod frecency;
mod oauth;
mod quicklinks;
mod snippets;
mod system;
mod file_search;
use crate::snippets::input_manager::{EvdevInputManager, InputManager};
use crate::{app::App, cache::AppCache};
@ -276,26 +276,27 @@ pub fn run() {
Ok(())
})
.build(tauri::generate_context!()).unwrap();
.build(tauri::generate_context!())
.unwrap();
app.run(|app, event| {
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
app.run(|app, event| {
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tauri::WindowEvent::Focused(false) => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
_ => {}
}
tauri::WindowEvent::Focused(false) => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
_ => {}
}
}
});
}
}
});
}

View file

@ -1,18 +1,44 @@
use crate::clipboard_history::manager::{
ClipboardHistoryManager, MANAGER as CLIPBOARD_MANAGER_STATIC,
};
use crate::error::AppError;
use crate::snippets::input_manager::{InputEvent, InputManager};
use crate::snippets::manager::SnippetManager;
use arboard::Clipboard;
use chrono::Local;
use chrono::{DateTime, Duration, Local, Months};
use enigo::Key as EnigoKey;
use once_cell::sync::Lazy;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use regex::Regex;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use uuid::Uuid;
const BUFFER_SIZE: usize = 30;
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
static PLACEHOLDER_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\{(?P<name>\w+)(?P<attributes>(?:\s+\w+=(?:"[^"]*"|\S+))*)?(?P<modifiers>(?:\s*\|\s*[\w%-]+)*)\}"#).unwrap()
});
static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\s*(?P<key>\w+)=(?:"(?P<q_value>[^"]*)"|(?P<uq_value>\S+))"#).unwrap()
});
static OFFSET_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?P<sign>[+-])(?P<num>\d+)(?P<unit>[ymhMd])").unwrap());
pub struct ResolvedSnippet {
pub content: String,
pub cursor_pos: Option<usize>,
}
#[derive(Debug)]
struct ParsedPlaceholder<'a> {
name: &'a str,
attributes: HashMap<&'a str, &'a str>,
modifiers: Vec<&'a str>,
}
pub struct ExpansionEngine {
buffer: Arc<Mutex<String>>,
snippet_manager: Arc<SnippetManager>,
@ -67,9 +93,15 @@ impl ExpansionEngine {
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
for snippet in snippets {
if buffer.ends_with(&snippet.keyword) {
let (keyword, content) = (snippet.keyword.clone(), snippet.content.clone());
let (keyword, content, id) =
(snippet.keyword.clone(), snippet.content.clone(), snippet.id);
let manager = self.snippet_manager.clone();
drop(buffer);
self.expand_snippet(&keyword, &content);
// run in a separate thread to not block input
thread::spawn(move || {
let _ = manager.snippet_was_used(id);
});
break;
}
}
@ -82,7 +114,24 @@ impl ExpansionEngine {
backspaces.push('\u{8}');
}
let resolved = parse_and_resolve_placeholders(content);
let clipboard_manager_lock = CLIPBOARD_MANAGER_STATIC.lock().unwrap();
let resolved_result = parse_and_resolve_placeholders(
content,
&self.snippet_manager,
clipboard_manager_lock.as_ref(),
);
let resolved = match resolved_result {
Ok(res) => res,
Err(e) => {
eprintln!("[ExpansionEngine] Error resolving placeholders: {}", e);
ResolvedSnippet {
content: content.to_string(),
cursor_pos: None,
}
}
};
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
@ -117,45 +166,267 @@ impl ExpansionEngine {
}
}
pub fn parse_and_resolve_placeholders(raw_content: &str) -> ResolvedSnippet {
fn parse_attributes(attr_str: &str) -> HashMap<&str, &str> {
ATTRIBUTE_REGEX
.captures_iter(attr_str)
.filter_map(|cap| {
let key = cap.name("key")?.as_str();
let value = cap
.name("q_value")
.or_else(|| cap.name("uq_value"))?
.as_str();
Some((key, value))
})
.collect()
}
fn parse_modifiers(mod_str: &str) -> Vec<&str> {
mod_str
.split('|')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect()
}
fn apply_modifiers(mut value: String, modifiers: &[&str]) -> String {
for &modifier in modifiers {
value = match modifier {
"uppercase" => value.to_uppercase(),
"lowercase" => value.to_lowercase(),
"trim" => value.trim().to_string(),
"percent-encode" => utf8_percent_encode(&value, FRAGMENT).to_string(),
"json-stringify" => serde_json::to_string(&value).unwrap_or(value),
_ => value,
};
}
value
}
fn translate_date_format(format_str: &str) -> String {
let mut result = String::with_capacity(format_str.len());
let mut in_literal = false;
let mut chars = format_str.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
in_literal = !in_literal;
continue;
}
if in_literal {
result.push(c);
continue;
}
let mut count = 1;
while chars.peek() == Some(&c) {
chars.next();
count += 1;
}
let format_specifier = match (c, count) {
// year
('y', 4) => "%Y",
('y', 2) => "%y",
// month
('M', 4) => "%B",
('M', 3) => "%b",
('M', 2) => "%m",
('M', 1) => "%-m",
// day of week
('E', 4) => "%A",
('E', 1..=3) => "%a",
// day of month
('d', 2) => "%d",
('d', 1) => "%-d",
// hour (0-23)
('H', 2) => "%H",
('H', 1) => "%-H",
// hour (1-12)
('h', 2) => "%I",
('h', 1) => "%-I",
// minute
('m', 2) => "%M",
('m', 1) => "%-M",
// second
('s', 2) => "%S",
('s', 1) => "%-S",
// fractional second
('S', 3) => "%f",
// am/pm
('a', 1) => "%p",
// timezone
('Z', 1) => "%z",
(other_char, num_chars) => {
// treat as literal
for _ in 0..num_chars {
result.push(other_char);
}
""
}
};
result.push_str(format_specifier);
}
result
}
fn resolve_value<'a>(
placeholder: &ParsedPlaceholder,
snippet_manager: &SnippetManager,
clipboard_manager: Option<&ClipboardHistoryManager>,
) -> Result<String, AppError> {
let now = Local::now();
match placeholder.name {
"cursor" => Ok(String::new()),
"uuid" => Ok(Uuid::new_v4().to_string().to_uppercase()),
"clipboard" => {
let offset: u32 = placeholder
.attributes
.get("offset")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if offset > 0 {
if let Some(cm) = clipboard_manager {
if let Some(content) = cm.get_content_by_offset(offset)? {
return Ok(content);
}
}
return Ok(String::new());
}
Ok(Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default())
}
"snippet" => {
if let Some(name) = placeholder.attributes.get("name") {
if let Some(snippet) = snippet_manager.find_snippet_by_name(name)? {
if PLACEHOLDER_REGEX.is_match(&snippet.content) {
return Ok(String::new()); // prevent recursion
}
return Ok(snippet.content);
}
}
Ok(String::new())
}
"date" | "time" | "datetime" | "day" => {
let mut date_time: DateTime<Local> = now;
if let Some(offset_str) = placeholder.attributes.get("offset") {
for cap in OFFSET_REGEX.captures_iter(offset_str) {
let sign = cap.name("sign").unwrap().as_str();
let num: i64 = cap.name("num").unwrap().as_str().parse().unwrap_or(0);
let val = if sign == "-" { -num } else { num };
match cap.name("unit").unwrap().as_str() {
"y" => {
let months_to_add = val * 12;
if months_to_add > 0 {
date_time = date_time
.checked_add_months(Months::new(months_to_add as u32))
.unwrap_or(date_time);
} else if months_to_add < 0 {
date_time = date_time
.checked_sub_months(Months::new(-months_to_add as u32))
.unwrap_or(date_time);
}
}
"M" => {
if val > 0 {
date_time = date_time
.checked_add_months(Months::new(val as u32))
.unwrap_or(date_time);
} else if val < 0 {
date_time = date_time
.checked_sub_months(Months::new(-val as u32))
.unwrap_or(date_time);
}
}
"d" => {
date_time = date_time
.checked_add_signed(Duration::days(val))
.unwrap_or(date_time)
}
"h" => {
date_time = date_time
.checked_add_signed(Duration::hours(val))
.unwrap_or(date_time)
}
"m" => {
date_time = date_time
.checked_add_signed(Duration::minutes(val))
.unwrap_or(date_time)
}
_ => {}
}
}
}
let format_str = if let Some(fmt) = placeholder.attributes.get("format") {
translate_date_format(fmt)
} else {
match placeholder.name {
"date" => "%-d %b %Y".to_string(),
"time" => "%-I:%M %p".to_string(),
"datetime" => "%-d %b %Y at %-I:%M %p".to_string(),
"day" => "%A".to_string(),
_ => "".to_string(),
}
};
Ok(date_time
.format(&format_str)
.to_string()
.replace("am", "AM")
.replace("pm", "PM"))
}
_ => Ok(String::new()),
}
}
pub fn parse_and_resolve_placeholders(
raw_content: &str,
snippet_manager: &SnippetManager,
clipboard_manager: Option<&ClipboardHistoryManager>,
) -> Result<ResolvedSnippet, AppError> {
let mut resolved_content = String::with_capacity(raw_content.len());
let mut cursor_pos: Option<usize> = None;
let mut last_end = 0;
for (start, _) in raw_content.match_indices('{') {
if start < last_end {
continue;
}
if let Some(end) = raw_content[start..].find('}') {
let placeholder = &raw_content[start + 1..start + end];
for cap in PLACEHOLDER_REGEX.captures_iter(raw_content) {
let full_match = cap.get(0).unwrap();
resolved_content.push_str(&raw_content[last_end..full_match.start()]);
resolved_content.push_str(&raw_content[last_end..start]);
let name = cap.name("name").unwrap().as_str();
let attributes = parse_attributes(cap.name("attributes").map_or("", |m| m.as_str()));
let modifiers = parse_modifiers(cap.name("modifiers").map_or("", |m| m.as_str()));
let replacement = match placeholder {
"cursor" => {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
String::new()
}
"clipboard" => Clipboard::new()
.ok()
.and_then(|mut c| c.get_text().ok())
.unwrap_or_default(),
"date" => Local::now().format("%d %b %Y").to_string(),
"time" => Local::now().format("%H:%M").to_string(),
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
"day" => Local::now().format("%A").to_string(),
_ => raw_content[start..start + end + 1].to_string(),
};
resolved_content.push_str(&replacement);
last_end = start + end + 1;
let placeholder = ParsedPlaceholder {
name,
attributes,
modifiers,
};
if placeholder.name == "cursor" {
if cursor_pos.is_none() {
cursor_pos = Some(resolved_content.chars().count());
}
} else {
let value = resolve_value(&placeholder, snippet_manager, clipboard_manager)?;
let modified_value = apply_modifiers(value, &placeholder.modifiers);
resolved_content.push_str(&modified_value);
}
last_end = full_match.end();
}
resolved_content.push_str(&raw_content[last_end..]);
ResolvedSnippet {
Ok(ResolvedSnippet {
content: resolved_content,
cursor_pos,
}
}
})
}

View file

@ -169,4 +169,30 @@ impl SnippetManager {
Ok(None)
}
}
}
pub fn find_snippet_by_name(&self, name: &str) -> Result<Option<Snippet>, AppError> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at, times_used, last_used_at FROM snippets WHERE name = ?1 ORDER BY updated_at DESC LIMIT 1")?;
let mut rows = stmt.query_map(params![name], |row| {
let created_at_ts: i64 = row.get(4)?;
let updated_at_ts: i64 = row.get(5)?;
let last_used_at_ts: i64 = row.get(7)?;
Ok(Snippet {
id: row.get(0)?,
name: row.get(1)?,
keyword: row.get(2)?,
content: row.get(3)?,
created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(),
updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(),
times_used: row.get(6)?,
last_used_at: DateTime::from_timestamp(last_used_at_ts, 0).unwrap_or_default(),
})
})?;
if let Some(row) = rows.next() {
Ok(Some(row?))
} else {
Ok(None)
}
}
}

View file

@ -3,6 +3,7 @@ pub mod input_manager;
pub mod manager;
pub mod types;
use crate::clipboard_history;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@ -72,12 +73,20 @@ pub fn snippet_was_used(app: AppHandle, id: i64) -> Result<(), String> {
#[tauri::command]
pub fn paste_snippet_content(app: AppHandle, content: String) -> Result<(), String> {
let snippet_manager = app.state::<manager::SnippetManager>().inner();
let clipboard_manager = clipboard_history::manager::MANAGER.lock().unwrap();
let input_manager = app
.state::<Arc<dyn input_manager::InputManager>>()
.inner()
.clone();
let resolved = engine::parse_and_resolve_placeholders(&content);
let resolved = engine::parse_and_resolve_placeholders(
&content,
snippet_manager,
clipboard_manager.as_ref(),
)
.map_err(|e| e.to_string())?;
let content_to_paste = resolved.content;
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
@ -131,4 +140,4 @@ pub fn import_snippets(app: AppHandle, json_content: String) -> Result<ImportRes
snippets_added,
duplicates_skipped,
})
}
}

View file

@ -19,49 +19,122 @@
let content = $state('');
let error = $state('');
const VALID_PLACEHOLDERS_NO_CURSOR = new Set(['clipboard', 'date', 'time', 'datetime', 'day']);
type ParsedPart = {
text: string;
type: 'text' | 'valid-bracket' | 'invalid-bracket' | 'valid-name' | 'invalid-name';
};
const PLACEHOLDER_REGEX =
/\{(?<name>\w+)(?<attributes>(?:\s+\w+=(?:"[^"]*"|\S+))*)?(?<modifiers>(?:\s*\|\s*[\w%-]+)*)\}/g;
const VALID_PLACEHOLDERS = new Set([
'clipboard',
'snippet',
'cursor',
'date',
'time',
'datetime',
'day',
'uuid'
]);
const VALID_MODIFIERS = new Set([
'uppercase',
'lowercase',
'trim',
'percent-encode',
'json-stringify'
]);
const VALID_ATTRIBUTES: Record<string, Set<string>> = {
clipboard: new Set(['offset']),
snippet: new Set(['name']),
date: new Set(['offset', 'format']),
time: new Set(['offset', 'format']),
datetime: new Set(['offset', 'format']),
day: new Set(['offset', 'format'])
};
const ATTRIBUTE_REGEX = /\s*(?<key>\w+)\s*=\s*"(?:[^"]*)"/g;
function parseAttributes(attrStr: string | undefined): Record<string, string> {
if (!attrStr) return {};
const attributes: Record<string, string> = {};
for (const match of attrStr.matchAll(ATTRIBUTE_REGEX)) {
if (match.groups) {
attributes[match.groups.key] = 'dummy';
}
}
return attributes;
}
function parseModifiers(modStr: string | undefined): string[] {
if (!modStr) return [];
return modStr
.split('|')
.map((s) => s.trim())
.filter(Boolean);
}
function validatePlaceholder(
name: string,
attributes: Record<string, string>,
modifiers: string[]
): boolean {
if (!VALID_PLACEHOLDERS.has(name)) return false;
const validAttrsForPlaceholder = VALID_ATTRIBUTES[name] || new Set();
for (const attrName in attributes) {
if (!validAttrsForPlaceholder.has(attrName)) return false;
}
for (const mod of modifiers) {
if (!VALID_MODIFIERS.has(mod)) return false;
}
return true;
}
const parsedContent = $derived.by(() => {
if (!content) return [];
const parts: { text: string; highlightType: 'text' | 'valid-bracket' | 'invalid-bracket' }[] =
[];
const parts: ParsedPart[] = [];
let lastIndex = 0;
const regex = /({([a-zA-Z_]+?)})/g;
let match;
let cursorFoundAndValid = false;
let cursorCount = 0;
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
for (const match of content.matchAll(PLACEHOLDER_REGEX)) {
if (match.index! > lastIndex) {
parts.push({
text: content.substring(lastIndex, match.index),
highlightType: 'text'
type: 'text'
});
}
const placeholderName = match[2];
let currentBracketHighlightType: 'valid-bracket' | 'invalid-bracket' = 'invalid-bracket';
const placeholderName = match.groups!.name;
const attributes = parseAttributes(match.groups!.attributes);
const modifiers = parseModifiers(match.groups!.modifiers);
let isValid = validatePlaceholder(placeholderName, attributes, modifiers);
if (placeholderName === 'cursor') {
if (!cursorFoundAndValid) {
currentBracketHighlightType = 'valid-bracket';
cursorFoundAndValid = true;
} else {
currentBracketHighlightType = 'invalid-bracket';
cursorCount++;
if (cursorCount > 1) {
isValid = false;
}
} else if (VALID_PLACEHOLDERS_NO_CURSOR.has(placeholderName)) {
currentBracketHighlightType = 'valid-bracket';
}
parts.push({ text: '{', highlightType: currentBracketHighlightType });
parts.push({ text: placeholderName, highlightType: 'text' });
parts.push({ text: '}', highlightType: currentBracketHighlightType });
const bracketType = isValid ? 'valid-bracket' : 'invalid-bracket';
const nameType = isValid ? 'valid-name' : 'invalid-name';
lastIndex = regex.lastIndex;
parts.push({ text: '{', type: bracketType });
parts.push({
text: match[0].slice(1, -1),
type: nameType
});
parts.push({ text: '}', type: bracketType });
lastIndex = match.index! + match[0].length;
}
if (lastIndex < content.length) {
parts.push({ text: content.substring(lastIndex), highlightType: 'text' });
parts.push({ text: content.substring(lastIndex), type: 'text' });
}
return parts;
@ -129,18 +202,18 @@
>
{#each parsedContent as part}
<span
class:text-blue-300={part.highlightType === 'valid-bracket'}
class:text-red-400={part.highlightType === 'invalid-bracket'}
class:text-foreground={part.highlightType === 'text'}
class:text-blue-400={part.type === 'valid-bracket'}
class:text-red-400={part.type === 'invalid-bracket' || part.type === 'invalid-name'}
class:text-foreground={part.type === 'text' || part.type === 'valid-name'}
>
{part.text}
</span>
{/each}
<span>&#x200B;</span>
<span></span>
</div>
<Textarea
id="content"
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard}!"
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard | uppercase}!"
bind:value={content}
class="caret-foreground col-start-1 row-start-1 min-h-32 resize-none !bg-transparent font-mono text-transparent"
spellcheck={false}