diff --git a/src/lib/components/pad/Editor.svelte b/src/lib/components/pad/Editor.svelte index cf73454..e5e3312 100644 --- a/src/lib/components/pad/Editor.svelte +++ b/src/lib/components/pad/Editor.svelte @@ -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(); - let suggestions = $state>([]); - let rhymes = $state>([]); + let body = $state(initialContent || localStorage.getItem('tipex') || ''); + let suggestions = $state([]); + let rhymes = $state([]); let currentWord = $state(''); + let selectedIndex = $state(-1); let cursorPosition = $state({ x: 0, y: 0 }); - let debounceTimer = $state(); + const debounce = createDebounce(); - type Suggestion = { - word: string; - score: number; - }; - - function debounce any>( - fn: T, - delay: number - ): (...args: Parameters) => void { - return (...args: Parameters) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => fn(...args), delay); - }; - } - - const datamuseApi = { - async getSuggestions(word: string): Promise { - 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 { - 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; + } } - +
{#if suggestions.length > 0} -
-

Suggestions

- {#each suggestions.slice(0, 10) as { word }} - - {/each} -
+ {/if} {#if rhymes.length > 0} -
-

Rhymes

- {#each rhymes.slice(0, 10) as { word }} - - {/each} -
+ {/if}
{/if} @@ -221,15 +173,16 @@ diff --git a/src/lib/components/pad/SuggestionList.svelte b/src/lib/components/pad/SuggestionList.svelte new file mode 100644 index 0000000..f7484b4 --- /dev/null +++ b/src/lib/components/pad/SuggestionList.svelte @@ -0,0 +1,29 @@ + + +
+

{title}

+ {#each suggestions.slice(0, 10) as { word }, i} + + {/each} +
diff --git a/src/lib/services/datamuse.ts b/src/lib/services/datamuse.ts new file mode 100644 index 0000000..ab32180 --- /dev/null +++ b/src/lib/services/datamuse.ts @@ -0,0 +1,26 @@ +export type Suggestion = { + word: string; + score: number; +}; + +export const datamuseApi = { + async getSuggestions(word: string): Promise { + 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 { + 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 []; + }); + } +}; diff --git a/src/lib/utils/cursor.ts b/src/lib/utils/cursor.ts new file mode 100644 index 0000000..7226933 --- /dev/null +++ b/src/lib/utils/cursor.ts @@ -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 }; +} diff --git a/src/lib/utils/debounce.ts b/src/lib/utils/debounce.ts new file mode 100644 index 0000000..59b6602 --- /dev/null +++ b/src/lib/utils/debounce.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function createDebounce() { + let timer: NodeJS.Timeout | null = null; + + return function debounce any>( + fn: T, + delay: number + ): (...args: Parameters) => void { + return (...args: Parameters) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + }; +} diff --git a/src/lib/utils/text.ts b/src/lib/utils/text.ts new file mode 100644 index 0000000..1491fb7 --- /dev/null +++ b/src/lib/utils/text.ts @@ -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); +}