split out pad page into its own editor component

This commit is contained in:
silentsilas 2025-02-07 22:57:12 -05:00
parent a214da2dfe
commit 5f8e355bdc
Signed by: silentsilas
GPG Key ID: 113DFB380F724A81
2 changed files with 232 additions and 224 deletions

View File

@ -0,0 +1,230 @@
<script lang="ts">
import { Tipex, type TipexEditor } from '@friendofsvelte/tipex';
import '@friendofsvelte/tipex/styles/Tipex.css';
import '@friendofsvelte/tipex/styles/ProseMirror.css';
import '@friendofsvelte/tipex/styles/Controls.css';
import '@friendofsvelte/tipex/styles/EditLink.css';
import '@friendofsvelte/tipex/styles/CodeBlock.css';
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 currentWord = $state('');
let cursorPosition = $state({ x: 0, y: 0 });
let debounceTimer = $state<NodeJS.Timeout>();
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();
}
};
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
};
}
async function handleUpdate() {
const currentHtml = editor?.getHTML();
localStorage.setItem('tipex', currentHtml || '');
const { state } = editor?.view || {};
const { doc, selection } = state || {};
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] : '';
if (word && word !== currentWord) {
currentWord = word;
debouncedFetchSuggestions(currentWord);
} else if (!word) {
suggestions = [];
currentWord = '';
}
updateCursorPosition();
}
async function handleMouseUp(event: MouseEvent | TouchEvent) {
handleSelectionChange();
}
async function handleMouseDown() {
suggestions = [];
currentWord = '';
rhymes = [];
}
function applySuggestion(suggestion: string) {
if (!editor?.view) return;
const view = editor.view;
const { state } = view;
const { schema, selection, tr } = state;
view.focus();
if (rhymes.length > 0 && !selection.empty) {
const newTr = tr.replaceSelectionWith(schema.text(suggestion));
view.dispatch(newTr);
rhymes = [];
} else {
const pos = selection.from;
const wordBefore = state.doc.textBetween(Math.max(0, pos - 100), pos);
const match = wordBefore.match(/\S+$/);
if (match) {
const wordStart = pos - match[0].length;
const newTr = tr.replaceWith(wordStart, pos, schema.text(suggestion));
view.dispatch(newTr);
}
}
suggestions = [];
currentWord = '';
setTimeout(() => {
view.focus();
}, 0);
}
</script>
<svelte:window on:selectionchange={handleSelectionChange} />
<div
class="container mx-auto my-8 dark relative px-4"
onmouseup={handleMouseUp}
ontouchend={handleMouseUp}
onmousedown={handleMouseDown}
ontouchstart={handleMouseDown}
role="textbox"
aria-label="Text editor container"
tabindex="0"
>
<Tipex {body} controls !focal class="h-[80vh]" bind:tipex={editor} onupdate={handleUpdate} />
{#if suggestions.length > 0 || rhymes.length > 0}
<div
class="suggestions-container"
role="listbox"
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>
{/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>
{/if}
</div>
{/if}
</div>
<style>
.suggestions-container {
position: absolute;
z-index: 1000;
max-width: min(300px, 90vw);
max-height: 30vh;
overflow-y: auto;
bottom: 1rem;
overscroll-behavior: contain;
left: var(--suggestion-x, 50%);
top: var(--suggestion-y, -50%);
transform: translate(-50%, -50%);
}
</style>

View File

@ -1,159 +1,7 @@
<script lang="ts">
import { Tipex, type TipexEditor } from '@friendofsvelte/tipex';
import '@friendofsvelte/tipex/styles/Tipex.css';
import '@friendofsvelte/tipex/styles/ProseMirror.css';
import '@friendofsvelte/tipex/styles/Controls.css';
import '@friendofsvelte/tipex/styles/EditLink.css';
import '@friendofsvelte/tipex/styles/CodeBlock.css';
import Editor from '$lib/components/pad/Editor.svelte';
const INITIAL_HTML_CONTENT = `<h1>Simple editor</h1><p>What's written here will be saved to your local storage. Highlight a word to see suggested rhymes.</p>`;
let body = $state(localStorage.getItem('tipex') || INITIAL_HTML_CONTENT);
let editor = $state<TipexEditor>();
let suggestions = $state<Array<{ word: string; score: number }>>([]);
let rhymes = $state<Array<{ word: string; score: number }>>([]);
let currentWord = $state('');
let cursorPosition = $state({ x: 0, y: 0 });
let debounceTimer = $state<NodeJS.Timeout>();
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();
}
};
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
};
}
async function handleUpdate() {
const currentHtml = editor?.getHTML();
localStorage.setItem('tipex', currentHtml || '');
const { state } = editor?.view || {};
const { doc, selection } = state || {};
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] : '';
if (word && word !== currentWord) {
currentWord = word;
debouncedFetchSuggestions(currentWord);
} else if (!word) {
suggestions = [];
currentWord = '';
}
updateCursorPosition();
}
async function handleMouseUp(event: MouseEvent | TouchEvent) {
handleSelectionChange();
}
async function handleMouseDown() {
suggestions = [];
currentWord = '';
rhymes = [];
}
function applySuggestion(suggestion: string) {
if (!editor?.view) return;
const view = editor.view;
const { state } = view;
const { schema, selection, tr } = state;
view.focus();
if (rhymes.length > 0 && !selection.empty) {
const newTr = tr.replaceSelectionWith(schema.text(suggestion));
view.dispatch(newTr);
rhymes = [];
} else {
const pos = selection.from;
const wordBefore = state.doc.textBetween(Math.max(0, pos - 100), pos);
const match = wordBefore.match(/\S+$/);
if (match) {
const wordStart = pos - match[0].length;
const newTr = tr.replaceWith(wordStart, pos, schema.text(suggestion));
view.dispatch(newTr);
}
}
suggestions = [];
currentWord = '';
setTimeout(() => {
view.focus();
}, 0);
}
</script>
<svelte:head>
@ -161,74 +9,4 @@
<meta name="description" content="A text editor with rhyming and word suggestions" />
</svelte:head>
<svelte:window on:selectionchange={handleSelectionChange} />
<div
class="container mx-auto my-8 dark relative px-4"
onmouseup={handleMouseUp}
ontouchend={handleMouseUp}
onmousedown={handleMouseDown}
ontouchstart={handleMouseDown}
role="textbox"
aria-label="Text editor container"
tabindex="0"
>
<Tipex {body} controls !focal class="h-[80vh]" bind:tipex={editor} onupdate={handleUpdate} />
{#if suggestions.length > 0 || rhymes.length > 0}
<div
class="suggestions-container"
role="listbox"
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>
{/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>
{/if}
</div>
{/if}
</div>
<style>
.suggestions-container {
position: absolute;
z-index: 1000;
max-width: min(300px, 90vw);
max-height: 30vh;
overflow-y: auto;
bottom: 1rem;
overscroll-behavior: contain;
left: var(--suggestion-x, 50%);
top: var(--suggestion-y, -50%);
transform: translate(-50%, -50%);
}
</style>
<Editor initialContent={INITIAL_HTML_CONTENT} />