bunch of fixes and refactoring for suggestions/rhymes in the pad editor

This commit is contained in:
silentsilas 2025-04-05 12:20:34 -04:00
parent 3e2e1b0bcb
commit 637ec3657b
Signed by: silentsilas
GPG Key ID: 113DFB380F724A81
6 changed files with 202 additions and 138 deletions

View File

@ -6,125 +6,71 @@
import '@friendofsvelte/tipex/styles/EditLink.css'; import '@friendofsvelte/tipex/styles/EditLink.css';
import '@friendofsvelte/tipex/styles/CodeBlock.css'; import '@friendofsvelte/tipex/styles/CodeBlock.css';
import Utilities from './Utilities.svelte'; import Utilities from './Utilities.svelte';
import SuggestionList from './SuggestionList.svelte';
import { datamuseApi, type Suggestion } from '$lib/services/datamuse';
import { calculateCursorPosition } from '$lib/utils/cursor';
import { createDebounce } from '$lib/utils/debounce';
import { extractCurrentWord, extractSelectedWord } from '$lib/utils/text';
let { initialContent } = $props(); let { initialContent } = $props();
let body = $state(localStorage.getItem('tipex') || initialContent);
let editor = $state<TipexEditor>(); let editor = $state<TipexEditor>();
let suggestions = $state<Array<{ word: string; score: number }>>([]); let body = $state(initialContent || localStorage.getItem('tipex') || '');
let rhymes = $state<Array<{ word: string; score: number }>>([]); let suggestions = $state<Suggestion[]>([]);
let rhymes = $state<Suggestion[]>([]);
let currentWord = $state(''); let currentWord = $state('');
let selectedIndex = $state(-1);
let cursorPosition = $state({ x: 0, y: 0 }); let cursorPosition = $state({ x: 0, y: 0 });
let debounceTimer = $state<NodeJS.Timeout>(); const debounce = createDebounce();
type Suggestion = { $effect(() => {
word: string; if (editor?.getHTML()) {
score: number; localStorage.setItem('tipex', editor.getHTML());
};
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
return (...args: Parameters<T>) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fn(...args), delay);
};
}
const datamuseApi = {
async getSuggestions(word: string): Promise<Suggestion[]> {
if (!word || word.length < 2) return [];
const response = await fetch(`https://api.datamuse.com/sug?s=${encodeURIComponent(word)}`);
return response.json();
},
async getRhymes(word: string): Promise<Suggestion[]> {
if (!word) return [];
const response = await fetch(
`https://api.datamuse.com/words?rel_rhy=${encodeURIComponent(word)}`
);
return response.json();
} }
}; });
const debouncedFetchSuggestions = debounce(async (word: string) => {
suggestions = await datamuseApi.getSuggestions(word);
}, 300);
async function handleSelectionChange() {
const selection = editor?.view.state.selection;
if (selection && !selection.empty) {
const selectedText = editor?.view.state.doc.textBetween(selection.from, selection.to, ' ');
if (selectedText) {
rhymes = await datamuseApi.getRhymes(selectedText);
updateCursorPosition();
}
}
}
function updateCursorPosition() { function updateCursorPosition() {
const selection = window.getSelection(); cursorPosition = calculateCursorPosition(editor);
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const x = Math.min(Math.max(rect.left + window.scrollX, 100), window.innerWidth - 100);
cursorPosition = {
x,
y: rect.bottom + window.scrollY + 10
};
} }
async function handleUpdate() { const handleUpdate = debounce(async () => {
const currentHtml = editor?.getHTML();
localStorage.setItem('tipex', currentHtml || '');
const { state } = editor?.view || {}; const { state } = editor?.view || {};
const { doc, selection } = state || {}; if (!state) return;
if (!doc || !selection) return; const { doc, selection } = state;
const pos = selection.from; const word = extractCurrentWord(doc, selection.from);
const currentChar = doc.textBetween(Math.max(0, pos - 1), pos);
if (/[\s\.,!?;:]/.test(currentChar)) {
suggestions = [];
currentWord = '';
return;
}
const textBefore = doc.textBetween(Math.max(0, pos - 100), pos);
const lastSpace = textBefore.search(/\s\w+$/);
const currentWordMatch =
lastSpace >= 0 ? textBefore.slice(lastSpace).match(/\w+$/) : textBefore.match(/\w+$/);
const word = currentWordMatch ? currentWordMatch[0] : '';
if (word && word !== currentWord) { if (word && word !== currentWord) {
currentWord = word; currentWord = word;
debouncedFetchSuggestions(currentWord); suggestions = await datamuseApi.getSuggestions(word);
selectedIndex = -1;
} else if (!word) { } else if (!word) {
suggestions = []; suggestions = [];
currentWord = ''; currentWord = '';
} }
updateCursorPosition(); updateCursorPosition();
} }, 300);
async function handleMouseUp(event: MouseEvent | TouchEvent) { const handleSelectionChange = debounce(async () => {
handleSelectionChange(); const selection = editor?.view.state.selection;
} if (editor && selection && !selection.empty) {
const word = extractSelectedWord(editor.view.state.doc, selection.from, selection.to);
if (word) {
rhymes = await datamuseApi.getRhymes(word);
selectedIndex = -1;
updateCursorPosition();
}
}
}, 300);
async function handleMouseDown() { function clearSuggestions() {
suggestions = []; suggestions = [];
currentWord = '';
rhymes = []; rhymes = [];
currentWord = '';
selectedIndex = -1;
} }
function applySuggestion(suggestion: string) { function applySuggestion(word: string) {
if (!editor?.view) return; if (!editor?.view) return;
const view = editor.view; const view = editor.view;
@ -134,8 +80,7 @@
view.focus(); view.focus();
if (rhymes.length > 0 && !selection.empty) { if (rhymes.length > 0 && !selection.empty) {
const newTr = tr.replaceSelectionWith(schema.text(suggestion)); view.dispatch(tr.replaceSelectionWith(schema.text(word)));
view.dispatch(newTr);
rhymes = []; rhymes = [];
} else { } else {
const pos = selection.from; const pos = selection.from;
@ -144,28 +89,49 @@
if (match) { if (match) {
const wordStart = pos - match[0].length; const wordStart = pos - match[0].length;
const newTr = tr.replaceWith(wordStart, pos, schema.text(suggestion)); view.dispatch(tr.replaceWith(wordStart, pos, schema.text(word)));
view.dispatch(newTr);
} }
} }
suggestions = []; clearSuggestions();
currentWord = ''; view.focus();
}
setTimeout(() => { function handleKeydown(event: KeyboardEvent) {
view.focus(); const activeSuggestions = suggestions.length ? suggestions : rhymes;
}, 0); if (!activeSuggestions.length) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, activeSuggestions.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (selectedIndex >= 0) {
applySuggestion(activeSuggestions[selectedIndex].word);
}
break;
case 'Escape':
event.preventDefault();
clearSuggestions();
break;
}
} }
</script> </script>
<svelte:window on:selectionchange={handleSelectionChange} /> <svelte:window onselectionchange={handleSelectionChange} onkeydown={handleKeydown} />
<div <div
class="container mx-auto my-8 dark relative px-4" class="container mx-auto my-8 dark relative px-4"
onmouseup={handleMouseUp} onmouseup={handleSelectionChange}
ontouchend={handleMouseUp} ontouchend={handleSelectionChange}
onmousedown={handleMouseDown} onmousedown={clearSuggestions}
ontouchstart={handleMouseDown} ontouchstart={clearSuggestions}
role="textbox" role="textbox"
aria-label="Text editor container" aria-label="Text editor container"
tabindex="0" tabindex="0"
@ -180,40 +146,26 @@
<div <div
class="suggestions-container" class="suggestions-container"
role="listbox" role="listbox"
aria-label="Suggestions and rhymes"
style:left="{cursorPosition.x}px" style:left="{cursorPosition.x}px"
style:top="{cursorPosition.y}px" style:top="{cursorPosition.y}px"
> >
{#if suggestions.length > 0} {#if suggestions.length > 0}
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-md p-2 mb-2"> <SuggestionList
<h3 class="text-sm font-bold mb-2 text-gray-700 dark:text-gray-300">Suggestions</h3> title="Suggestions"
{#each suggestions.slice(0, 10) as { word }} {suggestions}
<button {selectedIndex}
type="button" onSelect={applySuggestion}
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" />
onmousedown={() => applySuggestion(word)}
role="option"
aria-selected="false"
>
{word}
</button>
{/each}
</div>
{/if} {/if}
{#if rhymes.length > 0} {#if rhymes.length > 0}
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-md p-2"> <SuggestionList
<h3 class="text-sm font-bold mb-2 text-gray-700 dark:text-gray-300">Rhymes</h3> title="Rhymes"
{#each rhymes.slice(0, 10) as { word }} suggestions={rhymes}
<button {selectedIndex}
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" onSelect={applySuggestion}
onmousedown={() => applySuggestion(word)} />
role="option"
aria-selected="false"
>
{word}
</button>
{/each}
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -221,15 +173,16 @@
<style> <style>
.suggestions-container { .suggestions-container {
position: absolute; position: fixed;
z-index: 1000; z-index: 1000;
max-width: min(300px, 90vw); max-width: min(300px, 90vw);
max-height: 30vh; max-height: 300px;
overflow-y: auto; overflow-y: auto;
bottom: 1rem; transform: translateX(-50%);
overscroll-behavior: contain; overscroll-behavior: contain;
left: var(--suggestion-x, 50%); scroll-behavior: smooth;
top: var(--suggestion-y, -50%); box-shadow:
transform: translate(-50%, -50%); 0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
</style> </style>

View File

@ -0,0 +1,29 @@
<script lang="ts">
type Suggestion = {
word: string;
score: number;
};
let { title, suggestions, selectedIndex, onSelect } = $props<{
title: string;
suggestions: Suggestion[];
selectedIndex: number;
onSelect: (word: string) => void;
}>();
</script>
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-md p-2 mb-2">
<h3 class="text-sm font-bold mb-2 text-gray-700 dark:text-gray-300">{title}</h3>
{#each suggestions.slice(0, 10) as { word }, i}
<button
type="button"
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded
{selectedIndex === i ? 'bg-gray-100 dark:bg-gray-700' : ''}"
onmousedown={() => onSelect(word)}
role="option"
aria-selected={selectedIndex === i}
>
{word}
</button>
{/each}
</div>

View File

@ -0,0 +1,26 @@
export type Suggestion = {
word: string;
score: number;
};
export const datamuseApi = {
async getSuggestions(word: string): Promise<Suggestion[]> {
if (!word || word.length < 2) return [];
return fetch(`https://api.datamuse.com/sug?s=${encodeURIComponent(word)}`)
.then((res) => (res.ok ? res.json() : []))
.catch((error) => {
console.error('Error fetching suggestions:', error);
return [];
});
},
async getRhymes(word: string): Promise<Suggestion[]> {
if (!word) return [];
return fetch(`https://api.datamuse.com/words?rel_rhy=${encodeURIComponent(word)}`)
.then((res) => (res.ok ? res.json() : []))
.catch((error) => {
console.error('Error fetching rhymes:', error);
return [];
});
}
};

18
src/lib/utils/cursor.ts Normal file
View File

@ -0,0 +1,18 @@
import type { TipexEditor } from '@friendofsvelte/tipex';
export function calculateCursorPosition(editor: TipexEditor | undefined) {
const selection = window.getSelection();
if (!selection?.rangeCount) return { x: 0, y: 0 };
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const editorRect = editor?.view.dom.getBoundingClientRect() || { left: 0, top: 0 };
const x = Math.min(
Math.max(rect.left - editorRect.left + window.scrollX, 100),
window.innerWidth - 300
);
const y = rect.bottom - editorRect.top + window.scrollY + 10;
return { x, y };
}

14
src/lib/utils/debounce.ts Normal file
View File

@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export function createDebounce() {
let timer: NodeJS.Timeout | null = null;
return function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
}

24
src/lib/utils/text.ts Normal file
View File

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Extracts the last word (only alphabetical characters) from a given text
*/
function extractLastWord(text: string): string {
const matches = text.match(/[a-zA-Z]+\s*$/);
return matches ? matches[0].trim() : '';
}
/**
* Extracts the current word at cursor position from the document
*/
export function extractCurrentWord(doc: any, pos: number): string {
const textBefore = doc.textBetween(Math.max(0, pos - 100), pos);
return extractLastWord(textBefore);
}
/**
* Extracts the last word from a selected text range
*/
export function extractSelectedWord(doc: any, from: number, to: number): string {
const selectedText = doc.textBetween(from, to, ' ');
return extractLastWord(selectedText);
}