mirror of
https://github.com/kunkunsh/kunkun.git
synced 2025-04-13 02:04:34 +00:00

* feat: add paste API to extension API * feat(desktop): enhance clipboard and hotkey utilities - Add `hideAndPaste` utility function to simplify window hiding and clipboard pasting - Adjust key press and sleep timings for more reliable input simulation - Implement window focus listener in clipboard extension - Bind input element reference for automatic focus management * feat(permissions): enhance clipboard permission handling - Update clipboard permission schema to include paste permission - Modify clipboard API to check for paste permission before executing - Refactor permission map and schema for more flexible permission management * feat(desktop): refactor extension command search and rendering - Add `svelte-inspect-value` for debugging - Implement new `ExtCmds` component to replace `ExtCmdsGroup` - Enhance extension command search with separate Fuse.js instances for installed and dev extensions - Simplify extension command filtering and rendering logic - Add derived stores for extension commands with improved type safety * feat(desktop): improve extension command search filtering * bump @kksh/api version * feat(permissions): add clipboard paste permission description
276 lines
7.7 KiB
Svelte
276 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { hideAndPaste } from "@/utils/hotkey"
|
|
import { goHome } from "@/utils/route"
|
|
import { listenToNewClipboardItem, listenToWindowFocus } from "@/utils/tauri-events"
|
|
import Icon from "@iconify/svelte"
|
|
import { ClipboardContentType, db } from "@kksh/api/commands"
|
|
import { SearchModeEnum, SQLSortOrderEnum, type ExtData } from "@kksh/api/models"
|
|
import { Button, Command, Resizable } from "@kksh/svelte5"
|
|
import { Constants } from "@kksh/ui"
|
|
import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main"
|
|
import type { UnlistenFn } from "@tauri-apps/api/event"
|
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
|
|
import { platform } from "@tauri-apps/plugin-os"
|
|
import { ArrowLeft, FileQuestionIcon, ImageIcon, LetterTextIcon } from "lucide-svelte"
|
|
import { onDestroy, onMount, type Snippet } from "svelte"
|
|
import { toast } from "svelte-sonner"
|
|
import clipboard from "tauri-plugin-clipboard-api"
|
|
import ContentPreview from "./content-preview.svelte"
|
|
|
|
const _platform = platform()
|
|
let inputEle = $state<HTMLInputElement | null>(null)
|
|
const curWin = getCurrentWebviewWindow()
|
|
let searchTerm = $state("")
|
|
let clipboardHistoryList = $state<ExtData[]>([])
|
|
let highlightedItemValue = $state<string>("")
|
|
let highlighted = $state<ExtData | null>(null)
|
|
let unlistenClipboard = $state<UnlistenFn | null>(null)
|
|
let unlistenFocusEvt = $state<UnlistenFn | null>(null)
|
|
let isScrolling = $state(false)
|
|
let page = $state(1)
|
|
|
|
let clipboardHistoryMap = $derived(
|
|
clipboardHistoryList.reduce(
|
|
(acc, item) => {
|
|
acc[item.dataId] = item
|
|
return acc
|
|
},
|
|
{} as Record<string, ExtData>
|
|
)
|
|
)
|
|
|
|
let clipboardHistoryIds = $derived(clipboardHistoryList.map((item) => item.dataId))
|
|
let clipboardHistoryIdsSet = $derived(new Set(clipboardHistoryIds))
|
|
|
|
async function initClipboardHistory() {
|
|
const result = await db.searchExtensionData({
|
|
extId: 1,
|
|
searchMode: SearchModeEnum.FTS,
|
|
limit: 50,
|
|
offset: (page - 1) * 50,
|
|
fields: ["search_text"],
|
|
orderByCreatedAt: SQLSortOrderEnum.Desc
|
|
})
|
|
if (page === 1) {
|
|
// clear clipboardHistoryList when page is 1, because it's simply loading the first page, using previous search result will result in duplicate key error
|
|
clipboardHistoryList = result
|
|
} else {
|
|
clipboardHistoryList = [...result, ...clipboardHistoryList]
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
listenToNewClipboardItem(async (evt) => {
|
|
const result = await db.searchExtensionData({
|
|
extId: 1,
|
|
searchMode: SearchModeEnum.FTS,
|
|
limit: 1,
|
|
fields: ["search_text"],
|
|
orderByCreatedAt: SQLSortOrderEnum.Desc
|
|
})
|
|
if (result.length > 0) {
|
|
clipboardHistoryList = [result[0], ...clipboardHistoryList]
|
|
}
|
|
}).then((unlisten) => {
|
|
unlistenClipboard = unlisten
|
|
})
|
|
|
|
listenToWindowFocus(async () => {
|
|
if (inputEle) {
|
|
inputEle.focus()
|
|
}
|
|
}).then((unlisten) => {
|
|
unlistenFocusEvt = unlisten
|
|
})
|
|
})
|
|
|
|
onDestroy(() => {
|
|
unlistenClipboard?.()
|
|
unlistenFocusEvt?.()
|
|
})
|
|
|
|
$effect(() => {
|
|
// search sqlite when searchTerm changes
|
|
void searchTerm
|
|
;(async () => {
|
|
// console.log("searchTerm", searchTerm)
|
|
if (searchTerm === "") {
|
|
page = 1
|
|
initClipboardHistory()
|
|
return
|
|
}
|
|
const ftsResult = await db.searchExtensionData({
|
|
extId: 1,
|
|
searchMode: SearchModeEnum.FTS,
|
|
searchText: `${searchTerm}*`,
|
|
fields: ["search_text"],
|
|
orderByCreatedAt: SQLSortOrderEnum.Desc
|
|
})
|
|
const likeResult = await db.searchExtensionData({
|
|
extId: 1,
|
|
searchMode: SearchModeEnum.Like,
|
|
searchText: `%${searchTerm}%`,
|
|
fields: ["search_text"],
|
|
orderByCreatedAt: SQLSortOrderEnum.Desc
|
|
})
|
|
// merge ftsResult and likeResult, remove duplicate items
|
|
const result = [...ftsResult, ...likeResult]
|
|
// sort result by createdAt
|
|
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
// remove duplicate items from result list by dataId
|
|
const uniqueResult = result.filter(
|
|
(item, index, self) => index === self.findIndex((t) => t.dataId === item.dataId)
|
|
)
|
|
clipboardHistoryList = uniqueResult
|
|
if (uniqueResult.length > 0) {
|
|
highlightedItemValue = uniqueResult[0].dataId.toString()
|
|
}
|
|
})()
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!highlightedItemValue) {
|
|
return
|
|
}
|
|
try {
|
|
const dataId = parseInt(highlightedItemValue)
|
|
highlighted = clipboardHistoryMap[dataId]
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
})
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") {
|
|
const inputEle = event.target as HTMLInputElement
|
|
if (inputEle.value === "") {
|
|
goHome()
|
|
}
|
|
inputEle.value = ""
|
|
searchTerm = ""
|
|
}
|
|
}
|
|
|
|
async function onListScrolledToBottom() {
|
|
page++
|
|
await initClipboardHistory()
|
|
}
|
|
|
|
/**
|
|
* Handle scroll-to-bottom event
|
|
* @param e
|
|
*/
|
|
function onScroll(e: Event) {
|
|
const element = e.target as HTMLElement
|
|
if (!isScrolling && element?.scrollHeight - element?.scrollTop === element?.clientHeight) {
|
|
isScrolling = true
|
|
onListScrolledToBottom?.()
|
|
setTimeout(() => {
|
|
isScrolling = false
|
|
}, 500)
|
|
}
|
|
}
|
|
|
|
function writeToClipboard(data: ExtData) {
|
|
if (!data.data) {
|
|
toast.warning("No data found")
|
|
return Promise.reject(new Error("No data found"))
|
|
}
|
|
const dataType = data?.dataType as ClipboardContentType
|
|
switch (dataType) {
|
|
case "Text":
|
|
return clipboard.writeText(data.data)
|
|
case "Image":
|
|
return clipboard.writeImageBase64(data.data)
|
|
case "Html":
|
|
return clipboard.writeHtmlAndText(data.data, data.searchText ?? data.data)
|
|
case "Rtf":
|
|
return clipboard.writeRtf(data.data)
|
|
default:
|
|
return Promise.reject(new Error("Unsupported data type: " + dataType))
|
|
}
|
|
}
|
|
|
|
function onItemSelected(dataId: number) {
|
|
db.getExtensionDataById(dataId)
|
|
.then((data) => {
|
|
console.log("data", data)
|
|
if (!data) {
|
|
toast.warning("No data found")
|
|
return Promise.reject(new Error("No data found"))
|
|
}
|
|
return writeToClipboard(data).then(async () => {
|
|
return hideAndPaste(curWin)
|
|
})
|
|
})
|
|
.then(() => toast.success("Copied to clipboard"))
|
|
.catch((err) => {
|
|
console.error(err)
|
|
toast.error("Failed to fetch data from db", {
|
|
description: err.message
|
|
})
|
|
})
|
|
}
|
|
</script>
|
|
|
|
{#snippet leftSlot()}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onclick={goHome}
|
|
class={Constants.CLASSNAMES.BACK_BUTTON}
|
|
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
|
|
>
|
|
<ArrowLeft class="size-4" />
|
|
</Button>
|
|
{/snippet}
|
|
{#snippet typeIcon(type: string)}
|
|
{#if type === "Text"}
|
|
<LetterTextIcon />
|
|
{:else if type === "Html"}
|
|
<Icon icon="skill-icons:html" />
|
|
{:else if type === "Image"}
|
|
<ImageIcon />
|
|
{:else}
|
|
<FileQuestionIcon />
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<Command.Root
|
|
class="h-screen rounded-lg border shadow-md"
|
|
loop
|
|
bind:value={highlightedItemValue}
|
|
shouldFilter={false}
|
|
>
|
|
<CustomCommandInput
|
|
onkeydown={onKeyDown}
|
|
autofocus
|
|
placeholder="Type a command or search..."
|
|
leftSlot={leftSlot as Snippet}
|
|
bind:ref={inputEle}
|
|
bind:value={searchTerm}
|
|
/>
|
|
<Resizable.PaneGroup direction="horizontal" class="w-full rounded-lg">
|
|
<Resizable.Pane defaultSize={30} class="">
|
|
<Command.List class="h-full max-h-full grow" onscroll={onScroll}>
|
|
<Command.Empty>No results found.</Command.Empty>
|
|
{#each clipboardHistoryIds as dataId (dataId)}
|
|
<Command.Item value={dataId.toString()} onSelect={() => onItemSelected(dataId)}>
|
|
{@render typeIcon(clipboardHistoryMap[dataId].dataType)}
|
|
<span class="truncate">{clipboardHistoryMap[dataId].searchText}</span>
|
|
</Command.Item>
|
|
{/each}
|
|
</Command.List>
|
|
</Resizable.Pane>
|
|
<Resizable.Handle />
|
|
<Resizable.Pane defaultSize={50}>
|
|
{#if highlighted}
|
|
<ContentPreview {highlighted} />
|
|
{:else}
|
|
<div class="text-center">No content preview available</div>
|
|
{/if}
|
|
</Resizable.Pane>
|
|
</Resizable.PaneGroup>
|
|
<GlobalCommandPaletteFooter />
|
|
</Command.Root>
|