diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 3268204..b0b4027 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -44,6 +44,8 @@ import { listen } from '@tauri-apps/api/event'; import { debounce } from '$lib/utils/debounce'; import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets'; + import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText'; + import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -109,6 +111,25 @@ return true; } + function handleSourceSelectionPair(event: KeyboardEvent) { + if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) return false; + const ta = sourceElement; + if (!ta) return false; + const wrapped = wrapTextareaSelection(ta.value, ta.selectionStart, ta.selectionEnd, event.key); + if (!wrapped) return false; + + event.preventDefault(); + pushSourceHistoryImmediate(); + sourceContent = wrapped.value; + tick().then(() => { + ta.setSelectionRange(wrapped.selectionStart, wrapped.selectionEnd); + pushSourceHistoryImmediate(); + }); + $editorDirty = true; + autoSave(); + return true; + } + function closeAllDropdowns() { headingDropdown = false; colorDropdown = false; @@ -3411,6 +3432,7 @@ }), CtrlEndScrollPastEnd, HeadingShortcuts, + WrapSelectedText, SlashCommands, MoveLineShortcuts, TabIndent, @@ -4880,6 +4902,7 @@ }} onkeydown={(e) => { if (handleSourceCtrlEnd(e)) return; + if (handleSourceSelectionPair(e)) return; const mod = e.ctrlKey || e.metaKey; if (e.key === 'Enter' && e.shiftKey && !mod) { e.preventDefault(); @@ -4938,6 +4961,7 @@ }} onkeydown={(e) => { if (handleSourceCtrlEnd(e)) return; + if (handleSourceSelectionPair(e)) return; const mod = e.ctrlKey || e.metaKey; // Shift+Enter: insert two trailing spaces + newline for markdown hard break if (e.key === 'Enter' && e.shiftKey && !mod) { diff --git a/src/lib/editor/extensions/wrapSelectedText.ts b/src/lib/editor/extensions/wrapSelectedText.ts new file mode 100644 index 0000000..b431cbb --- /dev/null +++ b/src/lib/editor/extensions/wrapSelectedText.ts @@ -0,0 +1,30 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import { getSelectionPair } from '../selectionPairs'; + +// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection +// without flattening selected inline marks or content to plain text. +export const WrapSelectedText = Extension.create({ + name: 'wrapSelectedText', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('wrapSelectedText'), + props: { + // Intercept only supported single-character text input over a range. + // Everything else falls back to ProseMirror's normal input handling. + handleTextInput(view, from, to, text) { + const close = getSelectionPair(text); + if (!close || from === to) return false; + + const tr = view.state.tr.insertText(close, to).insertText(text, from); + tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); + view.dispatch(tr); + return true; + }, + }, + }), + ]; + }, +}); diff --git a/src/lib/editor/selectionPairs.ts b/src/lib/editor/selectionPairs.ts new file mode 100644 index 0000000..acb19d6 --- /dev/null +++ b/src/lib/editor/selectionPairs.ts @@ -0,0 +1,15 @@ +// Shared source of truth for editor behaviors that wrap an active selection. +// Keep this small: only characters users reasonably expect to form pairs. +const selectionPairs: Record = { + '(': ')', + '{': '}', + '[': ']', + "'": "'", + '"': '"', + '`': '`', +}; + +// Returns the closing character for a typed wrapper key, or null to pass through. +export function getSelectionPair(key: string) { + return selectionPairs[key] ?? null; +} diff --git a/src/lib/editor/source/selectionPairs.ts b/src/lib/editor/source/selectionPairs.ts new file mode 100644 index 0000000..9ef3ce1 --- /dev/null +++ b/src/lib/editor/source/selectionPairs.ts @@ -0,0 +1,14 @@ +import { getSelectionPair } from '../selectionPairs'; + +// Pure source-mode helper: computes the wrapped textarea value and preserved +// inner selection so Svelte event handlers stay as glue code. +export function wrapTextareaSelection(value: string, start: number, end: number, key: string) { + const close = getSelectionPair(key); + if (!close || start === end) return null; + + return { + value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end), + selectionStart: start + key.length, + selectionEnd: end + key.length, + }; +}