refac/enh: commands ui

This commit is contained in:
Timothy Jaeryang Baek
2025-09-12 20:31:57 +04:00
parent d973db829f
commit 6b69c4da0f
19 changed files with 1052 additions and 847 deletions

View File

@@ -13,7 +13,8 @@
const dispatch = createEventDispatcher();
export let className = 'w-60';
export let colorClassName = 'bg-white dark:bg-gray-850 border border-gray-50 dark:border-white/5';
export let colorClassName =
'bg-white dark:bg-gray-850 border border-gray-50 dark:border-gray-800';
export let url: string | null = null;
export let dismissible = false;
@@ -28,8 +29,8 @@
export let type: string;
export let size: number;
import { deleteFileById } from '$lib/apis/files';
import DocumentPage from '../icons/DocumentPage.svelte';
import Database from '../icons/Database.svelte';
let showModal = false;
const decodeString = (str: string) => {
@@ -47,7 +48,7 @@
<button
class="relative group p-1.5 {className} flex items-center gap-1 {colorClassName} {small
? 'rounded-xl'
? 'rounded-xl p-2'
: 'rounded-2xl'} text-left"
type="button"
on:click={async () => {
@@ -91,6 +92,23 @@
<Spinner />
{/if}
</div>
{:else}
<div class="pl-1">
{#if !loading}
<Tooltip
content={type === 'collection' ? $i18n.t('Collection') : $i18n.t('Document')}
placement="top"
>
{#if type === 'collection'}
<Database />
{:else}
<DocumentPage />
{/if}
</Tooltip>
{:else}
<Spinner />
{/if}
</div>
{/if}
{#if !small}
@@ -120,7 +138,7 @@
</div>
{:else}
<Tooltip content={decodeString(name)} className="flex flex-col w-full" placement="top-start">
<div class="flex flex-col justify-center -space-y-0.5 px-2.5 w-full">
<div class="flex flex-col justify-center -space-y-0.5 px-1 w-full">
<div class=" dark:text-gray-100 text-sm flex justify-between items-center">
{#if loading}
<div class=" shrink-0 mr-2">
@@ -128,7 +146,11 @@
</div>
{/if}
<div class="font-medium line-clamp-1 flex-1">{decodeString(name)}</div>
<div class="text-gray-500 text-xs capitalize shrink-0">{formatFileSize(size)}</div>
{#if size}
<div class="text-gray-500 text-xs capitalize shrink-0">{formatFileSize(size)}</div>
{:else}
<div class="text-gray-500 text-xs capitalize shrink-0">{type}</div>
{/if}
</div>
</div>
</Tooltip>

View File

@@ -137,13 +137,13 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Mention from '@tiptap/extension-mention';
import { all, createLowlight } from 'lowlight';
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight';
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { duration } from 'dayjs';
import MentionList from './RichTextInput/MentionList.svelte';
import { getSuggestionRenderer } from './RichTextInput/suggestions.js';
export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {};
@@ -166,6 +166,8 @@
export let image = false;
export let fileHandler = false;
export let suggestions = null;
export let onFileDrop = (currentEditor, files, pos) => {
files.forEach((file) => {
const fileReader = new FileReader();
@@ -951,6 +953,7 @@
}
console.log(bubbleMenuElement, floatingMenuElement);
console.log(suggestions);
editor = new Editor({
element: element,
@@ -966,12 +969,14 @@
}),
Highlight,
Typography,
Mention.configure({
HTMLAttributes: {
class: 'mention'
}
}),
...(suggestions
? [
Mention.configure({
HTMLAttributes: { class: 'mention' },
suggestions: suggestions
})
]
: []),
TableKit.configure({
table: { resizable: true }
@@ -1143,12 +1148,13 @@
if (event.key === 'Enter') {
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
const { state } = view;
const { $from } = state.selection;
const lineStart = $from.before($from.depth);
const lineEnd = $from.after($from.depth);
const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim();
if (event.shiftKey && !isCtrlPressed) {
const { state } = view;
const { $from } = state.selection;
const lineStart = $from.before($from.depth);
const lineEnd = $from.after($from.depth);
const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim();
if (lineText.startsWith('```')) {
// Fix GitHub issue #16337: prevent backtick removal for lines starting with ```
return false; // Let ProseMirror handle the Enter key normally
@@ -1163,10 +1169,18 @@
const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']);
const isInHeading = isInside(['heading']);
console.log({ isInCodeBlock, isInList, isInHeading });
if (isInCodeBlock || isInList || isInHeading) {
// Let ProseMirror handle the normal Enter behavior
return false;
}
const suggestionsElement = document.getElementById('suggestions-container');
if (lineText.startsWith('#') && suggestionsElement) {
console.log('Letting heading suggestion handle Enter key');
return true;
}
}
}

View File

@@ -22,7 +22,7 @@
</script>
<div
class="flex gap-0.5 p-0.5 rounded-lg shadow-lg bg-white text-gray-800 dark:text-white dark:bg-gray-800 min-w-fit"
class="flex gap-0.5 p-0.5 rounded-xl shadow-lg bg-white text-gray-800 dark:text-white dark:bg-gray-850 min-w-fit border border-gray-100 dark:border-gray-800"
>
<Tooltip placement="top" content={$i18n.t('H1')}>
<button

View File

@@ -0,0 +1,85 @@
<script lang="ts">
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
let ITEMS = [
{ id: '1', label: 'alice' },
{ id: '2', label: 'alex' },
{ id: '3', label: 'bob' },
{ id: '4', label: 'charlie' },
{ id: '5', label: 'diana' },
{ id: '6', label: 'eve' },
{ id: '7', label: 'frank' },
{ id: '8', label: 'grace' },
{ id: '9', label: 'heidi' },
{ id: '10', label: 'ivan' },
{ id: '11', label: 'judy' },
{ id: '12', label: 'mallory' },
{ id: '13', label: 'oscar' },
{ id: '14', label: 'peggy' },
{ id: '15', label: 'trent' },
{ id: '16', label: 'victor' },
{ id: '17', label: 'walter' }
];
let items = ITEMS;
$: items = ITEMS.filter((u) => u.label.toLowerCase().includes(query.toLowerCase())).slice(0, 5);
const select = (index: number) => {
const item = items[index];
if (item) command(item);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + items.length - 1) % items.length;
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % items.length;
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
</script>
<div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 overflow-y-auto scrollbar-thin max-h-60 w-52"
id="suggestions-container"
>
{#if items.length === 0}
<div class=" p-4 text-gray-400">No results</div>
{:else}
{#each items as item, i}
<button
type="button"
on:click={() => select(i)}
class=" text-left w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition px-3 py-1 {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 font-medium'
: ''}"
>
@{item.label}
</button>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,26 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
export default Extension.create({
name: 'commands',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }) => {
props.command({ editor, range });
}
}
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
];
}
});

View File

@@ -0,0 +1,69 @@
import tippy from 'tippy.js';
export function getSuggestionRenderer(Component: any, ComponentProps = {}) {
return function suggestionRenderer() {
let component = null;
let container: HTMLDivElement | null = null;
let popup: TippyInstance | null = null;
return {
onStart: (props: any) => {
container = document.createElement('div');
container.className = 'suggestion-list-container';
document.body.appendChild(container);
// mount Svelte component
component = new Component({
target: container,
props: {
char: props?.text,
command: (item) => {
props.command({ id: item.id, label: item.label });
},
...ComponentProps
},
context: new Map<string, any>([['i18n', ComponentProps?.i18n]])
});
popup = tippy(document.body, {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: container, // ✅ real element, not Svelte internals
interactive: true,
trigger: 'manual',
theme: 'transparent',
placement: 'top-start',
offset: [-10, -2],
arrow: false
});
popup?.show();
},
onUpdate: (props: any) => {
if (!component) return;
component.$set({ query: props.query });
if (props.clientRect && popup) {
popup.setProps({ getReferenceClientRect: props.clientRect as any });
}
},
onKeyDown: (props: any) => {
// forward to the Svelte components handler
// (expose this from component as `export function onKeyDown(evt)`)
// @ts-ignore
return component?._onKeyDown?.(props.event) ?? false;
},
onExit: () => {
popup?.destroy();
popup = null;
component?.$destroy();
component = null;
if (container?.parentNode) container.parentNode.removeChild(container);
container = null;
}
};
};
}