mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Wrap selected text with typed pairs (#119)
Adds modular selection-pair handling for the rich editor and source mode so typing (, {, [, ', " or ` around a selection wraps it instead of replacing it.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/119
This commit is contained in:
@@ -44,6 +44,8 @@
|
|||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
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 GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
|
|
||||||
@@ -109,6 +111,25 @@
|
|||||||
return true;
|
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() {
|
function closeAllDropdowns() {
|
||||||
headingDropdown = false;
|
headingDropdown = false;
|
||||||
colorDropdown = false;
|
colorDropdown = false;
|
||||||
@@ -3411,6 +3432,7 @@
|
|||||||
}),
|
}),
|
||||||
CtrlEndScrollPastEnd,
|
CtrlEndScrollPastEnd,
|
||||||
HeadingShortcuts,
|
HeadingShortcuts,
|
||||||
|
WrapSelectedText,
|
||||||
SlashCommands,
|
SlashCommands,
|
||||||
MoveLineShortcuts,
|
MoveLineShortcuts,
|
||||||
TabIndent,
|
TabIndent,
|
||||||
@@ -4880,6 +4902,7 @@
|
|||||||
}}
|
}}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (handleSourceCtrlEnd(e)) return;
|
if (handleSourceCtrlEnd(e)) return;
|
||||||
|
if (handleSourceSelectionPair(e)) return;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
if (e.key === 'Enter' && e.shiftKey && !mod) {
|
if (e.key === 'Enter' && e.shiftKey && !mod) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -4938,6 +4961,7 @@
|
|||||||
}}
|
}}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (handleSourceCtrlEnd(e)) return;
|
if (handleSourceCtrlEnd(e)) return;
|
||||||
|
if (handleSourceSelectionPair(e)) return;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
// Shift+Enter: insert two trailing spaces + newline for markdown hard break
|
// Shift+Enter: insert two trailing spaces + newline for markdown hard break
|
||||||
if (e.key === 'Enter' && e.shiftKey && !mod) {
|
if (e.key === 'Enter' && e.shiftKey && !mod) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
'(': ')',
|
||||||
|
'{': '}',
|
||||||
|
'[': ']',
|
||||||
|
"'": "'",
|
||||||
|
'"': '"',
|
||||||
|
'`': '`',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the closing character for a typed wrapper key, or null to pass through.
|
||||||
|
export function getSelectionPair(key: string) {
|
||||||
|
return selectionPairs[key] ?? null;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user