refac/enh: commands ui
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
85
src/lib/components/common/RichTextInput/MentionList.svelte
Normal file
85
src/lib/components/common/RichTextInput/MentionList.svelte
Normal 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>
|
||||
26
src/lib/components/common/RichTextInput/commands.ts
Normal file
26
src/lib/components/common/RichTextInput/commands.ts
Normal 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
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
69
src/lib/components/common/RichTextInput/suggestions.ts
Normal file
69
src/lib/components/common/RichTextInput/suggestions.ts
Normal 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 component’s 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user