bunch of fixes and refactoring for suggestions/rhymes in the pad editor
This commit is contained in:
parent
3e2e1b0bcb
commit
637ec3657b
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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 [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -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 };
|
||||||
|
}
|
|
@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
Loading…
Reference in New Issue