diff --git a/frontend/src/common/constant/config.ts b/frontend/src/common/constant/config.ts index 3f19326..f34e7a7 100644 --- a/frontend/src/common/constant/config.ts +++ b/frontend/src/common/constant/config.ts @@ -1,43 +1,19 @@ import { AppConfig, - AppearanceConfig, - EditingConfig, - GeneralConfig, + AuthMethod, LanguageType, SystemThemeType, TabType, - UpdatesConfig, - UpdateSourceType, - GitBackupConfig, - AuthMethod + UpdateSourceType } from '@/../bindings/voidraft/internal/models/models'; import {FONT_OPTIONS} from './fonts'; -// 配置键映射和限制的类型定义 -export type GeneralConfigKeyMap = { - readonly [K in keyof GeneralConfig]: string; -}; - -export type EditingConfigKeyMap = { - readonly [K in keyof EditingConfig]: string; -}; - -export type AppearanceConfigKeyMap = { - readonly [K in keyof AppearanceConfig]: string; -}; - -export type UpdatesConfigKeyMap = { - readonly [K in keyof UpdatesConfig]: string; -}; - -export type BackupConfigKeyMap = { - readonly [K in keyof GitBackupConfig]: string; -}; - export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight'; +export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup'; -// 配置键映射 -export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = { +// 统一配置键映射(平级展开) +export const CONFIG_KEY_MAP = { + // general alwaysOnTop: 'general.alwaysOnTop', dataPath: 'general.dataPath', enableSystemTray: 'general.enableSystemTray', @@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = { enableWindowSnap: 'general.enableWindowSnap', enableLoadingAnimation: 'general.enableLoadingAnimation', enableTabs: 'general.enableTabs', -} as const; - -export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = { + // editing fontSize: 'editing.fontSize', fontFamily: 'editing.fontFamily', fontWeight: 'editing.fontWeight', @@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = { enableTabIndent: 'editing.enableTabIndent', tabSize: 'editing.tabSize', tabType: 'editing.tabType', - autoSaveDelay: 'editing.autoSaveDelay' -} as const; - -export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = { + autoSaveDelay: 'editing.autoSaveDelay', + // appearance language: 'appearance.language', systemTheme: 'appearance.systemTheme', - currentTheme: 'appearance.currentTheme' -} as const; - -export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = { + currentTheme: 'appearance.currentTheme', + // updates version: 'updates.version', autoUpdate: 'updates.autoUpdate', primarySource: 'updates.primarySource', @@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = { backupBeforeUpdate: 'updates.backupBeforeUpdate', updateTimeout: 'updates.updateTimeout', github: 'updates.github', - gitea: 'updates.gitea' -} as const; - -export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = { + gitea: 'updates.gitea', + // backup enabled: 'backup.enabled', repo_url: 'backup.repo_url', auth_method: 'backup.auth_method', @@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = { auto_backup: 'backup.auto_backup', } as const; +export type ConfigKey = keyof typeof CONFIG_KEY_MAP; + // 配置限制 export const CONFIG_LIMITS = { fontSize: {min: 12, max: 28, default: 13}, diff --git a/frontend/src/stores/configStore.ts b/frontend/src/stores/configStore.ts index e0879d4..8791534 100644 --- a/frontend/src/stores/configStore.ts +++ b/frontend/src/stores/configStore.ts @@ -3,29 +3,23 @@ import {computed, reactive} from 'vue'; import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services'; import { AppConfig, - AppearanceConfig, AuthMethod, EditingConfig, - GeneralConfig, - GitBackupConfig, LanguageType, SystemThemeType, - TabType, - UpdatesConfig + TabType } from '@/../bindings/voidraft/internal/models/models'; import {useI18n} from 'vue-i18n'; import {ConfigUtils} from '@/common/utils/configUtils'; import {FONT_OPTIONS} from '@/common/constant/fonts'; import {SUPPORTED_LOCALES} from '@/common/constant/locales'; import { - APPEARANCE_CONFIG_KEY_MAP, - BACKUP_CONFIG_KEY_MAP, + CONFIG_KEY_MAP, CONFIG_LIMITS, + ConfigKey, + ConfigSection, DEFAULT_CONFIG, - EDITING_CONFIG_KEY_MAP, - GENERAL_CONFIG_KEY_MAP, - NumberConfigKey, - UPDATES_CONFIG_KEY_MAP + NumberConfigKey } from '@/common/constant/config'; import * as runtime from '@wailsio/runtime'; @@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => { // Font options (no longer localized) const fontOptions = computed(() => FONT_OPTIONS); - // 计算属性 - 使用工厂函数简化 + // 计算属性 const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]); const limits = Object.fromEntries( (['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)]) ) as Record>; - // 通用配置更新方法 - const updateGeneralConfig = async (key: K, value: GeneralConfig[K]): Promise => { - // 确保配置已加载 + // 统一配置更新方法 + const updateConfig = async (key: K, value: any): Promise => { if (!state.configLoaded && !state.isLoading) { await initConfig(); } - const backendKey = GENERAL_CONFIG_KEY_MAP[key]; + const backendKey = CONFIG_KEY_MAP[key]; if (!backendKey) { - throw new Error(`No backend key mapping found for general.${key.toString()}`); + throw new Error(`No backend key mapping found for ${String(key)}`); } + // 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general') + const section = backendKey.split('.')[0] as ConfigSection; + await ConfigService.Set(backendKey, value); - state.config.general[key] = value; + (state.config[section] as any)[key] = value; }; - const updateEditingConfig = async (key: K, value: EditingConfig[K]): Promise => { - // 确保配置已加载 - if (!state.configLoaded && !state.isLoading) { - await initConfig(); - } - - const backendKey = EDITING_CONFIG_KEY_MAP[key]; - if (!backendKey) { - throw new Error(`No backend key mapping found for editing.${key.toString()}`); - } - - await ConfigService.Set(backendKey, value); - state.config.editing[key] = value; + // 只更新本地状态,不保存到后端 + const updateConfigLocal = (key: K, value: any): void => { + const backendKey = CONFIG_KEY_MAP[key]; + const section = backendKey.split('.')[0] as ConfigSection; + (state.config[section] as any)[key] = value; }; - const updateAppearanceConfig = async (key: K, value: AppearanceConfig[K]): Promise => { - // 确保配置已加载 - if (!state.configLoaded && !state.isLoading) { - await initConfig(); - } - - const backendKey = APPEARANCE_CONFIG_KEY_MAP[key]; - if (!backendKey) { - throw new Error(`No backend key mapping found for appearance.${key.toString()}`); - } - - await ConfigService.Set(backendKey, value); - state.config.appearance[key] = value; - }; - - const updateUpdatesConfig = async (key: K, value: UpdatesConfig[K]): Promise => { - // 确保配置已加载 - if (!state.configLoaded && !state.isLoading) { - await initConfig(); - } - - const backendKey = UPDATES_CONFIG_KEY_MAP[key]; - if (!backendKey) { - throw new Error(`No backend key mapping found for updates.${key.toString()}`); - } - - await ConfigService.Set(backendKey, value); - state.config.updates[key] = value; - }; - - const updateBackupConfig = async (key: K, value: GitBackupConfig[K]): Promise => { - // 确保配置已加载 - if (!state.configLoaded && !state.isLoading) { - await initConfig(); - } - - const backendKey = BACKUP_CONFIG_KEY_MAP[key]; - if (!backendKey) { - throw new Error(`No backend key mapping found for backup.${key.toString()}`); - } - - await ConfigService.Set(backendKey, value); - state.config.backup[key] = value; + // 保存指定配置到后端 + const saveConfig = async (key: K): Promise => { + const backendKey = CONFIG_KEY_MAP[key]; + const section = backendKey.split('.')[0] as ConfigSection; + await ConfigService.Set(backendKey, (state.config[section] as any)[key]); }; // 加载配置 @@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => { const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max); return { - increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)), - decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)), - set: async (value: number) => await updateEditingConfig(key, clamp(value)), - reset: async () => await updateEditingConfig(key, limit.default) + increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)), + decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)), + set: async (value: number) => await updateConfig(key, clamp(value)), + reset: async () => await updateConfig(key, limit.default), + increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)), + decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1)) }; }; const createEditingToggler = (key: T) => - async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]); + async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]); // 枚举值切换器 const createEnumToggler = (key: 'tabType', values: readonly T[]) => async () => { const currentIndex = values.indexOf(state.config.editing[key] as T); const nextIndex = (currentIndex + 1) % values.length; - return await updateEditingConfig(key, values[nextIndex]); + return await updateConfig(key, values[nextIndex]); }; // 重置配置 @@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => { // 语言设置方法 const setLanguage = async (language: LanguageType): Promise => { - await updateAppearanceConfig('language', language); - - // 同步更新前端语言 + await updateConfig('language', language); const frontendLocale = ConfigUtils.backendLanguageToFrontend(language); locale.value = frontendLocale as any; }; // 系统主题设置方法 const setSystemTheme = async (systemTheme: SystemThemeType): Promise => { - await updateAppearanceConfig('systemTheme', systemTheme); + await updateConfig('systemTheme', systemTheme); }; // 当前主题设置方法 const setCurrentTheme = async (themeName: string): Promise => { - await updateAppearanceConfig('currentTheme', themeName); + await updateConfig('currentTheme', themeName); }; @@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => { const togglers = { tabIndent: createEditingToggler('enableTabIndent'), alwaysOnTop: async () => { - await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop); - // 立即应用窗口置顶状态 + await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop); await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop); }, tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values) }; - // 字符串配置设置器 - const setters = { - fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value), - fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value), - dataPath: async (value: string) => await updateGeneralConfig('dataPath', value), - autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value) - }; - return { // 状态 config: computed(() => state.config), @@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => { decreaseFontSize: adjusters.fontSize.decrease, resetFontSize: adjusters.fontSize.reset, setFontSize: adjusters.fontSize.set, + // 字体大小操作 + increaseFontSizeLocal: adjusters.fontSize.increaseLocal, + decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal, + saveFontSize: () => saveConfig('fontSize'), // Tab操作 toggleTabIndent: togglers.tabIndent, - setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value), + setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value), ...adjusters.tabSize, increaseTabSize: adjusters.tabSize.increase, decreaseTabSize: adjusters.tabSize.decrease, @@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => { // 窗口操作 toggleAlwaysOnTop: togglers.alwaysOnTop, - setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value), + setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value), // 字体操作 - setFontFamily: setters.fontFamily, - setFontWeight: setters.fontWeight, + setFontFamily: (value: string) => updateConfig('fontFamily', value), + setFontWeight: (value: string) => updateConfig('fontWeight', value), // 路径操作 - setDataPath: setters.dataPath, + setDataPath: (value: string) => updateConfig('dataPath', value), // 保存配置相关方法 - setAutoSaveDelay: setters.autoSaveDelay, + setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value), // 热键配置相关方法 - setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value), - setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey), + setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value), + setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey), // 系统托盘配置相关方法 - setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value), + setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value), // 开机启动配置相关方法 setStartAtLogin: async (value: boolean) => { - // 先更新配置文件 - await updateGeneralConfig('startAtLogin', value); - // 再调用系统设置API + await updateConfig('startAtLogin', value); await StartupService.SetEnabled(value); }, // 窗口吸附配置相关方法 - setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value), + setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value), // 加载动画配置相关方法 - setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value), + setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value), // 标签页配置相关方法 - setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value), + setEnableTabs: (value: boolean) => updateConfig('enableTabs', value), // 更新配置相关方法 - setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value), + setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value), // 备份配置相关方法 - setEnableBackup: async (value: boolean) => { - await updateBackupConfig('enabled', value); - }, - setAutoBackup: async (value: boolean) => { - await updateBackupConfig('auto_backup', value); - }, - setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value), - setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value), - setUsername: async (value: string) => await updateBackupConfig('username', value), - setPassword: async (value: string) => await updateBackupConfig('password', value), - setToken: async (value: string) => await updateBackupConfig('token', value), - setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value), - setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value), - setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value), + setEnableBackup: (value: boolean) => updateConfig('enabled', value), + setAutoBackup: (value: boolean) => updateConfig('auto_backup', value), + setRepoUrl: (value: string) => updateConfig('repo_url', value), + setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value), + setUsername: (value: string) => updateConfig('username', value), + setPassword: (value: string) => updateConfig('password', value), + setToken: (value: string) => updateConfig('token', value), + setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value), + setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value), + setBackupInterval: (value: number) => updateConfig('backup_interval', value), }; }); \ No newline at end of file diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index 3ea6c32..afb1791 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -242,10 +242,12 @@ export const useEditorStore = defineStore('editor', () => { fontWeight: configStore.config.editing.fontWeight }); - const wheelZoomExtension = createWheelZoomExtension( - () => configStore.increaseFontSize(), - () => configStore.decreaseFontSize() - ); + const wheelZoomExtension = createWheelZoomExtension({ + increaseFontSize: () => configStore.increaseFontSizeLocal(), + decreaseFontSize: () => configStore.decreaseFontSizeLocal(), + onSave: () => configStore.saveFontSize(), + saveDelay: 500 + }); // 统计扩展 const statsExtension = createStatsUpdateExtension(updateDocumentStats); diff --git a/frontend/src/views/editor/basic/wheelZoomExtension.ts b/frontend/src/views/editor/basic/wheelZoomExtension.ts index b87a88a..59110b3 100644 --- a/frontend/src/views/editor/basic/wheelZoomExtension.ts +++ b/frontend/src/views/editor/basic/wheelZoomExtension.ts @@ -1,25 +1,40 @@ import {EditorView} from '@codemirror/view'; import type {Extension} from '@codemirror/state'; +import {createDebounce} from '@/common/utils/debounce'; -type FontAdjuster = () => Promise | void; +type FontAdjuster = () => void; +type SaveCallback = () => Promise | void; -const runAdjuster = (adjuster: FontAdjuster) => { - try { - const result = adjuster(); - if (result && typeof (result as Promise).then === 'function') { - (result as Promise).catch((error) => { - console.error('Failed to adjust font size:', error); - }); - } - } catch (error) { - console.error('Failed to adjust font size:', error); - } -}; +export interface WheelZoomOptions { + /** 增加字体大小的回调(立即执行) */ + increaseFontSize: FontAdjuster; + /** 减少字体大小的回调(立即执行) */ + decreaseFontSize: FontAdjuster; + /** 保存回调(防抖执行),在滚动结束后调用 */ + onSave?: SaveCallback; + /** 保存防抖延迟(毫秒),默认 300ms */ + saveDelay?: number; +} + +export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => { + const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options; + + // 如果有 onSave 回调,创建防抖版本 + const {debouncedFn: debouncedSave} = onSave + ? createDebounce(() => { + try { + const result = onSave(); + if (result && typeof (result as Promise).then === 'function') { + (result as Promise).catch((error) => { + console.error('Failed to save font size:', error); + }); + } + } catch (error) { + console.error('Failed to save font size:', error); + } + }, {delay: saveDelay}) + : {debouncedFn: null}; -export const createWheelZoomExtension = ( - increaseFontSize: FontAdjuster, - decreaseFontSize: FontAdjuster -): Extension => { return EditorView.domEventHandlers({ wheel(event) { if (!event.ctrlKey) { @@ -28,10 +43,16 @@ export const createWheelZoomExtension = ( event.preventDefault(); + // 立即更新字体大小 if (event.deltaY < 0) { - runAdjuster(increaseFontSize); + increaseFontSize(); } else if (event.deltaY > 0) { - runAdjuster(decreaseFontSize); + decreaseFontSize(); + } + + // 防抖保存 + if (debouncedSave) { + debouncedSave(); } return true; diff --git a/frontend/src/views/editor/extensions/codeblock/decorations.ts b/frontend/src/views/editor/extensions/codeblock/decorations.ts index 1ecf639..3b78782 100644 --- a/frontend/src/views/editor/extensions/codeblock/decorations.ts +++ b/frontend/src/views/editor/extensions/codeblock/decorations.ts @@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass( /** * 块背景层 - 修复高度计算问题 + * + * 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。 + * 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0), + * 行的坐标也不会受影响,边界线位置正确。 */ const blockLayer = layer({ above: false, @@ -135,14 +139,17 @@ const blockLayer = layer({ return; } - // view.coordsAtPos 如果编辑器不可见则返回 null - const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top; - let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom; + const fromPos = Math.max(block.content.from, view.visibleRanges[0].from); + const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to); - if (fromCoordsTop === undefined || toCoordsBottom === undefined) { - idx++; - return; - } + // 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响 + const fromLineBlock = view.lineBlockAt(fromPos); + const toLineBlock = view.lineBlockAt(toPos); + + // lineBlockAt 返回的 top 是相对于内容区域的偏移 + // 转换为视口坐标进行后续计算 + const fromCoordsTop = fromLineBlock.top + view.documentTop; + let toCoordsBottom = toLineBlock.bottom + view.documentTop; // 对最后一个块进行特殊处理,让它直接延伸到底部 if (idx === blocks.length - 1) { @@ -151,7 +158,7 @@ const blockLayer = layer({ // 让最后一个块直接延伸到编辑器底部 if (contentBottom < editorHeight) { - const extraHeight = editorHeight - contentBottom-10; + const extraHeight = editorHeight - contentBottom - 10; toCoordsBottom += extraHeight; } } diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index e0f9e2f..f04e4bb 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -3,7 +3,7 @@ import { blockquote } from './plugins/blockquote'; import { codeblock } from './plugins/code-block'; import { headings } from './plugins/heading'; import { hideMarks } from './plugins/hide-mark'; -import { htmlBlock } from './plugins/html'; +import { htmlBlockExtension } from './plugins/html'; import { image } from './plugins/image'; import { links } from './plugins/link'; import { lists } from './plugins/list'; @@ -14,7 +14,6 @@ import { imagePreview } from './state/image'; import { codeblockEnhanced } from './plugins/code-block-enhanced'; import { emoji } from './plugins/emoji'; import { horizontalRule } from './plugins/horizontal-rule'; -import { softIndent } from './plugins/soft-indent'; import { revealOnArrow } from './plugins/reveal-on-arrow'; import { pasteRichText } from './plugins/paste-rich-text'; @@ -29,7 +28,7 @@ export { frontmatter } from './plugins/frontmatter'; export { headings } from './plugins/heading'; export { hideMarks } from './plugins/hide-mark'; export { image } from './plugins/image'; -export { htmlBlock } from './plugins/html'; +export { htmlBlock, htmlBlockExtension } from './plugins/html'; export { links } from './plugins/link'; export { lists } from './plugins/list'; @@ -37,7 +36,6 @@ export { lists } from './plugins/list'; export { codeblockEnhanced } from './plugins/code-block-enhanced'; export { emoji, addEmoji, getEmojiNames } from './plugins/emoji'; export { horizontalRule } from './plugins/horizontal-rule'; -export { softIndent } from './plugins/soft-indent'; export { revealOnArrow } from './plugins/reveal-on-arrow'; export { pasteRichText } from './plugins/paste-rich-text'; @@ -47,6 +45,8 @@ export * as classes from './classes'; /** * markdown extensions (includes all ProseMark-inspired features). + * NOTE: All decorations avoid using block: true to prevent interfering + * with the codeblock system's boundary calculations. */ export const markdownExtensions: Extension = [ headingSlugField, @@ -58,12 +58,11 @@ export const markdownExtensions: Extension = [ lists(), links(), image(), - htmlBlock, + htmlBlockExtension, // Enhanced features codeblockEnhanced(), emoji(), horizontalRule(), - softIndent(), revealOnArrow(), pasteRichText() ]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts index 063597f..2c337cc 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts @@ -3,101 +3,87 @@ import { DecorationSet, EditorView, ViewPlugin, - ViewUpdate, - WidgetType + ViewUpdate } from '@codemirror/view'; import { Range } from '@codemirror/state'; -import { - iterateTreeInVisibleRanges, - editorLines, - isCursorInRange, - checkRangeSubset -} from '../util'; +import { syntaxTree } from '@codemirror/language'; +import { isCursorInRange, invisibleDecoration } from '../util'; import { blockquote as classes } from '../classes'; -const quoteMarkRE = /^(\s*>+)/gm; - -class BlockQuoteBorderWidget extends WidgetType { - toDOM(): HTMLElement { - const dom = document.createElement('span'); - dom.classList.add(classes.mark); - return dom; - } +/** + * Blockquote plugin. + * + * Features: + * - Decorates blockquote with left border + * - Hides quote marks (>) when cursor is outside + * - Supports nested blockquotes + */ +export function blockquote() { + return [blockQuotePlugin, baseTheme]; } /** - * Plugin to add style blockquotes. + * Build blockquote decorations. + */ +function buildBlockQuoteDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + const processedLines = new Set(); + + syntaxTree(view.state).iterate({ + enter(node) { + if (node.type.name !== 'Blockquote') return; + + const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]); + + // Add line decoration for each line in the blockquote + const startLine = view.state.doc.lineAt(node.from).number; + const endLine = view.state.doc.lineAt(node.to).number; + + for (let i = startLine; i <= endLine; i++) { + if (!processedLines.has(i)) { + processedLines.add(i); + const line = view.state.doc.line(i); + decorations.push( + Decoration.line({ class: classes.widget }).range(line.from) + ); + } + } + + // Hide quote marks when cursor is outside + if (!cursorInBlockquote) { + const cursor = node.node.cursor(); + cursor.iterate((child) => { + if (child.type.name === 'QuoteMark') { + decorations.push( + invisibleDecoration.range(child.from, child.to) + ); + } + }); + } + + // Don't recurse into nested blockquotes (handled by outer iteration) + return false; + } + }); + + return Decoration.set(decorations, true); +} + +/** + * Blockquote plugin class. */ class BlockQuotePlugin { decorations: DecorationSet; + constructor(view: EditorView) { - this.decorations = this.styleBlockquote(view); + this.decorations = buildBlockQuoteDecorations(view); } + update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet - ) { - this.decorations = this.styleBlockquote(update.view); + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = buildBlockQuoteDecorations(update.view); } } - /** - * - * @param view - The editor view - * @returns The blockquote decorations to add to the editor - */ - private styleBlockquote(view: EditorView): DecorationSet { - const widgets: Range[] = []; - iterateTreeInVisibleRanges(view, { - enter: ({ name, from, to }) => { - if (name !== 'Blockquote') return; - const lines = editorLines(view, from, to); - - lines.forEach((line) => { - const lineDec = Decoration.line({ - class: classes.widget - }); - widgets.push(lineDec.range(line.from)); - }); - - if ( - lines.every( - (line) => - !isCursorInRange(view.state, [line.from, line.to]) - ) - ) { - const marks = Array.from( - view.state.sliceDoc(from, to).matchAll(quoteMarkRE) - ) - .map((x) => from + x.index) - .map((i) => - Decoration.replace({ - widget: new BlockQuoteBorderWidget() - }).range(i, i + 1) - ); - lines.forEach((line) => { - if ( - !marks.some((mark) => - checkRangeSubset( - [line.from, line.to], - [mark.from, mark.to] - ) - ) - ) - marks.push( - Decoration.widget({ - widget: new BlockQuoteBorderWidget() - }).range(line.from) - ); - }); - - widgets.push(...marks); - } - } - }); - return Decoration.set(widgets, true); - } } const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, { @@ -105,24 +91,11 @@ const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, { }); /** - * Default styles for blockquotes. + * Base theme for blockquotes. */ const baseTheme = EditorView.baseTheme({ - ['.' + classes.mark]: { - 'border-left': '4px solid #ccc' - }, - ['.' + classes.widget]: { - color: '#555' + [`.${classes.widget}`]: { + borderLeft: '4px solid var(--cm-blockquote-border, #ccc)', + color: 'var(--cm-blockquote-color, #666)' } }); - -/** - * Ixora blockquote plugin. - * - * This plugin allows to: - * - Decorate blockquote marks in the editor - * - Add default styling to blockquote marks - */ -export function blockquote() { - return [blockQuotePlugin, baseTheme]; -} diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts index b8d77fd..404cfbd 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -1,4 +1,4 @@ -import { Extension } from '@codemirror/state'; +import { Extension, Range } from '@codemirror/state'; import { ViewPlugin, DecorationSet, @@ -6,92 +6,148 @@ import { EditorView, ViewUpdate } from '@codemirror/view'; -import { - isCursorInRange, - invisibleDecoration, - iterateTreeInVisibleRanges, - editorLines -} from '../util'; +import { syntaxTree } from '@codemirror/language'; +import { isCursorInRange } from '../util'; import { codeblock as classes } from '../classes'; /** - * Ixora code block plugin. + * Code block types to match in the syntax tree. + */ +const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const; + +/** + * Code block plugin with optimized decoration building. * - * This plugin allows to: - * - Add default styling to code blocks - * - Customize visibility of code block markers and language + * This plugin: + * - Adds styling to code blocks (begin/end markers) + * - Hides code markers and language info when cursor is outside */ export const codeblock = (): Extension => [codeBlockPlugin, baseTheme]; -const codeBlockPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = decorateCodeBlocks(view); - } - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet - ) - this.decorations = decorateCodeBlocks(update.view); - } - }, - { decorations: (v) => v.decorations } -); +/** + * Build code block decorations. + * Uses array + Decoration.set() for automatic sorting. + */ +function buildCodeBlockDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + const visited = new Set(); -function decorateCodeBlocks(view: EditorView) { - const widgets: Array> = []; - iterateTreeInVisibleRanges(view, { - enter: ({ type, from, to, node }) => { - if (!['FencedCode', 'CodeBlock'].includes(type.name)) return; - editorLines(view, from, to).forEach((block, i) => { - const lineDec = Decoration.line({ - class: [ - classes.widget, - i === 0 - ? classes.widgetBegin - : block.to === to - ? classes.widgetEnd - : '' - ].join(' ') - }); - widgets.push(lineDec.range(block.from)); - }); - if (isCursorInRange(view.state, [from, to])) return; - const codeBlock = node.toTree(); - codeBlock.iterate({ - enter: ({ type, from: nodeFrom, to: nodeTo }) => { - switch (type.name) { - case 'CodeInfo': - case 'CodeMark': - // eslint-disable-next-line no-case-declarations - const decRange = invisibleDecoration.range( - from + nodeFrom, - from + nodeTo - ); - widgets.push(decRange); - break; - } + // Process only visible ranges + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (!CODE_BLOCK_TYPES.includes(type.name as typeof CODE_BLOCK_TYPES[number])) { + return; } - }); - } - }); - return Decoration.set(widgets, true); + + // Avoid processing the same code block multiple times + const key = `${nodeFrom}:${nodeTo}`; + if (visited.has(key)) return; + visited.add(key); + + const cursorInBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]); + + // Add line decorations for each line in the code block + const startLine = view.state.doc.lineAt(nodeFrom); + const endLine = view.state.doc.lineAt(nodeTo); + + for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { + const line = view.state.doc.line(lineNum); + + // Determine line position class + let positionClass = ''; + if (lineNum === startLine.number) { + positionClass = classes.widgetBegin; + } else if (lineNum === endLine.number) { + positionClass = classes.widgetEnd; + } + + decorations.push( + Decoration.line({ + class: `${classes.widget} ${positionClass}`.trim() + }).range(line.from) + ); + } + + // Hide code markers when cursor is outside the block + if (!cursorInBlock) { + const codeBlock = node.toTree(); + codeBlock.iterate({ + enter: ({ type: childType, from: childFrom, to: childTo }) => { + if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') { + decorations.push( + Decoration.replace({}).range( + nodeFrom + childFrom, + nodeFrom + childTo + ) + ); + } + } + }); + } + } + }); + } + + // Use Decoration.set with sort=true to handle unsorted ranges + return Decoration.set(decorations, true); } /** - * Base theme for code block plugin. + * Code block plugin class with optimized update detection. + */ +class CodeBlockPlugin { + decorations: DecorationSet; + private lastSelection: number = -1; + + constructor(view: EditorView) { + this.decorations = buildCodeBlockDecorations(view); + this.lastSelection = view.state.selection.main.head; + } + + update(update: ViewUpdate) { + const docChanged = update.docChanged; + const viewportChanged = update.viewportChanged; + const selectionChanged = update.selectionSet; + + // Optimization: check if selection moved to a different line + if (selectionChanged && !docChanged && !viewportChanged) { + const newHead = update.state.selection.main.head; + const oldHead = this.lastSelection; + + const oldLine = update.startState.doc.lineAt(oldHead); + const newLine = update.state.doc.lineAt(newHead); + + if (oldLine.number === newLine.number) { + this.lastSelection = newHead; + return; + } + } + + if (docChanged || viewportChanged || selectionChanged) { + this.decorations = buildCodeBlockDecorations(update.view); + this.lastSelection = update.state.selection.main.head; + } + } +} + +const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, { + decorations: (v) => v.decorations +}); + +/** + * Base theme for code blocks. */ const baseTheme = EditorView.baseTheme({ - ['.' + classes.widget]: { - backgroundColor: '#CCC7' + [`.${classes.widget}`]: { + backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))' }, - ['.' + classes.widgetBegin]: { + [`.${classes.widgetBegin}`]: { borderRadius: '5px 5px 0 0' }, - ['.' + classes.widgetEnd]: { + [`.${classes.widgetEnd}`]: { borderRadius: '0 0 5px 5px' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts index ee4e4b5..e4c5b2e 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts @@ -1,4 +1,4 @@ -import { Extension } from '@codemirror/state'; +import { Extension, RangeSetBuilder } from '@codemirror/state'; import { ViewPlugin, DecorationSet, @@ -7,138 +7,156 @@ import { ViewUpdate, WidgetType } from '@codemirror/view'; -import { isCursorInRange, iterateTreeInVisibleRanges } from '../util'; +import { isCursorInRange } from '../util'; /** * Emoji plugin that converts :emoji_name: to actual emoji characters. * - * This plugin: + * Features: * - Detects emoji patterns like :smile:, :heart:, etc. * - Replaces them with actual emoji characters * - Shows the original text when cursor is nearby + * - Uses RangeSetBuilder for optimal performance */ export const emoji = (): Extension => [emojiPlugin, baseTheme]; /** - * Common emoji mappings. - * Extended from common emoji shortcodes. + * Emoji regex pattern for matching :emoji_name: syntax. */ -const EMOJI_MAP: { [key: string]: string } = { - // Smileys & Emotion - smile: '😄', - smiley: '😃', - grin: '😁', - laughing: '😆', - satisfied: '😆', - sweat_smile: '😅', - rofl: '🤣', - joy: '😂', - slightly_smiling_face: '🙂', - upside_down_face: '🙃', - wink: '😉', - blush: '😊', - innocent: '😇', - smiling_face_with_three_hearts: '🥰', - heart_eyes: '😍', - star_struck: '🤩', - kissing_heart: '😘', - kissing: '😗', - relaxed: '☺️', - kissing_closed_eyes: '😚', - kissing_smiling_eyes: '😙', - smiling_face_with_tear: '🥲', - yum: '😋', - stuck_out_tongue: '😛', - stuck_out_tongue_winking_eye: '😜', - zany_face: '🤪', - stuck_out_tongue_closed_eyes: '😝', - money_mouth_face: '🤑', - hugs: '🤗', - hand_over_mouth: '🤭', - shushing_face: '🤫', - thinking: '🤔', - zipper_mouth_face: '🤐', - raised_eyebrow: '🤨', - neutral_face: '😐', - expressionless: '😑', - no_mouth: '😶', - smirk: '😏', - unamused: '😒', - roll_eyes: '🙄', - grimacing: '😬', - lying_face: '🤥', - relieved: '😌', - pensive: '😔', - sleepy: '😪', - drooling_face: '🤤', - sleeping: '😴', - - // Hearts - heart: '❤️', - orange_heart: '🧡', - yellow_heart: '💛', - green_heart: '💚', - blue_heart: '💙', - purple_heart: '💜', - brown_heart: '🤎', - black_heart: '🖤', - white_heart: '🤍', - - // Gestures - '+1': '👍', - thumbsup: '👍', - '-1': '👎', - thumbsdown: '👎', - fist: '✊', - facepunch: '👊', - punch: '👊', - wave: '👋', - clap: '👏', - raised_hands: '🙌', - pray: '🙏', - handshake: '🤝', - - // Nature - sun: '☀️', - moon: '🌙', - star: '⭐', - fire: '🔥', - zap: '⚡', - sparkles: '✨', - tada: '🎉', - rocket: '🚀', - trophy: '🏆', - - // Symbols - check: '✔️', - x: '❌', - warning: '⚠️', - bulb: '💡', - question: '❓', - exclamation: '❗', - heavy_check_mark: '✔️', - - // Common - eyes: '👀', - eye: '👁️', - brain: '🧠', - muscle: '💪', - ok_hand: '👌', - point_right: '👉', - point_left: '👈', - point_up: '☝️', - point_down: '👇', -}; +const EMOJI_REGEX = /:([a-z0-9_+\-]+):/g; /** - * Widget to display emoji character. + * Common emoji mappings. + */ +const EMOJI_MAP: Map = new Map([ + // Smileys & Emotion + ['smile', '😄'], + ['smiley', '😃'], + ['grin', '😁'], + ['laughing', '😆'], + ['satisfied', '😆'], + ['sweat_smile', '😅'], + ['rofl', '🤣'], + ['joy', '😂'], + ['slightly_smiling_face', '🙂'], + ['upside_down_face', '🙃'], + ['wink', '😉'], + ['blush', '😊'], + ['innocent', '😇'], + ['smiling_face_with_three_hearts', '🥰'], + ['heart_eyes', '😍'], + ['star_struck', '🤩'], + ['kissing_heart', '😘'], + ['kissing', '😗'], + ['relaxed', '☺️'], + ['kissing_closed_eyes', '😚'], + ['kissing_smiling_eyes', '😙'], + ['smiling_face_with_tear', '🥲'], + ['yum', '😋'], + ['stuck_out_tongue', '😛'], + ['stuck_out_tongue_winking_eye', '😜'], + ['zany_face', '🤪'], + ['stuck_out_tongue_closed_eyes', '😝'], + ['money_mouth_face', '🤑'], + ['hugs', '🤗'], + ['hand_over_mouth', '🤭'], + ['shushing_face', '🤫'], + ['thinking', '🤔'], + ['zipper_mouth_face', '🤐'], + ['raised_eyebrow', '🤨'], + ['neutral_face', '😐'], + ['expressionless', '😑'], + ['no_mouth', '😶'], + ['smirk', '😏'], + ['unamused', '😒'], + ['roll_eyes', '🙄'], + ['grimacing', '😬'], + ['lying_face', '🤥'], + ['relieved', '😌'], + ['pensive', '😔'], + ['sleepy', '😪'], + ['drooling_face', '🤤'], + ['sleeping', '😴'], + + // Hearts + ['heart', '❤️'], + ['orange_heart', '🧡'], + ['yellow_heart', '💛'], + ['green_heart', '💚'], + ['blue_heart', '💙'], + ['purple_heart', '💜'], + ['brown_heart', '🤎'], + ['black_heart', '🖤'], + ['white_heart', '🤍'], + + // Gestures + ['+1', '👍'], + ['thumbsup', '👍'], + ['-1', '👎'], + ['thumbsdown', '👎'], + ['fist', '✊'], + ['facepunch', '👊'], + ['punch', '👊'], + ['wave', '👋'], + ['clap', '👏'], + ['raised_hands', '🙌'], + ['pray', '🙏'], + ['handshake', '🤝'], + + // Nature + ['sun', '☀️'], + ['moon', '🌙'], + ['star', '⭐'], + ['fire', '🔥'], + ['zap', '⚡'], + ['sparkles', '✨'], + ['tada', '🎉'], + ['rocket', '🚀'], + ['trophy', '🏆'], + + // Symbols + ['check', '✔️'], + ['x', '❌'], + ['warning', '⚠️'], + ['bulb', '💡'], + ['question', '❓'], + ['exclamation', '❗'], + ['heavy_check_mark', '✔️'], + + // Common + ['eyes', '👀'], + ['eye', '👁️'], + ['brain', '🧠'], + ['muscle', '💪'], + ['ok_hand', '👌'], + ['point_right', '👉'], + ['point_left', '👈'], + ['point_up', '☝️'], + ['point_down', '👇'], +]); + +/** + * Reverse lookup map for emoji to name. + */ +const EMOJI_REVERSE_MAP = new Map(); +EMOJI_MAP.forEach((emoji, name) => { + if (!EMOJI_REVERSE_MAP.has(emoji)) { + EMOJI_REVERSE_MAP.set(emoji, name); + } +}); + +/** + * Emoji widget with optimized rendering. */ class EmojiWidget extends WidgetType { - constructor(readonly emoji: string) { + constructor( + readonly emoji: string, + readonly name: string + ) { super(); } - eq(other: EmojiWidget) { + eq(other: EmojiWidget): boolean { return other.emoji === this.emoji; } @@ -146,62 +164,108 @@ class EmojiWidget extends WidgetType { const span = document.createElement('span'); span.className = 'cm-emoji'; span.textContent = this.emoji; - span.title = ':' + Object.keys(EMOJI_MAP).find( - key => EMOJI_MAP[key] === this.emoji - ) + ':'; + span.title = `:${this.name}:`; return span; } } /** - * Plugin to render emoji. + * Match result for emoji patterns. + */ +interface EmojiMatch { + from: number; + to: number; + name: string; + emoji: string; +} + +/** + * Find all emoji matches in a text range. + */ +function findEmojiMatches(text: string, offset: number): EmojiMatch[] { + const matches: EmojiMatch[] = []; + let match: RegExpExecArray | null; + + // Reset regex state + EMOJI_REGEX.lastIndex = 0; + + while ((match = EMOJI_REGEX.exec(text)) !== null) { + const name = match[1]; + const emoji = EMOJI_MAP.get(name); + + if (emoji) { + matches.push({ + from: offset + match.index, + to: offset + match.index + match[0].length, + name, + emoji + }); + } + } + + return matches; +} + +/** + * Build emoji decorations using RangeSetBuilder. + */ +function buildEmojiDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const doc = view.state.doc; + + for (const { from, to } of view.visibleRanges) { + const text = doc.sliceString(from, to); + const matches = findEmojiMatches(text, from); + + for (const match of matches) { + // Skip if cursor is in this range + if (isCursorInRange(view.state, [match.from, match.to])) { + continue; + } + + builder.add( + match.from, + match.to, + Decoration.replace({ + widget: new EmojiWidget(match.emoji, match.name) + }) + ); + } + } + + return builder.finish(); +} + +/** + * Emoji plugin with optimized update detection. */ class EmojiPlugin { decorations: DecorationSet; + private lastSelectionHead: number = -1; constructor(view: EditorView) { - this.decorations = this.buildDecorations(view); + this.decorations = buildEmojiDecorations(view); + this.lastSelectionHead = view.state.selection.main.head; } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = this.buildDecorations(update.view); + // Always rebuild on doc or viewport change + if (update.docChanged || update.viewportChanged) { + this.decorations = buildEmojiDecorations(update.view); + this.lastSelectionHead = update.state.selection.main.head; + return; } - } - private buildDecorations(view: EditorView): DecorationSet { - const widgets: Array> = []; - const doc = view.state.doc; + // For selection changes, check if we moved significantly + if (update.selectionSet) { + const newHead = update.state.selection.main.head; - for (const { from, to } of view.visibleRanges) { - // Use regex to find :emoji: patterns - const text = doc.sliceString(from, to); - const emojiRegex = /:([a-z0-9_+\-]+):/g; - let match; - - while ((match = emojiRegex.exec(text)) !== null) { - const matchStart = from + match.index; - const matchEnd = matchStart + match[0].length; - - // Skip if cursor is in this range - if (isCursorInRange(view.state, [matchStart, matchEnd])) { - continue; - } - - const emojiName = match[1]; - const emojiChar = EMOJI_MAP[emojiName]; - - if (emojiChar) { - // Replace the :emoji: with the actual emoji - const widget = Decoration.replace({ - widget: new EmojiWidget(emojiChar) - }); - widgets.push(widget.range(matchStart, matchEnd)); - } + // Only rebuild if cursor moved to a different position + if (newHead !== this.lastSelectionHead) { + this.decorations = buildEmojiDecorations(update.view); + this.lastSelectionHead = newHead; } } - - return Decoration.set(widgets, true); } } @@ -227,13 +291,20 @@ const baseTheme = EditorView.baseTheme({ * @param emoji - Emoji character */ export function addEmoji(name: string, emoji: string): void { - EMOJI_MAP[name] = emoji; + EMOJI_MAP.set(name, emoji); + EMOJI_REVERSE_MAP.set(emoji, name); } /** * Get all available emoji names. */ export function getEmojiNames(): string[] { - return Object.keys(EMOJI_MAP); + return Array.from(EMOJI_MAP.keys()); } +/** + * Get emoji by name. + */ +export function getEmoji(name: string): string | undefined { + return EMOJI_MAP.get(name); +} diff --git a/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts b/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts index f8699d1..d2d041a 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts @@ -53,8 +53,8 @@ export const frontmatter: MarkdownExtension = { } } if (end > 0) { - children.push(cx.elt('FrontmatterMark', end - 4, end)); - cx.addElement(cx.elt('Frontmatter', 0, end, children)); + children.push(cx.elt('FrontmatterMark', end - 4, end)); + cx.addElement(cx.elt('Frontmatter', 0, end, children)); } return true; } else { diff --git a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts index 437d4b8..a7bc7f9 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts @@ -1,134 +1,96 @@ -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate -} from '@codemirror/view'; -import { checkRangeOverlap, iterateTreeInVisibleRanges } from '../util'; -import { headingSlugField } from '../state/heading-slug'; -import { heading as classes } from '../classes'; +import { syntaxTree } from '@codemirror/language'; +import { EditorState, StateField, Range } from '@codemirror/state'; +import { Decoration, DecorationSet, EditorView } from '@codemirror/view'; /** - * Ixora headings plugin. - * - * This plugin allows to: - * - Size headings according to their heading level - * - Add default styling to headings + * Hidden mark decoration - uses visibility: hidden to hide content */ -export const headings = () => [ - headingDecorationsPlugin, - hideHeaderMarkPlugin, - baseTheme -]; +const hiddenMarkDecoration = Decoration.mark({ + class: 'cm-heading-mark-hidden' +}); -class HideHeaderMarkPlugin { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = this.hideHeaderMark(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) - this.decorations = this.hideHeaderMark(update.view); - } - /** - * Function to decide if to insert a decoration to hide the header mark - * @param view - Editor view - * @returns The `Decoration`s that hide the header marks - */ - private hideHeaderMark(view: EditorView) { - const widgets: Array> = []; - const ranges = view.state.selection.ranges; - iterateTreeInVisibleRanges(view, { - enter: ({ type, from, to }) => { - // Get the active line - const line = view.lineBlockAt(from); - // If any cursor overlaps with the heading line, skip - const cursorOverlaps = ranges.some(({ from, to }) => - checkRangeOverlap([from, to], [line.from, line.to]) - ); - if (cursorOverlaps) return; - if ( - type.name === 'HeaderMark' && - // Setext heading's horizontal lines are not hidden. - /[#]/.test(view.state.sliceDoc(from, to)) - ) { - const dec = Decoration.replace({}); - widgets.push(dec.range(from, to + 1)); +/** + * Check if selection overlaps with a range. + */ +function isSelectionInRange(state: EditorState, from: number, to: number): boolean { + return state.selection.ranges.some( + (range) => from <= range.to && to >= range.from + ); +} + +/** + * Build heading decorations. + * Hides # marks when cursor is not on the heading line. + */ +function buildHeadingDecorations(state: EditorState): DecorationSet { + const decorations: Range[] = []; + + syntaxTree(state).iterate({ + enter(node) { + // Skip if cursor is in this node's range + if (isSelectionInRange(state, node.from, node.to)) return; + + // Handle ATX headings (# Heading) + if (node.type.name.startsWith('ATXHeading')) { + const header = node.node.firstChild; + if (header && header.type.name === 'HeaderMark') { + const from = header.from; + // Include the space after # + const to = Math.min(header.to + 1, node.to); + decorations.push(hiddenMarkDecoration.range(from, to)); } } - }); - return Decoration.set(widgets, true); - } -} - -/** - * Plugin to hide the header mark. - * - * The header mark will not be hidden when: - * - The cursor is on the active line - * - The mark is on a line which is in the current selection - */ -const hideHeaderMarkPlugin = ViewPlugin.fromClass(HideHeaderMarkPlugin, { - decorations: (v) => v.decorations -}); - -class HeadingDecorationsPlugin { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = this.decorateHeadings(view); - } - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet - ) { - this.decorations = this.decorateHeadings(update.view); - } - } - private decorateHeadings(view: EditorView) { - const widgets: Array> = []; - iterateTreeInVisibleRanges(view, { - enter: ({ name, from }) => { - // To capture ATXHeading and SetextHeading - if (!name.includes('Heading')) return; - const slug = view.state - .field(headingSlugField) - .find((s) => s.pos === from)?.slug; - const match = /[1-6]$/.exec(name); - if (!match) return; - const level = parseInt(match[0]); - const dec = Decoration.line({ - class: [ - classes.heading, - classes.level(level), - slug ? classes.slug(slug) : '' - ].join(' ') + // Handle Setext headings (underline style) + else if (node.type.name.startsWith('SetextHeading')) { + // Hide the underline marks (=== or ---) + const cursor = node.node.cursor(); + cursor.iterate((child) => { + if (child.type.name === 'HeaderMark') { + decorations.push( + hiddenMarkDecoration.range(child.from, child.to) + ); + } }); - widgets.push(dec.range(view.state.doc.lineAt(from).from)); } - }); - return Decoration.set(widgets, true); - } + } + }); + + return Decoration.set(decorations, true); } -const headingDecorationsPlugin = ViewPlugin.fromClass( - HeadingDecorationsPlugin, - { decorations: (v) => v.decorations } -); +/** + * Heading StateField - manages # mark visibility. + */ +const headingField = StateField.define({ + create(state) { + return buildHeadingDecorations(state); + }, + + update(deco, tr) { + if (tr.docChanged || tr.selection) { + return buildHeadingDecorations(tr.state); + } + return deco.map(tr.changes); + }, + + provide: (f) => EditorView.decorations.from(f) +}); /** - * Base theme for headings. + * Theme for hidden heading marks. + * + * Uses fontSize: 0 to hide the # mark without leaving whitespace. + * This works correctly now because blockLayer uses lineBlockAt() + * which calculates coordinates based on the entire line, not + * individual characters, so fontSize: 0 doesn't affect boundaries. */ -const baseTheme = EditorView.baseTheme({ - '.cm-heading': { - fontWeight: 'bold' - }, - ['.' + classes.level(1)]: { fontSize: '2.2rem' }, - ['.' + classes.level(2)]: { fontSize: '1.8rem' }, - ['.' + classes.level(3)]: { fontSize: '1.4rem' }, - ['.' + classes.level(4)]: { fontSize: '1.2rem' }, - ['.' + classes.level(5)]: { fontSize: '1rem' }, - ['.' + classes.level(6)]: { fontSize: '0.8rem' } +const headingTheme = EditorView.baseTheme({ + '.cm-heading-mark-hidden': { + fontSize: '0' + } }); + +/** + * Headings plugin. + */ +export const headings = () => [headingField, headingTheme]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts index 49596f8..dcd6939 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts @@ -5,81 +5,133 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view'; -import { - checkRangeOverlap, - invisibleDecoration, - isCursorInRange, - iterateTreeInVisibleRanges -} from '../util'; +import { RangeSetBuilder } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { checkRangeOverlap, isCursorInRange } from '../util'; /** - * These types contain markers as child elements that can be hidden. + * Node types that contain markers as child elements. */ -export const typesWithMarks = [ +const TYPES_WITH_MARKS = new Set([ 'Emphasis', 'StrongEmphasis', 'InlineCode', 'Strikethrough' -]; -/** - * The elements which are used as marks. - */ -export const markTypes = ['EmphasisMark', 'CodeMark', 'StrikethroughMark']; +]); /** - * Plugin to hide marks when the they are not in the editor selection. + * Node types that are markers themselves. + */ +const MARK_TYPES = new Set([ + 'EmphasisMark', + 'CodeMark', + 'StrikethroughMark' +]); + +// Export for external use +export const typesWithMarks = Array.from(TYPES_WITH_MARKS); +export const markTypes = Array.from(MARK_TYPES); + +/** + * Build mark hiding decorations using RangeSetBuilder for optimal performance. + */ +function buildHideMarkDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const replaceDecoration = Decoration.replace({}); + + // Track processed ranges to avoid duplicate processing of nested marks + let currentParentRange: [number, number] | null = null; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (!TYPES_WITH_MARKS.has(type.name)) return; + + // Skip if this is a nested element within a parent we're already processing + if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) { + return; + } + + // Update current parent range + currentParentRange = [nodeFrom, nodeTo]; + + // Skip if cursor is in this range + if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return; + + // Iterate through child marks + const innerTree = node.toTree(); + innerTree.iterate({ + enter({ type: markType, from: markFrom, to: markTo }) { + if (!MARK_TYPES.has(markType.name)) return; + + // Add decoration to hide the mark + builder.add( + nodeFrom + markFrom, + nodeFrom + markTo, + replaceDecoration + ); + } + }); + } + }); + } + + return builder.finish(); +} + +/** + * Hide marks plugin with optimized update detection. + * + * This plugin: + * - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside + * - Uses RangeSetBuilder for efficient decoration construction + * - Optimizes selection change detection */ class HideMarkPlugin { decorations: DecorationSet; + private lastSelectionRanges: string = ''; + constructor(view: EditorView) { - this.decorations = this.compute(view); + this.decorations = buildHideMarkDecorations(view); + this.lastSelectionRanges = this.serializeSelection(view); } + update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) - this.decorations = this.compute(update.view); - } - compute(view: EditorView): DecorationSet { - const widgets: Array> = []; - let parentRange: [number, number]; - iterateTreeInVisibleRanges(view, { - enter: ({ type, from, to, node }) => { - if (typesWithMarks.includes(type.name)) { - // There can be a possibility that the current node is a - // child eg. a bold node in a emphasis node, so check - // for that or else save the node range - if ( - parentRange && - checkRangeOverlap([from, to], parentRange) - ) - return; - else parentRange = [from, to]; - if (isCursorInRange(view.state, [from, to])) return; - const innerTree = node.toTree(); - innerTree.iterate({ - enter({ type, from: markFrom, to: markTo }) { - // Check for mark types and push the replace - // decoration - if (!markTypes.includes(type.name)) return; - widgets.push( - invisibleDecoration.range( - from + markFrom, - from + markTo - ) - ); - } - }); - } + // Always rebuild on doc or viewport change + if (update.docChanged || update.viewportChanged) { + this.decorations = buildHideMarkDecorations(update.view); + this.lastSelectionRanges = this.serializeSelection(update.view); + return; + } + + // For selection changes, check if selection actually changed positions + if (update.selectionSet) { + const newRanges = this.serializeSelection(update.view); + if (newRanges !== this.lastSelectionRanges) { + this.decorations = buildHideMarkDecorations(update.view); + this.lastSelectionRanges = newRanges; } - }); - return Decoration.set(widgets, true); + } + } + + /** + * Serialize selection ranges for comparison. + */ + private serializeSelection(view: EditorView): string { + return view.state.selection.ranges + .map(r => `${r.from}:${r.to}`) + .join(','); } } /** - * Ixora hide marks plugin. + * Hide marks plugin. * - * This plugin allows to: - * - Hide marks when they are not in the editor selection. + * This plugin: + * - Hides marks when they are not in the editor selection + * - Supports emphasis, strong, inline code, and strikethrough */ export const hideMarks = () => [ ViewPlugin.fromClass(HideMarkPlugin, { diff --git a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts index e8f30da..6ace7c2 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts @@ -1,4 +1,4 @@ -import { Extension, StateField, EditorState } from '@codemirror/state'; +import { Extension, StateField, EditorState, Range } from '@codemirror/state'; import { DecorationSet, Decoration, @@ -14,6 +14,7 @@ import { syntaxTree } from '@codemirror/language'; * This plugin: * - Replaces markdown horizontal rules (---, ***, ___) with styled
elements * - Shows the original text when cursor is on the line + * - Uses inline widget to avoid affecting block system boundaries */ export const horizontalRule = (): Extension => [ horizontalRuleField, @@ -21,16 +22,18 @@ export const horizontalRule = (): Extension => [ ]; /** - * Widget to display a horizontal rule. + * Widget to display a horizontal rule (inline version). */ class HorizontalRuleWidget extends WidgetType { toDOM(): HTMLElement { - const container = document.createElement('div'); - container.className = 'cm-horizontal-rule-container'; + const span = document.createElement('span'); + span.className = 'cm-horizontal-rule-widget'; + const hr = document.createElement('hr'); hr.className = 'cm-horizontal-rule'; - container.appendChild(hr); - return container; + span.appendChild(hr); + + return span; } eq(_other: HorizontalRuleWidget) { @@ -44,9 +47,10 @@ class HorizontalRuleWidget extends WidgetType { /** * Build horizontal rule decorations. + * Uses Decoration.replace WITHOUT block: true to avoid affecting block system. */ function buildHorizontalRuleDecorations(state: EditorState): DecorationSet { - const widgets: Array> = []; + const decorations: Range[] = []; syntaxTree(state).iterate({ enter: ({ type, from, to }) => { @@ -56,19 +60,20 @@ function buildHorizontalRuleDecorations(state: EditorState): DecorationSet { if (isCursorInRange(state, [from, to])) return; // Replace the entire horizontal rule with a styled widget - const widget = Decoration.replace({ - widget: new HorizontalRuleWidget(), - block: true - }); - widgets.push(widget.range(from, to)); + // NOTE: NOT using block: true to avoid affecting codeblock boundaries + decorations.push( + Decoration.replace({ + widget: new HorizontalRuleWidget() + }).range(from, to) + ); } }); - return Decoration.set(widgets, true); + return Decoration.set(decorations, true); } /** - * StateField for horizontal rule decorations (must use StateField for block decorations). + * StateField for horizontal rule decorations. */ const horizontalRuleField = StateField.define({ create(state) { @@ -87,21 +92,19 @@ const horizontalRuleField = StateField.define({ /** * Base theme for horizontal rules. + * Uses inline-block display to render properly without block: true. */ const baseTheme = EditorView.baseTheme({ - '.cm-horizontal-rule-container': { - display: 'flex', - alignItems: 'center', - padding: '0.5rem 0', - margin: '0.5rem 0', - userSelect: 'none' + '.cm-horizontal-rule-widget': { + display: 'inline-block', + width: '100%', + verticalAlign: 'middle' }, '.cm-horizontal-rule': { width: '100%', - height: '1px', + height: '0', border: 'none', borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))', - margin: '0' + margin: '0.5em 0' } }); - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/html.ts b/frontend/src/views/editor/extensions/markdown/plugins/html.ts index 6653d3a..9abe544 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/html.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/html.ts @@ -1,5 +1,5 @@ import { syntaxTree } from '@codemirror/language'; -import { EditorState, StateField } from '@codemirror/state'; +import { EditorState, StateField, Range } from '@codemirror/state'; import { Decoration, DecorationSet, @@ -36,11 +36,11 @@ function extractHTMLBlocks(state: EditorState) { return blocks; } -function blockToDecoration(blocks: EmbedBlockData[]) { +function blockToDecoration(blocks: EmbedBlockData[]): Range[] { return blocks.map((block) => Decoration.widget({ widget: new HTMLBlockWidget(block), - block: true, + // NOTE: NOT using block: true to avoid affecting codeblock boundaries side: 1 }).range(block.to) ); @@ -48,12 +48,13 @@ function blockToDecoration(blocks: EmbedBlockData[]) { export const htmlBlock = StateField.define({ create(state) { - return Decoration.set(blockToDecoration(extractHTMLBlocks(state))); + return Decoration.set(blockToDecoration(extractHTMLBlocks(state)), true); }, update(value, tx) { if (tx.docChanged || tx.selection) { return Decoration.set( - blockToDecoration(extractHTMLBlocks(tx.state)) + blockToDecoration(extractHTMLBlocks(tx.state)), + true ); } return value.map(tx.changes); @@ -64,19 +65,33 @@ export const htmlBlock = StateField.define({ }); class HTMLBlockWidget extends WidgetType { - constructor(public data: EmbedBlockData, public isInline?: true) { + constructor(public data: EmbedBlockData) { super(); } + toDOM(): HTMLElement { - const dom = document.createElement('div'); - dom.style.display = this.isInline ? 'inline' : 'block'; - // Contain child margins - dom.style.overflow = 'auto'; + const dom = document.createElement('span'); + dom.className = 'cm-html-block-widget'; // This is sanitized! dom.innerHTML = this.data.content; return dom; } + eq(widget: HTMLBlockWidget): boolean { return JSON.stringify(widget.data) === JSON.stringify(this.data); } } + +/** + * Base theme for HTML blocks. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-html-block-widget': { + display: 'inline-block', + width: '100%', + overflow: 'auto' + } +}); + +// Export the extension with theme +export const htmlBlockExtension = [htmlBlock, baseTheme]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/link.ts b/frontend/src/views/editor/extensions/markdown/plugins/link.ts index 700f443..cd078fb 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/link.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -1,4 +1,5 @@ import { syntaxTree } from '@codemirror/language'; +import { Range } from '@codemirror/state'; import { Decoration, DecorationSet, @@ -8,147 +9,207 @@ import { WidgetType } from '@codemirror/view'; import { headingSlugField } from '../state/heading-slug'; -import { - checkRangeOverlap, - invisibleDecoration, - isCursorInRange -} from '../util'; +import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util'; import { link as classes } from '../classes'; -const autoLinkMarkRE = /^<|>$/g; +/** + * Pattern for auto-link markers (< and >). + */ +const AUTO_LINK_MARK_RE = /^<|>$/g; /** - * Ixora Links plugin. + * Parent node types that should not have link widgets. + */ +const BLACKLISTED_PARENTS = new Set(['Image']); + +/** + * Links plugin. * - * This plugin allows to: - * - Add an interactive link icon to a URL which can navigate to the URL. + * Features: + * - Adds interactive link icon for navigation + * - Supports internal anchor links (#heading) + * - Hides link markup when cursor is outside */ export const links = () => [goToLinkPlugin, baseTheme]; +/** + * Link widget for external/internal navigation. + */ export class GoToLinkWidget extends WidgetType { - constructor(readonly link: string, readonly title?: string) { + constructor( + readonly link: string, + readonly title?: string + ) { super(); } + + eq(other: GoToLinkWidget): boolean { + return other.link === this.link && other.title === this.title; + } + toDOM(view: EditorView): HTMLElement { const anchor = document.createElement('a'); + anchor.classList.add(classes.widget); + anchor.textContent = '🔗'; + if (this.link.startsWith('#')) { - // Handle links within the markdown document. - const slugs = view.state.field(headingSlugField); - anchor.addEventListener('click', () => { - const pos = slugs.find( - (h) => h.slug === this.link.slice(1) - )?.pos; - // pos could be zero, so instead check if its undefined + // Handle internal anchor links + anchor.href = 'javascript:void(0)'; + anchor.addEventListener('click', (e) => { + e.preventDefault(); + const slugs = view.state.field(headingSlugField); + const targetSlug = this.link.slice(1); + const pos = slugs.find((h) => h.slug === targetSlug)?.pos; + if (typeof pos !== 'undefined') { - const tr = view.state.update({ + view.dispatch({ selection: { anchor: pos }, scrollIntoView: true }); - view.dispatch(tr); } }); - } else anchor.href = this.link; - anchor.target = '_blank'; - anchor.classList.add(classes.widget); - anchor.textContent = '🔗'; - if (this.title) anchor.title = this.title; + } else { + // External links + anchor.href = this.link; + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + + if (this.title) { + anchor.title = this.title; + } + return anchor; } + + ignoreEvent(): boolean { + return false; + } } -function getLinkAnchor(view: EditorView) { - const widgets: Array> = []; +/** + * Build link decorations. + * Uses array + Decoration.set() for automatic sorting. + */ +function buildLinkDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + const selectionRanges = view.state.selection.ranges; for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, - enter: ({ type, from, to, node }) => { + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { if (type.name !== 'URL') return; - const parent = node.parent; - // FIXME: make this configurable - const blackListedParents = ['Image']; - if (parent && !blackListedParents.includes(parent.name)) { - const marks = parent.getChildren('LinkMark'); - const linkTitle = parent.getChild('LinkTitle'); - const ranges = view.state.selection.ranges; - let cursorOverlaps = ranges.some(({ from, to }) => - checkRangeOverlap([from, to], [parent.from, parent.to]) - ); - if (!cursorOverlaps && marks.length > 0) { - widgets.push( - ...marks.map(({ from, to }) => - invisibleDecoration.range(from, to) - ), - invisibleDecoration.range(from, to) - ); - if (linkTitle) - widgets.push( - invisibleDecoration.range( - linkTitle.from, - linkTitle.to - ) - ); - } - let linkContent = view.state.sliceDoc(from, to); - if (autoLinkMarkRE.test(linkContent)) { - // Remove '<' and '>' from link and content - linkContent = linkContent.replace(autoLinkMarkRE, ''); - cursorOverlaps = isCursorInRange(view.state, [ - node.from, - node.to - ]); - if (!cursorOverlaps) { - widgets.push( - invisibleDecoration.range(from, from + 1), - invisibleDecoration.range(to - 1, to) - ); - } + const parent = node.parent; + if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return; + + const marks = parent.getChildren('LinkMark'); + const linkTitle = parent.getChild('LinkTitle'); + + // Check if cursor overlaps with the link + const cursorOverlaps = selectionRanges.some((range) => + checkRangeOverlap([range.from, range.to], [parent.from, parent.to]) + ); + + // Hide link marks and URL when cursor is outside + if (!cursorOverlaps && marks.length > 0) { + for (const mark of marks) { + decorations.push(invisibleDecoration.range(mark.from, mark.to)); } + decorations.push(invisibleDecoration.range(nodeFrom, nodeTo)); + + if (linkTitle) { + decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to)); + } + } + + // Get link content + let linkContent = view.state.sliceDoc(nodeFrom, nodeTo); + + // Handle auto-links with < > markers + if (AUTO_LINK_MARK_RE.test(linkContent)) { + linkContent = linkContent.replace(AUTO_LINK_MARK_RE, ''); + + if (!isCursorInRange(view.state, [node.from, node.to])) { + decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1)); + decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo)); + } + } + + // Get link title content const linkTitleContent = linkTitle ? view.state.sliceDoc(linkTitle.from, linkTitle.to) : undefined; - const dec = Decoration.widget({ - widget: new GoToLinkWidget( - linkContent, - linkTitleContent - ), + + // Add link widget + decorations.push( + Decoration.widget({ + widget: new GoToLinkWidget(linkContent, linkTitleContent), side: 1 - }); - widgets.push(dec.range(to, to)); - } + }).range(nodeTo) + ); } }); } - return Decoration.set(widgets, true); + // Use Decoration.set with sort=true to handle unsorted ranges + return Decoration.set(decorations, true); } -export const goToLinkPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet = Decoration.none; - constructor(view: EditorView) { - this.decorations = getLinkAnchor(view); +/** + * Link plugin with optimized update detection. + */ +class LinkPlugin { + decorations: DecorationSet; + private lastSelectionRanges: string = ''; + + constructor(view: EditorView) { + this.decorations = buildLinkDecorations(view); + this.lastSelectionRanges = this.serializeSelection(view); + } + + update(update: ViewUpdate) { + // Always rebuild on doc or viewport change + if (update.docChanged || update.viewportChanged) { + this.decorations = buildLinkDecorations(update.view); + this.lastSelectionRanges = this.serializeSelection(update.view); + return; } - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet - ) - this.decorations = getLinkAnchor(update.view); + + // For selection changes, check if selection actually changed + if (update.selectionSet) { + const newRanges = this.serializeSelection(update.view); + if (newRanges !== this.lastSelectionRanges) { + this.decorations = buildLinkDecorations(update.view); + this.lastSelectionRanges = newRanges; + } } - }, - { decorations: (v) => v.decorations } -); + } + + private serializeSelection(view: EditorView): string { + return view.state.selection.ranges + .map((r) => `${r.from}:${r.to}`) + .join(','); + } +} + +export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, { + decorations: (v) => v.decorations +}); /** - * Base theme for the links plugin. + * Base theme for links. */ const baseTheme = EditorView.baseTheme({ - ['.' + classes.widget]: { + [`.${classes.widget}`]: { cursor: 'pointer', - textDecoration: 'underline' + textDecoration: 'none', + opacity: '0.7', + transition: 'opacity 0.2s' + }, + [`.${classes.widget}:hover`]: { + opacity: '1' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/list.ts b/frontend/src/views/editor/extensions/markdown/plugins/list.ts index f040557..a639937 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/list.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -6,56 +6,28 @@ import { ViewUpdate, WidgetType } from '@codemirror/view'; -import { isCursorInRange, iterateTreeInVisibleRanges } from '../util'; -import { ChangeSpec, Range } from '@codemirror/state'; -import { NodeType, SyntaxNodeRef } from '@lezer/common'; +import { Range, StateField, Transaction } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { isCursorInRange } from '../util'; import { list as classes } from '../classes'; -const bulletListMarkerRE = /^[-+*]/; +/** + * Pattern for bullet list markers. + */ +const BULLET_LIST_MARKER_RE = /^[-+*]$/; /** - * Ixora Lists plugin. + * Lists plugin. * - * This plugin allows to: - * - Customize list mark - * - Add an interactive checkbox for task lists + * Features: + * - Custom bullet mark rendering (- → •) + * - Interactive task list checkboxes */ -export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme]; +export const lists = () => [listBulletPlugin, taskListField, baseTheme]; -/** - * Plugin to add custom list bullet mark. - */ -class ListBulletPlugin { - decorations: DecorationSet = Decoration.none; - constructor(view: EditorView) { - this.decorations = this.decorateLists(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) - this.decorations = this.decorateLists(update.view); - } - private decorateLists(view: EditorView) { - const widgets: Array> = []; - iterateTreeInVisibleRanges(view, { - enter: ({ type, from, to }) => { - if (isCursorInRange(view.state, [from, to])) return; - if (type.name === 'ListMark') { - const listMark = view.state.sliceDoc(from, to); - if (bulletListMarkerRE.test(listMark)) { - const dec = Decoration.replace({ - widget: new ListBulletWidget(listMark) - }); - widgets.push(dec.range(from, to)); - } - } - } - }); - return Decoration.set(widgets, true); - } -} -const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, { - decorations: (v) => v.decorations -}); +// ============================================================================ +// List Bullet Plugin +// ============================================================================ /** * Widget to render list bullet mark. @@ -64,114 +36,244 @@ class ListBulletWidget extends WidgetType { constructor(readonly bullet: string) { super(); } + + eq(other: ListBulletWidget): boolean { + return other.bullet === this.bullet; + } + toDOM(): HTMLElement { - const listBullet = document.createElement('span'); - listBullet.textContent = this.bullet; - listBullet.className = 'cm-list-bullet'; - return listBullet; + const span = document.createElement('span'); + span.className = classes.bullet; + span.textContent = '•'; + return span; } } /** - * Plugin to add checkboxes in task lists. + * Build list bullet decorations. */ -class TaskListsPlugin { - decorations: DecorationSet = Decoration.none; - constructor(view: EditorView) { - this.decorations = this.addCheckboxes(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) - this.decorations = this.addCheckboxes(update.view); - } - addCheckboxes(view: EditorView) { - const widgets: Range[] = []; - iterateTreeInVisibleRanges(view, { - enter: this.iterateTree(view, widgets) - }); - return Decoration.set(widgets, true); - } +function buildListBulletDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; - private iterateTree(view: EditorView, widgets: Range[]) { - return ({ type, from, to, node }: SyntaxNodeRef) => { - if (type.name !== 'Task') return; - let checked = false; - // Iterate inside the task node to find the checkbox - node.toTree().iterate({ - enter: (ref) => iterateInner(ref.type, ref.from, ref.to) - }); - if (checked) - widgets.push( - Decoration.mark({ - tagName: 'span', - class: 'cm-task-checked' - }).range(from, to) - ); + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { + if (type.name !== 'ListMark') return; - function iterateInner(type: NodeType, nfrom: number, nto: number) { - if (type.name !== 'TaskMarker') return; - if (isCursorInRange(view.state, [from + nfrom, from + nto])) - return; - const checkbox = view.state.sliceDoc(from + nfrom, from + nto); - // Checkbox is checked if it has a 'x' in between the [] - if ('xX'.includes(checkbox[1])) checked = true; - const dec = Decoration.replace({ - widget: new CheckboxWidget(checked, from + nfrom + 1) - }); - widgets.push(dec.range(from + nfrom, from + nto)); + // Skip if this is part of a task list (has Task sibling) + const parent = node.parent; + if (parent) { + const task = parent.getChild('Task'); + if (task) return; + } + + // Skip if cursor is in this range + if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return; + + const listMark = view.state.sliceDoc(nodeFrom, nodeTo); + if (BULLET_LIST_MARKER_RE.test(listMark)) { + decorations.push( + Decoration.replace({ + widget: new ListBulletWidget(listMark) + }).range(nodeFrom, nodeTo) + ); + } } - }; + }); + } + + return Decoration.set(decorations, true); +} + +/** + * List bullet plugin. + */ +class ListBulletPlugin { + decorations: DecorationSet; + private lastSelectionHead: number = -1; + + constructor(view: EditorView) { + this.decorations = buildListBulletDecorations(view); + this.lastSelectionHead = view.state.selection.main.head; + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildListBulletDecorations(update.view); + this.lastSelectionHead = update.state.selection.main.head; + return; + } + + if (update.selectionSet) { + const newHead = update.state.selection.main.head; + const oldLine = update.startState.doc.lineAt(this.lastSelectionHead); + const newLine = update.state.doc.lineAt(newHead); + + if (oldLine.number !== newLine.number) { + this.decorations = buildListBulletDecorations(update.view); + } + this.lastSelectionHead = newHead; + } } } +const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, { + decorations: (v) => v.decorations +}); + +// ============================================================================ +// Task List Plugin (using StateField to avoid flickering) +// ============================================================================ + /** * Widget to render checkbox for a task list item. */ -class CheckboxWidget extends WidgetType { - constructor(public checked: boolean, readonly pos: number) { +class TaskCheckboxWidget extends WidgetType { + constructor( + readonly checked: boolean, + readonly pos: number // Position of the checkbox character in document + ) { super(); } + + eq(other: TaskCheckboxWidget): boolean { + return other.checked === this.checked && other.pos === this.pos; + } + toDOM(view: EditorView): HTMLElement { const wrap = document.createElement('span'); - wrap.classList.add(classes.taskCheckbox); + wrap.setAttribute('aria-hidden', 'true'); + wrap.className = classes.taskCheckbox; + const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = this.checked; - checkbox.addEventListener('click', ({ target }) => { - const change: ChangeSpec = { - from: this.pos, - to: this.pos + 1, - insert: this.checked ? ' ' : 'x' - }; - view.dispatch({ changes: change }); - this.checked = !this.checked; - (target as HTMLInputElement).checked = this.checked; + checkbox.tabIndex = -1; + + // Handle click directly in the widget + checkbox.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const newValue = !this.checked; + view.dispatch({ + changes: { + from: this.pos, + to: this.pos + 1, + insert: newValue ? 'x' : ' ' + } + }); }); + wrap.appendChild(checkbox); return wrap; } + + ignoreEvent(): boolean { + return false; + } } -const taskListPlugin = ViewPlugin.fromClass(TaskListsPlugin, { - decorations: (v) => v.decorations -}); +/** + * Build task list decorations from state. + */ +function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet { + const decorations: Range[] = []; + + syntaxTree(state).iterate({ + enter: ({ type, from: taskFrom, to: taskTo, node }) => { + if (type.name !== 'Task') return; + + const listItem = node.parent; + if (!listItem || listItem.type.name !== 'ListItem') return; + + const listMark = listItem.getChild('ListMark'); + const taskMarker = node.getChild('TaskMarker'); + + if (!listMark || !taskMarker) return; + + const replaceFrom = listMark.from; + const replaceTo = taskMarker.to; + + // Check if cursor is in this range + if (isCursorInRange(state, [replaceFrom, replaceTo])) return; + + // Check if task is checked - position of x or space is taskMarker.from + 1 + const markerText = state.sliceDoc(taskMarker.from, taskMarker.to); + const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]); + const checkboxPos = taskMarker.from + 1; // Position of the x or space + + // Add checked style to the entire task content + if (isChecked) { + decorations.push( + Decoration.mark({ + class: classes.taskChecked + }).range(taskFrom, taskTo) + ); + } + + // Replace "- [x]" or "- [ ]" with checkbox widget + decorations.push( + Decoration.replace({ + widget: new TaskCheckboxWidget(isChecked, checkboxPos) + }).range(replaceFrom, replaceTo) + ); + } + }); + + return Decoration.set(decorations, true); +} /** - * Base theme for the lists plugin. + * Task list StateField - uses incremental updates to avoid flickering. */ -const baseTheme = EditorView.baseTheme({ - ['.' + classes.bullet]: { - position: 'relative', - visibility: 'hidden' +const taskListField = StateField.define({ + create(state) { + return buildTaskListDecorations(state); }, - ['.' + classes.taskChecked]: { - textDecoration: 'line-through !important' + + update(value, tr: Transaction) { + // Only rebuild when document or selection changes + if (tr.docChanged || tr.selection) { + return buildTaskListDecorations(tr.state); + } + return value; }, - ['.' + classes.bullet + ':after']: { - visibility: 'visible', - position: 'absolute', - top: 0, - left: 0, - content: "'\\2022'" /* U+2022 BULLET */ + + provide(field) { + return EditorView.decorations.from(field); + } +}); + +// ============================================================================ +// Theme +// ============================================================================ + +/** + * Base theme for lists. + */ +const baseTheme = EditorView.baseTheme({ + [`.${classes.bullet}`]: { + // No extra width - just replace the character + color: 'var(--cm-list-bullet-color, inherit)' + }, + [`.${classes.taskChecked}`]: { + textDecoration: 'line-through', + opacity: '0.6' + }, + [`.${classes.taskCheckbox}`]: { + display: 'inline-block', + verticalAlign: 'baseline' + }, + [`.${classes.taskCheckbox} input`]: { + cursor: 'pointer', + margin: '0', + marginRight: '0.35em', + width: '1em', + height: '1em', + position: 'relative', + top: '0.1em' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/soft-indent.ts b/frontend/src/views/editor/extensions/markdown/plugins/soft-indent.ts deleted file mode 100644 index e9ccc84..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/soft-indent.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Annotation, - Line, - RangeSet, - RangeSetBuilder, - Extension -} from '@codemirror/state'; -import { - Decoration, - EditorView, - ViewPlugin, - ViewUpdate, - type DecorationSet -} from '@codemirror/view'; - -/** - * Soft indent plugin for better visual alignment of list items and blockquotes. - * - * This plugin: - * - Measures the width of list markers, blockquote markers, etc. - * - Applies padding to align the content properly - * - Updates dynamically as content changes - */ -export const softIndent = (): Extension => [softIndentPlugin]; - -interface IndentData { - line: Line; - indentWidth: number; -} - -/** - * Pattern to match content that needs soft indentation: - * - Blockquote markers (> ) - * - List markers (-, *, +, 1., etc.) - * - Task markers ([x] or [ ]) - */ -const softIndentPattern = /^(> )*(\s*)?(([-*+]|\d+[.)])\s)?(\[.\]\s)?/; - -const softIndentRefresh = Annotation.define(); - -/** - * Plugin to apply soft indentation. - */ -class SoftIndentPlugin { - decorations: DecorationSet = Decoration.none; - - constructor(view: EditorView) { - this.requestMeasure(view); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.requestMeasure(update.view); - } - - if (update.transactions.some((tr) => tr.annotation(softIndentRefresh))) { - this.requestMeasure(update.view); - } - } - - requestMeasure(view: EditorView) { - // Needs to run via requestMeasure since it measures and updates the DOM - view.requestMeasure({ - read: (view) => this.measureIndents(view), - write: (indents, view) => { - this.applyIndents(indents, view); - } - }); - } - - /** - * Measure the indent width for each line that needs soft indentation. - */ - measureIndents(view: EditorView): IndentData[] { - const indents: IndentData[] = []; - - // Loop through all visible lines - for (const { from, to } of view.visibleRanges) { - const start = view.state.doc.lineAt(from); - const end = view.state.doc.lineAt(to); - - for (let i = start.number; i <= end.number; i++) { - // Get current line object - const line = view.state.doc.line(i); - - // Match the line's text with the indent pattern - const text = view.state.sliceDoc(line.from, line.to); - const matches = softIndentPattern.exec(text); - if (!matches) continue; - - const nonContent = matches[0]; - if (!nonContent) continue; - - // Get indent width by measuring DOM coordinates - const startCoords = view.coordsAtPos(line.from); - const endCoords = view.coordsAtPos(line.from + nonContent.length); - - if (!startCoords || !endCoords) continue; - - const indentWidth = endCoords.left - startCoords.left; - if (indentWidth <= 0) continue; - - indents.push({ - line, - indentWidth - }); - } - } - - return indents; - } - - /** - * Build decorations from indent data. - */ - buildDecorations(indents: IndentData[]): DecorationSet { - const builder = new RangeSetBuilder(); - - for (const { line, indentWidth } of indents) { - const deco = Decoration.line({ - attributes: { - style: `padding-inline-start: ${Math.ceil( - indentWidth + 6 - )}px; text-indent: -${Math.ceil(indentWidth)}px;` - } - }); - - builder.add(line.from, line.from, deco); - } - - return builder.finish(); - } - - /** - * Apply new decorations and dispatch a transaction if needed. - */ - applyIndents(indents: IndentData[], view: EditorView) { - const newDecos = this.buildDecorations(indents); - let changed = false; - - for (const { from, to } of view.visibleRanges) { - if (!RangeSet.eq([this.decorations], [newDecos], from, to)) { - changed = true; - break; - } - } - - if (changed) { - queueMicrotask(() => { - view.dispatch({ annotations: [softIndentRefresh.of(true)] }); - }); - } - - this.decorations = newDecos; - } -} - -const softIndentPlugin = ViewPlugin.fromClass(SoftIndentPlugin, { - decorations: (v) => v.decorations -}); - diff --git a/frontend/src/views/editor/extensions/markdown/state/image.ts b/frontend/src/views/editor/extensions/markdown/state/image.ts index 8323520..2cfca03 100644 --- a/frontend/src/views/editor/extensions/markdown/state/image.ts +++ b/frontend/src/views/editor/extensions/markdown/state/image.ts @@ -48,11 +48,12 @@ export const imagePreview = StateField.define({ create(state) { const images = extractImages(state); const decorations = images.map((img) => - // This does not need to be a block widget + // NOTE: NOT using block: true to avoid affecting codeblock boundaries Decoration.widget({ widget: new ImagePreviewWidget(img, WidgetState.INITIAL), info: img, - src: img.src + src: img.src, + side: 1 }).range(img.to) ); return Decoration.set(decorations, true); @@ -86,9 +87,8 @@ export const imagePreview = StateField.define({ ? WidgetState.LOADED : WidgetState.INITIAL ), - // Create returns a inline widget, return inline image - // if image is not loaded for consistency. - block: hasImageLoaded ? true : false, + // NOTE: NOT using block: true to avoid affecting codeblock boundaries + // Always use inline widget src: img.src, side: 1, // This is important to keep track of loaded images @@ -137,6 +137,9 @@ class ImagePreviewWidget extends WidgetType { } toDOM(view: EditorView): HTMLElement { + const wrapper = document.createElement('span'); + wrapper.className = 'cm-image-preview-wrapper'; + const img = new Image(); img.classList.add(classes.widget); img.src = this.info.src; @@ -157,9 +160,11 @@ class ImagePreviewWidget extends WidgetType { view.dispatch(tx); }); - if (this.state === WidgetState.LOADED) return img; - // Render placeholder - else return new Image(); + if (this.state === WidgetState.LOADED) { + wrapper.appendChild(img); + } + // Return wrapper (empty for initial state, with img for loaded state) + return wrapper; } eq(widget: ImagePreviewWidget): boolean { diff --git a/frontend/src/views/editor/extensions/markdown/util.ts b/frontend/src/views/editor/extensions/markdown/util.ts index 0d597ec..83c0fee 100644 --- a/frontend/src/views/editor/extensions/markdown/util.ts +++ b/frontend/src/views/editor/extensions/markdown/util.ts @@ -1,49 +1,112 @@ import { foldedRanges, syntaxTree } from '@codemirror/language'; -import type { SyntaxNodeRef } from '@lezer/common'; +import type { SyntaxNodeRef, TreeCursor } from '@lezer/common'; import { Decoration, EditorView } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; +import { + EditorState, + SelectionRange, + CharCategory, + findClusterBreak +} from '@codemirror/state'; + +// ============================================================================ +// Type Definitions (ProseMark style) +// ============================================================================ /** - * Check if two ranges overlap + * A range-like object with from and to properties. + */ +export interface RangeLike { + from: number; + to: number; +} + +/** + * Tuple representation of a range [from, to]. + */ +export type RangeTuple = [number, number]; + +// ============================================================================ +// Range Utilities +// ============================================================================ + +/** + * Check if two ranges overlap (touch or intersect). * Based on the visual diagram on https://stackoverflow.com/a/25369187 - * @param range1 - Range 1 - * @param range2 - Range 2 + * + * @param range1 - First range + * @param range2 - Second range * @returns True if the ranges overlap */ export function checkRangeOverlap( - range1: [number, number], - range2: [number, number] -) { + range1: RangeTuple, + range2: RangeTuple +): boolean { return range1[0] <= range2[1] && range2[0] <= range1[1]; } /** - * Check if a range is inside another range + * Check if two range-like objects touch or overlap. + * ProseMark-style range comparison. + * + * @param a - First range + * @param b - Second range + * @returns True if ranges touch + */ +export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean { + return a.from <= b.to && b.from <= a.to; +} + +/** + * Check if a selection touches a range. + * + * @param selection - Array of selection ranges + * @param range - Range to check against + * @returns True if any selection touches the range + */ +export function selectionTouchesRange( + selection: readonly SelectionRange[], + range: RangeLike +): boolean { + return selection.some((sel) => rangeTouchesRange(sel, range)); +} + +/** + * Check if a range is inside another range (subset). + * * @param parent - Parent (bigger) range * @param child - Child (smaller) range * @returns True if child is inside parent */ export function checkRangeSubset( - parent: [number, number], - child: [number, number] -) { + parent: RangeTuple, + child: RangeTuple +): boolean { return child[0] >= parent[0] && child[1] <= parent[1]; } /** - * Check if any of the editor cursors is in the given range + * Check if any of the editor cursors is in the given range. + * * @param state - Editor state * @param range - Range to check * @returns True if the cursor is in the range */ -export function isCursorInRange(state: EditorState, range: [number, number]) { +export function isCursorInRange( + state: EditorState, + range: RangeTuple +): boolean { return state.selection.ranges.some((selection) => checkRangeOverlap(range, [selection.from, selection.to]) ); } +// ============================================================================ +// Tree Iteration Utilities +// ============================================================================ + /** - * Iterate over the syntax tree in the visible ranges of the document + * Iterate over the syntax tree in the visible ranges of the document. + * * @param view - Editor view * @param iterateFns - Object with `enter` and `leave` iterate function */ @@ -53,30 +116,49 @@ export function iterateTreeInVisibleRanges( enter(node: SyntaxNodeRef): boolean | void; leave?(node: SyntaxNodeRef): void; } -) { +): void { for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ ...iterateFns, from, to }); } } /** - * Decoration to simply hide anything. + * Iterate through child nodes of a cursor. + * ProseMark-style tree traversal. + * + * @param cursor - Tree cursor to iterate + * @param enter - Callback function, return true to stop iteration */ -export const invisibleDecoration = Decoration.replace({}); +export function iterChildren( + cursor: TreeCursor, + enter: (cursor: TreeCursor) => boolean | undefined +): void { + if (!cursor.firstChild()) return; + do { + if (enter(cursor)) break; + } while (cursor.nextSibling()); + cursor.parent(); +} + +// ============================================================================ +// Line Utilities +// ============================================================================ /** * Returns the lines of the editor that are in the given range and not folded. - * This function is of use when you need to get the lines of a particular - * block node and add line decorations to each line of it. + * This function is useful for adding line decorations to each line of a block node. * * @param view - Editor view * @param from - Start of the range * @param to - End of the range * @returns A list of line blocks that are in the range */ -export function editorLines(view: EditorView, from: number, to: number) { +export function editorLines( + view: EditorView, + from: number, + to: number +) { let lines = view.viewportLineBlocks.filter((block) => - // Keep lines that are in the range checkRangeOverlap([block.from, block.to], [from, to]) ); @@ -96,28 +178,175 @@ export function editorLines(view: EditorView, from: number, to: number) { } /** - * Class containing methods to generate slugs from heading contents. + * Get line numbers for a range. + * + * @param state - Editor state + * @param from - Start position + * @param to - End position + * @returns Array of line numbers + */ +export function getLineNumbers( + state: EditorState, + from: number, + to: number +): number[] { + const startLine = state.doc.lineAt(from).number; + const endLine = state.doc.lineAt(to).number; + const lines: number[] = []; + + for (let i = startLine; i <= endLine; i++) { + lines.push(i); + } + + return lines; +} + +// ============================================================================ +// Word Utilities (ProseMark style) +// ============================================================================ + +/** + * Get the "WORD" at a position (vim-style WORD, including non-whitespace). + * + * @param state - Editor state + * @param pos - Position in document + * @returns Selection range of the WORD, or null if at whitespace + */ +export function stateWORDAt( + state: EditorState, + pos: number +): SelectionRange | null { + const { text, from, length } = state.doc.lineAt(pos); + const cat = state.charCategorizer(pos); + let start = pos - from; + let end = pos - from; + + while (start > 0) { + const prev = findClusterBreak(text, start, false); + if (cat(text.slice(prev, start)) === CharCategory.Space) break; + start = prev; + } + + while (end < length) { + const next = findClusterBreak(text, end); + if (cat(text.slice(end, next)) === CharCategory.Space) break; + end = next; + } + + return start === end + ? null + : { from: start + from, to: end + from } as SelectionRange; +} + +// ============================================================================ +// Decoration Utilities +// ============================================================================ + +/** + * Decoration to simply hide anything (replace with nothing). + */ +export const invisibleDecoration = Decoration.replace({}); + +/** + * Decoration to hide inline content (font-size: 0). + */ +export const hideInlineDecoration = Decoration.mark({ + class: 'cm-hidden-token' +}); + +/** + * Decoration to make content transparent but preserve space. + */ +export const hideInlineKeepSpaceDecoration = Decoration.mark({ + class: 'cm-transparent-token' +}); + +// ============================================================================ +// Slug Generation +// ============================================================================ + +/** + * Class for generating unique slugs from heading contents. */ export class Slugger { /** Occurrences for each slug. */ - private occurences: { [key: string]: number } = {}; + private occurrences: Map = new Map(); + /** * Generate a slug from the given content. + * * @param text - Content to generate the slug from - * @returns the slug + * @returns The generated slug */ - public slug(text: string) { + public slug(text: string): string { let slug = text .toLowerCase() .replace(/\s+/g, '-') .replace(/[^\w-]+/g, ''); - if (slug in this.occurences) { - this.occurences[slug]++; - slug += '-' + this.occurences[slug]; - } else { - this.occurences[slug] = 1; + const count = this.occurrences.get(slug) || 0; + if (count > 0) { + slug += '-' + count; } + this.occurrences.set(slug, count + 1); + return slug; } + + /** + * Reset the slugger state. + */ + public reset(): void { + this.occurrences.clear(); + } +} + +// ============================================================================ +// Performance Utilities +// ============================================================================ + +/** + * Create a debounced version of a function. + * + * @param fn - Function to debounce + * @param delay - Delay in milliseconds + * @returns Debounced function + */ +export function debounce void>( + fn: T, + delay: number +): T { + let timeoutId: ReturnType | null = null; + + return ((...args: unknown[]) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + fn(...args); + timeoutId = null; + }, delay); + }) as T; +} + +/** + * Create a throttled version of a function. + * + * @param fn - Function to throttle + * @param limit - Minimum time between calls in milliseconds + * @returns Throttled function + */ +export function throttle void>( + fn: T, + limit: number +): T { + let lastCall = 0; + + return ((...args: unknown[]) => { + const now = Date.now(); + if (now - lastCall >= limit) { + lastCall = now; + fn(...args); + } + }) as T; } diff --git a/frontend/src/views/editor/extensions/spellcheck/spellcheck.ts b/frontend/src/views/editor/extensions/spellcheck/spellcheck.ts new file mode 100644 index 0000000..ee08676 --- /dev/null +++ b/frontend/src/views/editor/extensions/spellcheck/spellcheck.ts @@ -0,0 +1,8 @@ +import type { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +export const spellcheck = (): Extension => { + return EditorView.contentAttributes.of({ + spellcheck: 'true', + }) +}