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/CodeBlock.css';
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 body = $state(localStorage.getItem('tipex') || initialContent);
let editor = $state<TipexEditor>();
let suggestions = $state<Array<{ word: string; score: number }>>([]);
let rhymes = $state<Array<{ word: string; score: number }>>([]);
let body = $state(initialContent || localStorage.getItem('tipex') || '');
let suggestions = $state<Suggestion[]>([]);
let rhymes = $state<Suggestion[]>([]);
let currentWord = $state('');
let selectedIndex = $state(-1);
let cursorPosition = $state({ x: 0, y: 0 });
let debounceTimer = $state<NodeJS.Timeout>();
const debounce = createDebounce();
type Suggestion = {
word: string;
score: number;
};
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();
$effect(() => {
if (editor?.getHTML()) {
localStorage.setItem('tipex', editor.getHTML());
}
};
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() {
const selection = window.getSelection();
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
};
cursorPosition = calculateCursorPosition(editor);
}
async function handleUpdate() {
const currentHtml = editor?.getHTML();
localStorage.setItem('tipex', currentHtml || '');
const handleUpdate = debounce(async () => {
const { state } = editor?.view || {};
const { doc, selection } = state || {};
if (!state) return;
if (!doc || !selection) return;
const pos = 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] : '';
const { doc, selection } = state;
const word = extractCurrentWord(doc, selection.from);
if (word && word !== currentWord) {
currentWord = word;
debouncedFetchSuggestions(currentWord);
suggestions = await datamuseApi.getSuggestions(word);
selectedIndex = -1;
} else if (!word) {
suggestions = [];
currentWord = '';
}
updateCursorPosition();
}
}, 300);
async function handleMouseUp(event: MouseEvent | TouchEvent) {
handleSelectionChange();
}
const handleSelectionChange = debounce(async () => {
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 = [];
currentWord = '';
rhymes = [];
currentWord = '';
selectedIndex = -1;
}
function applySuggestion(suggestion: string) {
function applySuggestion(word: string) {
if (!editor?.view) return;
const view = editor.view;
@ -134,8 +80,7 @@
view.focus();
if (rhymes.length > 0 && !selection.empty) {
const newTr = tr.replaceSelectionWith(schema.text(suggestion));
view.dispatch(newTr);
view.dispatch(tr.replaceSelectionWith(schema.text(word)));
rhymes = [];
} else {
const pos = selection.from;
@ -144,28 +89,49 @@
if (match) {
const wordStart = pos - match[0].length;
const newTr = tr.replaceWith(wordStart, pos, schema.text(suggestion));
view.dispatch(newTr);
view.dispatch(tr.replaceWith(wordStart, pos, schema.text(word)));
}
}
suggestions = [];
currentWord = '';
clearSuggestions();
view.focus();
}
setTimeout(() => {
view.focus();
}, 0);
function handleKeydown(event: KeyboardEvent) {
const activeSuggestions = suggestions.length ? suggestions : rhymes;
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>
<svelte:window on:selectionchange={handleSelectionChange} />
<svelte:window onselectionchange={handleSelectionChange} onkeydown={handleKeydown} />
<div
class="container mx-auto my-8 dark relative px-4"
onmouseup={handleMouseUp}
ontouchend={handleMouseUp}
onmousedown={handleMouseDown}
ontouchstart={handleMouseDown}
onmouseup={handleSelectionChange}
ontouchend={handleSelectionChange}
onmousedown={clearSuggestions}
ontouchstart={clearSuggestions}
role="textbox"
aria-label="Text editor container"
tabindex="0"
@ -180,40 +146,26 @@
<div
class="suggestions-container"
role="listbox"
aria-label="Suggestions and rhymes"
style:left="{cursorPosition.x}px"
style:top="{cursorPosition.y}px"
>
{#if suggestions.length > 0}
<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">Suggestions</h3>
{#each suggestions.slice(0, 10) as { word }}
<button
type="button"
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>
<SuggestionList
title="Suggestions"
{suggestions}
{selectedIndex}
onSelect={applySuggestion}
/>
{/if}
{#if rhymes.length > 0}
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-md p-2">
<h3 class="text-sm font-bold mb-2 text-gray-700 dark:text-gray-300">Rhymes</h3>
{#each rhymes.slice(0, 10) as { word }}
<button
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>
<SuggestionList
title="Rhymes"
suggestions={rhymes}
{selectedIndex}
onSelect={applySuggestion}
/>
{/if}
</div>
{/if}
@ -221,15 +173,16 @@
<style>
.suggestions-container {
position: absolute;
position: fixed;
z-index: 1000;
max-width: min(300px, 90vw);
max-height: 30vh;
max-height: 300px;
overflow-y: auto;
bottom: 1rem;
transform: translateX(-50%);
overscroll-behavior: contain;
left: var(--suggestion-x, 50%);
top: var(--suggestion-y, -50%);
transform: translate(-50%, -50%);
scroll-behavior: smooth;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
}
</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);
}