First implementation of Plimi
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
ignored
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
215
README.md
Normal file
215
README.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Plimi: Privacy-First Browser Toolbox
|
||||||
|
|
||||||
|
Plimi is a modern, modular, zero-backend utility suite that executes all tools directly inside your browser. No data ever leaves your device.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Privacy-First Design**: Completely offline-capable. No data is sent to external servers.
|
||||||
|
- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema.
|
||||||
|
- **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads.
|
||||||
|
- **High Performance**: Built with React, Vite, and Tailwind CSS v4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or pnpm
|
||||||
|
|
||||||
|
### Installation & Run Dev Server
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Unit Tests
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Build
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Create a Tool (Plugin)
|
||||||
|
|
||||||
|
Adding a new tool to Plimi is seamless thanks to its generic rendering architecture. By default, Plimi generates the tool's interface based on its options schema and input/output definitions.
|
||||||
|
|
||||||
|
### Step 1: Create the Plugin Definition
|
||||||
|
Create a new directory in `src/tools/` (e.g., `src/tools/my-tool/index.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { PlimiPlugin } from '../../core/plugins/plugin-types';
|
||||||
|
import { runMyTool } from './run';
|
||||||
|
|
||||||
|
export interface MyToolOptions {
|
||||||
|
uppercase: boolean;
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const myToolPlugin: PlimiPlugin<MyToolOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: 'my-tool',
|
||||||
|
name: 'My Custom Tool',
|
||||||
|
description: 'Does something awesome entirely in the browser.',
|
||||||
|
category: 'developer',
|
||||||
|
version: '1.0.0',
|
||||||
|
tags: ['custom', 'developer', 'text'],
|
||||||
|
|
||||||
|
// 1. Defining Inputs (see Input Options below)
|
||||||
|
input: {
|
||||||
|
type: 'text',
|
||||||
|
multiline: false, // Renders a single-line input field instead of a textarea
|
||||||
|
placeholder: 'Type something...',
|
||||||
|
example: 'Hello Plimi'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Defining Outputs (text, json, table, files)
|
||||||
|
output: { type: 'text' },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. User options generated in the right panel automatically
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
key: 'uppercase',
|
||||||
|
label: 'Uppercase Output',
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
key: 'prefix',
|
||||||
|
label: 'Output Prefix',
|
||||||
|
defaultValue: 'Result: ',
|
||||||
|
placeholder: 'e.g. Result: '
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: override or add user-facing examples.
|
||||||
|
// If omitted, Plimi automatically builds a "Try example" action from
|
||||||
|
// manifest.example for single-input tools or field.example for grouped inputs.
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
label: 'Try greeting',
|
||||||
|
input: { text: 'Hello Plimi' },
|
||||||
|
options: { uppercase: true, prefix: 'Result: ' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runMyTool,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Implement the Runner Logic
|
||||||
|
Create the runner file (e.g., `src/tools/my-tool/run.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getTextInput, type ToolInput } from '../../core/io/input-types';
|
||||||
|
import type { ToolResult } from '../../core/io/output-types';
|
||||||
|
import type { ToolContext } from '../../core/plugins/plugin-types';
|
||||||
|
import type { MyToolOptions } from './index';
|
||||||
|
|
||||||
|
export async function runMyTool(
|
||||||
|
input: ToolInput,
|
||||||
|
options: MyToolOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
// Use getTextInput helper for safe retrieval
|
||||||
|
const text = getTextInput(input);
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("No text provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress back to the UI progress bar (if needed)
|
||||||
|
context.reportProgress({ percentage: 50, message: "Processing text..." });
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
if (options.uppercase) {
|
||||||
|
result = result.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
value: `${options.prefix}${result}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Register the Plugin
|
||||||
|
Import your new plugin into `src/core/plugins/plugin-registry.ts` and append it to the `pluginRegistry` array:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { myToolPlugin } from "../../tools/my-tool";
|
||||||
|
|
||||||
|
export const pluginRegistry: PlimiPlugin<any>[] = [
|
||||||
|
// ... existing plugins
|
||||||
|
myToolPlugin,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
The application will automatically register the tool, list it in the directory, enable search, and construct its input and options UI!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Abstraction Details
|
||||||
|
|
||||||
|
### Input Schema Configuration
|
||||||
|
The input property in the manifest supports three structures:
|
||||||
|
1. **`type: "none"`**: No input field is rendered (useful for generators, e.g., Lorem Ipsum).
|
||||||
|
2. **Single Field**: Renders a single input field.
|
||||||
|
- `type: "text"`: Renders a text entry box.
|
||||||
|
- Specify `multiline: false` to make it a compact single-line input field (default is a textarea).
|
||||||
|
- Specify `rows: number` to customize multiline text area height.
|
||||||
|
- `type: "files"`: Renders a drag-and-drop file uploader area.
|
||||||
|
3. **`type: "group"`**: Renders a group of input fields stacked vertically. Each field requires a unique `key`.
|
||||||
|
- Plimi will automatically expose a shared **"Try example"** action when fields contain example data, allowing the user to populate the form with one click.
|
||||||
|
- Access group values inside the runner using `getTextInput(input, "field_key")`.
|
||||||
|
|
||||||
|
### Output Visualizers
|
||||||
|
Plimi automatically formats results based on the returned `ToolResult.type`:
|
||||||
|
- **`type: "text"`**: Renders a code block or plain text area with a global copy button.
|
||||||
|
- **`type: "files"`**: Renders a lists of downloadable files with size comparisons.
|
||||||
|
- **`type: "table"`**: Renders a tabular data format with column headers, row styles, and a **"Copy CSV"** button.
|
||||||
|
- **`type: "json"`**: Renders a flat grid of cards for each key.
|
||||||
|
- Return a key named `"primary"` in your JSON output to showcase it prominently in a large card at the top of the results panel.
|
||||||
|
- Each key in the grid renders with its own individual, hoverable **"Copy"** button for a premium experience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Writing Custom UIs
|
||||||
|
For complex interfaces (like Canvas-based image manipulation or interactive dropzones), set `customUi: true` under `capabilities` in the manifest and supply a React component to the `customUi` key:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { lazy } from "react";
|
||||||
|
const CustomUiComponent = lazy(() => import("./CustomUiComponent"));
|
||||||
|
|
||||||
|
export const myPlugin = {
|
||||||
|
// ...
|
||||||
|
capabilities: { customUi: true },
|
||||||
|
customUi: CustomUiComponent
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Your component will receive the plugin definition as `plugin`, the current `dark` theme boolean, and normalized `examples` as props. Custom UIs can import `ExampleActionButton` from `src/components/tool/ExampleActionButton` to render the same "Try example" action style used by generated tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
Every tool should be tested. Create a `run.test.ts` alongside your tool and run:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
See `src/tools/regex-tester/run.test.ts` for an example of testing both key-based inputs and fallback single-string values.
|
||||||
22
eslint.config.js
Normal file
22
eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(["dist"]),
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<title>Plimi</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "plimi-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"fabric": "^7.4.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.15.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"zxing-wasm": "^3.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.59.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
2979
pnpm-lock.yaml
generated
Normal file
2979
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
public/favicon.svg
Normal file
60
public/favicon.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<style>
|
||||||
|
/* Global classes */
|
||||||
|
.tool-outline { stroke: #1A1714; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.pouch-outline { stroke: #1A1714; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
.pouch-fill { fill: #5B8C3E; }
|
||||||
|
.pencil-shaft { fill: #EDE3D0; }
|
||||||
|
.pencil-wood { fill: #FBF6ED; }
|
||||||
|
.pencil-lead { fill: #5B8C3E; }
|
||||||
|
.ruler-fill { fill: #FFFBF2; }
|
||||||
|
.pen-fill { fill: #1A1714; }
|
||||||
|
|
||||||
|
.zipper-line { stroke: #FFFBF2; stroke-width: 1.2; stroke-dasharray: 1.2, 1.2; }
|
||||||
|
.zipper-pull { fill: #FFFBF2; stroke: #1A1714; stroke-width: 1.2; stroke-linejoin: round; }
|
||||||
|
.zipper-hole { fill: #1A1714; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tool-outline { stroke: #F2F2EF; }
|
||||||
|
.pouch-outline { stroke: #F2F2EF; }
|
||||||
|
|
||||||
|
.pouch-fill { fill: #A8FF60; }
|
||||||
|
.pencil-shaft { fill: #1E222D; }
|
||||||
|
.pencil-wood { fill: #161922; }
|
||||||
|
.pencil-lead { fill: #A8FF60; }
|
||||||
|
.ruler-fill { fill: #161922; }
|
||||||
|
.pen-fill { fill: #F2F2EF; }
|
||||||
|
|
||||||
|
.zipper-line { stroke: #0F1117; stroke-width: 1.2; stroke-dasharray: 1.2, 1.2; }
|
||||||
|
.zipper-pull { fill: #0F1117; stroke: #F2F2EF; stroke-width: 1.2; }
|
||||||
|
.zipper-hole { fill: #F2F2EF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Pencils, Pens, Rulers peeking from behind -->
|
||||||
|
<g id="tools">
|
||||||
|
<!-- Pencil (Left) -->
|
||||||
|
<rect class="pencil-shaft tool-outline" x="7" y="10" width="4" height="7" />
|
||||||
|
<polygon class="pencil-wood tool-outline" points="7,10 9,6 11,10" />
|
||||||
|
<polygon class="pencil-lead" points="8.2,8.4 9,6 9.8,8.4" />
|
||||||
|
|
||||||
|
<!-- Ruler (Center) -->
|
||||||
|
<rect class="ruler-fill tool-outline" x="14" y="5" width="4" height="12" />
|
||||||
|
<line class="tool-outline" x1="14.5" y1="8" x2="16.5" y2="8" stroke-width="1" />
|
||||||
|
<line class="tool-outline" x1="14.5" y1="11" x2="17.5" y2="11" stroke-width="1" />
|
||||||
|
<line class="tool-outline" x1="14.5" y1="14" x2="16.5" y2="14" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- Pen (Right) -->
|
||||||
|
<path class="pen-fill tool-outline" d="M 21,9 A 1.5,1.5 0 0,1 24,9 L 24,17 L 21,17 Z" />
|
||||||
|
<path class="tool-outline" d="M 24,11 H 25.5 V 14.5 H 24" fill="none" stroke-width="1.2" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pouch body in front -->
|
||||||
|
<g id="pouch">
|
||||||
|
<rect class="pouch-fill pouch-outline" x="4" y="15" width="24" height="12" rx="3.5" />
|
||||||
|
<line class="zipper-line" x1="4.5" y1="18.5" x2="27.5" y2="18.5" />
|
||||||
|
<rect class="zipper-pull" x="8" y="17" width="2.5" height="4.5" rx="0.8" />
|
||||||
|
<circle class="zipper-hole" cx="9.25" cy="19.25" r="0.6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/zxing_writer.wasm
Normal file
BIN
public/zxing_writer.wasm
Normal file
Binary file not shown.
184
src/App.css
Normal file
184
src/App.css
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/App.tsx
Normal file
122
src/App.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import reactLogo from "./assets/react.svg";
|
||||||
|
import viteLogo from "./assets/vite.svg";
|
||||||
|
import heroImg from "./assets/hero.png";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section id="center">
|
||||||
|
<div className="hero">
|
||||||
|
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||||
|
<img src={reactLogo} className="framework" alt="React logo" />
|
||||||
|
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>
|
||||||
|
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="counter"
|
||||||
|
onClick={() => setCount((count) => count + 1)}
|
||||||
|
>
|
||||||
|
Count is {count}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg className="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img className="logo" src={viteLogo} alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://react.dev/" target="_blank">
|
||||||
|
<img className="button-icon" src={reactLogo} alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg className="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg
|
||||||
|
className="button-icon"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg
|
||||||
|
className="button-icon"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg
|
||||||
|
className="button-icon"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg
|
||||||
|
className="button-icon"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
6
src/app/App.tsx
Normal file
6
src/app/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
8
src/app/ThemeContext.ts
Normal file
8
src/app/ThemeContext.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export type ThemeContextType = {
|
||||||
|
dark: boolean;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
25
src/app/ThemeProvider.tsx
Normal file
25
src/app/ThemeProvider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ThemeContext } from "./ThemeContext";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (dark) {
|
||||||
|
root.classList.add('plimi-theme-dark');
|
||||||
|
root.classList.remove('plimi-theme-light');
|
||||||
|
} else {
|
||||||
|
root.classList.add('plimi-theme-light');
|
||||||
|
root.classList.remove('plimi-theme-dark');
|
||||||
|
}
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
const toggleTheme = () => setDark(!dark);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ dark, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/router.tsx
Normal file
31
src/app/router.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import { AppShell } from "../components/layout/AppShell";
|
||||||
|
import { HomePage } from "../pages/HomePage";
|
||||||
|
import { ToolsPage } from "../pages/ToolsPage";
|
||||||
|
import { ToolDetailPage } from "../pages/ToolDetailPage";
|
||||||
|
import { HowItWorksPage } from "../pages/HowItWorksPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AppShell />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <HomePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "tools",
|
||||||
|
element: <ToolsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "tools/:toolId",
|
||||||
|
element: <ToolDetailPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "how-it-works",
|
||||||
|
element: <HowItWorksPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
10
src/app/useTheme.ts
Normal file
10
src/app/useTheme.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { ThemeContext } from "./ThemeContext";
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
263
src/components/directory/DirectoryComponents.tsx
Normal file
263
src/components/directory/DirectoryComponents.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import type { UnknownPlimiPlugin } from '../../core/plugins/plugin-types';
|
||||||
|
import { CategoryIcon } from '../ui/CategoryIcon';
|
||||||
|
|
||||||
|
export function PlimiSearch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
onArrow,
|
||||||
|
onEnter,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
onArrow?: (dir: number) => void;
|
||||||
|
onEnter?: () => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const shortcutText =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
/Mac|iPod|iPhone|iPad/.test(navigator.userAgent || navigator.platform || "")
|
||||||
|
? "⌘ K"
|
||||||
|
: "Ctrl K";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div
|
||||||
|
className="relative bg-[var(--p-surface)] border-[1.5px] border-[var(--p-border)] rounded-[20px] px-[22px] py-[20px] flex items-center gap-[14px] transition-colors cursor-text"
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 1px 0 0 var(--p-shadow-inset), 0 12px 30px -22px var(--p-shadow-soft)',
|
||||||
|
}}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--p-muted)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-3.5-3.5" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
onArrow?.(e.key === 'ArrowDown' ? 1 : -1);
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onEnter?.();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Start typing to find a tool…"
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 min-w-0 border-none outline-none bg-transparent text-[var(--p-text)] text-2xl font-sans tracking-tight font-medium"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 inline-flex items-center gap-1.5 font-mono text-xs text-[var(--p-muted)]">
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange('');
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 rounded-lg bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<kbd className="px-2 py-1.5 rounded-md bg-[var(--p-chip)] border border-[var(--p-border)] text-[var(--p-muted)]">
|
||||||
|
{shortcutText}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 flex justify-between font-mono text-[11px] text-[var(--p-muted)] tracking-wider uppercase">
|
||||||
|
<span>{value ? `${count} match${count === 1 ? '' : 'es'}` : `${total} tools`}</span>
|
||||||
|
<span>↑↓ to browse · enter to open</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryChips({
|
||||||
|
active,
|
||||||
|
onPick,
|
||||||
|
counts,
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
active: string;
|
||||||
|
onPick: (cat: string) => void;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
categories: { id: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{categories.map((c) => {
|
||||||
|
const on = active === c.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onPick(c.id)}
|
||||||
|
className={`cursor-pointer px-3.5 py-2 rounded-full font-sans text-[13px] font-medium tracking-tight inline-flex items-center gap-2 transition-all ${
|
||||||
|
on
|
||||||
|
? 'bg-[var(--p-accent)] text-[var(--p-accent-ink)] border-[var(--p-accent)]'
|
||||||
|
: 'bg-[var(--p-surface)] text-[var(--p-text)] border-[var(--p-border)]'
|
||||||
|
} border`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[11px] ${
|
||||||
|
on ? 'opacity-70' : 'opacity-55'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{counts[c.id] || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tilt(id: string, range = 1.5) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
|
||||||
|
const x = (Math.abs(h) % 1000) / 1000;
|
||||||
|
return (x - 0.5) * 2 * range;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TINTS: Record<string, { paper: string; ink: string; neon: string }> = {
|
||||||
|
image: { paper: '#E8F5E9', ink: '#2E7D32', neon: '#69F0AE' },
|
||||||
|
pdf: { paper: '#FFEBEE', ink: '#C62828', neon: '#FF5252' },
|
||||||
|
developer: { paper: '#E3F2FD', ink: '#1565C0', neon: '#448AFF' },
|
||||||
|
text: { paper: '#FFF8E1', ink: '#F57F17', neon: '#FFD740' },
|
||||||
|
crypto: { paper: '#F3E5F5', ink: '#6A1B9A', neon: '#E040FB' },
|
||||||
|
privacy: { paper: '#E0F2F1', ink: '#00695C', neon: '#64FFDA' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolTile({
|
||||||
|
plugin,
|
||||||
|
focused,
|
||||||
|
onClick,
|
||||||
|
dark,
|
||||||
|
}: {
|
||||||
|
plugin: UnknownPlimiPlugin;
|
||||||
|
focused: boolean;
|
||||||
|
onClick: (plugin: UnknownPlimiPlugin) => void;
|
||||||
|
dark: boolean;
|
||||||
|
}) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
const tints = TINTS[plugin.manifest.category] || TINTS.developer;
|
||||||
|
const t = tilt(plugin.manifest.id, 1.5);
|
||||||
|
const lifted = hover || focused;
|
||||||
|
|
||||||
|
const iconColor = dark ? tints.neon : tints.ink;
|
||||||
|
|
||||||
|
const bg = 'var(--p-surface)';
|
||||||
|
const stickerEdge = dark
|
||||||
|
? `color-mix(in oklab, ${tints.neon} 22%, var(--p-border))`
|
||||||
|
: `color-mix(in oklab, ${tints.ink} 18%, var(--p-border))`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onClick(plugin)}
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
className="relative flex flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: bg,
|
||||||
|
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
|
||||||
|
boxShadow: lifted
|
||||||
|
? '0 18px 38px -22px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)'
|
||||||
|
: '0 6px 18px -16px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)',
|
||||||
|
transform: `rotate(${lifted ? 0 : t}deg) translateY(${lifted ? -2 : 0}px)`,
|
||||||
|
transition: 'transform .18s cubic-bezier(.2,.8,.2,1), box-shadow .18s, border-color .18s',
|
||||||
|
minHeight: 158,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: 18,
|
||||||
|
width: 26,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '0 0 6px 6px',
|
||||||
|
background: dark ? tints.neon : tints.paper,
|
||||||
|
opacity: dark ? 0.5 : 1,
|
||||||
|
transform: `rotate(${t * 1.5}deg)`,
|
||||||
|
boxShadow: '0 2px 6px -2px var(--p-shadow-soft)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-[52px] h-[52px] rounded-[14px]"
|
||||||
|
style={{
|
||||||
|
background: dark
|
||||||
|
? `color-mix(in oklab, ${tints.neon} 8%, transparent)`
|
||||||
|
: tints.paper,
|
||||||
|
border: dark ? `1px solid color-mix(in oklab, ${tints.neon} 20%, transparent)` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon category={plugin.manifest.category} color={iconColor} size={24} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="font-sans font-semibold text-base tracking-tight text-[var(--p-text)]">
|
||||||
|
{plugin.manifest.name}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans font-normal text-[13px] text-[var(--p-muted)] leading-snug text-balance">
|
||||||
|
{plugin.manifest.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({ catLabel, catId, n }: { catLabel: string; catId: string; n: number }) {
|
||||||
|
const tints = TINTS[catId] || TINTS.developer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between pb-1.5">
|
||||||
|
<div className="inline-flex items-baseline gap-2.5 font-mono text-xs text-[var(--p-muted)] tracking-wider uppercase">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-sm transform translate-y-px -rotate-6"
|
||||||
|
style={{
|
||||||
|
background: tints.paper,
|
||||||
|
border: `1.5px solid color-mix(in oklab, ${tints.ink} 40%, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{catLabel}</span>
|
||||||
|
<span className="opacity-50">{String(n).padStart(2, '0')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/layout/AppShell.tsx
Normal file
13
src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full min-h-screen bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
|
||||||
|
<Header />
|
||||||
|
<main className="px-6 py-8 md:px-14 md:py-9">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/layout/Header.tsx
Normal file
124
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useTheme } from "../../app/useTheme";
|
||||||
|
|
||||||
|
function PlimiMark({ size = 28 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: "Geist, system-ui, sans-serif",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size,
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
color: "var(--p-text)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ position: "relative" }}>
|
||||||
|
plimi
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "74%",
|
||||||
|
top: "-0.12em",
|
||||||
|
width: "0.32em",
|
||||||
|
height: "0.32em",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--p-accent)",
|
||||||
|
transform: "rotate(-12deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function ThemeToggle({ dark, onClick }: { dark: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
className="flex items-center justify-center w-9 h-8 rounded-lg bg-[var(--p-surface)] border border-[var(--p-border)] text-[var(--p-text)] cursor-pointer"
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { dark, toggleTheme } = useTheme();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5 border-b border-[var(--p-border)] bg-[var(--p-bg)]">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Link to="/">
|
||||||
|
<PlimiMark size={26} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex gap-6">
|
||||||
|
{[
|
||||||
|
{ label: "Tools", to: "/tools" },
|
||||||
|
{ label: "How it works", to: "/how-it-works" },
|
||||||
|
{ label: "Privacy", to: "#" },
|
||||||
|
{ label: "Changelog", to: "#" },
|
||||||
|
].map((item) => {
|
||||||
|
const isActive = item.to !== "#" && (
|
||||||
|
item.to === "/tools"
|
||||||
|
? location.pathname.startsWith("/tools")
|
||||||
|
: location.pathname === item.to
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
to={item.to}
|
||||||
|
className={`font-sans text-[13px] no-underline transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-[var(--p-text)] font-semibold"
|
||||||
|
: "text-[var(--p-muted)] hover:text-[var(--p-text)] font-normal"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle dark={dark} onClick={toggleTheme} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/layout/Sidebar.tsx
Normal file
27
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
|
||||||
|
<nav className="p-4 flex flex-col space-y-1">
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
|
||||||
|
}
|
||||||
|
end
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/tools"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tools
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/tool/ExampleActionButton.tsx
Normal file
25
src/components/tool/ExampleActionButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Button } from "../ui/Button";
|
||||||
|
|
||||||
|
export function ExampleActionButton({
|
||||||
|
label = "Try example",
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/components/tool/ToolInputPanel.tsx
Normal file
246
src/components/tool/ToolInputPanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import {
|
||||||
|
getInputValue,
|
||||||
|
setInputValue,
|
||||||
|
type ToolInput,
|
||||||
|
type ToolInputDefinition,
|
||||||
|
type ToolInputFieldDefinition,
|
||||||
|
} from "../../core/io/input-types";
|
||||||
|
import { Dropzone } from "../ui/Dropzone";
|
||||||
|
|
||||||
|
interface ToolInputPanelTints {
|
||||||
|
paper: string;
|
||||||
|
ink: string;
|
||||||
|
neon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolInputPanel({
|
||||||
|
definition,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
dark,
|
||||||
|
tints,
|
||||||
|
}: {
|
||||||
|
definition: ToolInputDefinition;
|
||||||
|
value: ToolInput;
|
||||||
|
onChange: (val: ToolInput) => void;
|
||||||
|
dark?: boolean;
|
||||||
|
tints?: ToolInputPanelTints;
|
||||||
|
}) {
|
||||||
|
const isDark = dark ?? false;
|
||||||
|
|
||||||
|
const fields: ToolInputFieldDefinition[] =
|
||||||
|
definition.type === "group"
|
||||||
|
? definition.fields
|
||||||
|
: definition.type === "none"
|
||||||
|
? []
|
||||||
|
: [definition];
|
||||||
|
|
||||||
|
const handleTextChange = (fieldKey: string, newText: string) => {
|
||||||
|
const prev = getInputValue(value, fieldKey);
|
||||||
|
onChange(setInputValue(value, fieldKey, { ...prev, text: newText }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesChange = (fieldKey: string, files?: File[]) => {
|
||||||
|
const prev = getInputValue(value, fieldKey);
|
||||||
|
onChange(setInputValue(value, fieldKey, { ...prev, files }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (definition.type === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[160px] flex-col justify-center rounded-[14px] border border-[var(--p-border)] p-[24px]"
|
||||||
|
style={{
|
||||||
|
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-[8px] font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
no input required
|
||||||
|
</div>
|
||||||
|
<p className="m-0 font-sans text-[14px] leading-[1.6] text-[var(--p-muted)]">
|
||||||
|
This tool runs from its options only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderFileField = (
|
||||||
|
field: Extract<ToolInputFieldDefinition, { type: "files" }>,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
const fieldKey = field.key ?? "input";
|
||||||
|
const fieldValue = getInputValue(value, fieldKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${fieldKey}-${index}`}
|
||||||
|
className="flex min-h-[240px] flex-col rounded-[14px] border border-[var(--p-border)] p-[24px] transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-[16px] flex items-center justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
<span>{field.label ?? "input files"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{field.description && (
|
||||||
|
<p className="mb-[16px] mt-0 font-sans text-[13px] text-[var(--p-muted)]">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fieldValue.files && fieldValue.files.length > 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col gap-[12px]">
|
||||||
|
<ul className="m-0 flex max-h-[160px] list-none flex-col gap-[8px] overflow-y-auto p-0">
|
||||||
|
{fieldValue.files.map((file, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between rounded-[10px] border border-[var(--p-border)] bg-[var(--p-surface)] p-[12px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-[12px] overflow-hidden">
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--p-muted)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||||
|
<polyline points="13 2 13 9 20 9" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-[12px] whitespace-nowrap font-mono text-[10px] text-[var(--p-muted)]">
|
||||||
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-auto flex justify-end pt-[12px]">
|
||||||
|
<button
|
||||||
|
onClick={() => handleFilesChange(fieldKey, undefined)}
|
||||||
|
className="cursor-pointer rounded-lg bg-[var(--p-chip)] px-3 py-1.5 font-sans text-[12px] text-[var(--p-muted)] transition-colors hover:bg-[var(--p-border)]"
|
||||||
|
>
|
||||||
|
Clear files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 flex-col items-center justify-center rounded-[10px] border-2 border-dashed transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: isDark
|
||||||
|
? `color-mix(in oklab, ${tints?.neon || "var(--p-accent)"} 30%, var(--p-border))`
|
||||||
|
: `color-mix(in oklab, ${tints?.ink || "var(--p-accent)"} 25%, var(--p-border))`,
|
||||||
|
background: isDark
|
||||||
|
? `color-mix(in oklab, ${tints?.neon || "var(--p-accent)"} 4%, transparent)`
|
||||||
|
: `color-mix(in oklab, ${tints?.paper || "var(--p-bg)"} 40%, transparent)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="44"
|
||||||
|
height="44"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={isDark ? (tints?.neon || "currentColor") : (tints?.ink || "currentColor")}
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<Dropzone
|
||||||
|
accept={field.accept?.join(",")}
|
||||||
|
multiple={field.multiple}
|
||||||
|
maxFiles={field.maxFiles}
|
||||||
|
maxSizeMb={field.maxSizeMb}
|
||||||
|
onFilesDrop={(files) => handleFilesChange(fieldKey, files)}
|
||||||
|
className="w-full flex-1 border-none"
|
||||||
|
/>
|
||||||
|
<div className="mb-[24px] font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
processed in your browser
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTextField = (
|
||||||
|
field: Extract<ToolInputFieldDefinition, { type: "text" | "text-or-files" }>,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
const fieldKey = field.key ?? "input";
|
||||||
|
const fieldValue = getInputValue(value, fieldKey);
|
||||||
|
const placeholder =
|
||||||
|
field.type === "text"
|
||||||
|
? (field.placeholder ?? "Enter input here...")
|
||||||
|
: "Enter input here...";
|
||||||
|
|
||||||
|
const isMultiline =
|
||||||
|
field.type === "text-or-files" ||
|
||||||
|
(field.type === "text" && field.multiline !== false);
|
||||||
|
const rows = field.type === "text" ? field.rows : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${fieldKey}-${index}`}
|
||||||
|
className={`flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] ${
|
||||||
|
isMultiline ? "min-h-[160px]" : ""
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
<span>{field.label ?? "input"}</span>
|
||||||
|
<span>{isMultiline ? "paste / drop / type" : "type"}</span>
|
||||||
|
</div>
|
||||||
|
{field.description && (
|
||||||
|
<p className="m-0 font-sans text-[13px] text-[var(--p-muted)]">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isMultiline ? (
|
||||||
|
<textarea
|
||||||
|
className="w-full flex-1 resize-none border-none bg-transparent font-mono text-[13px] text-[var(--p-text)] outline-none placeholder:text-[var(--p-muted)] placeholder:opacity-50"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={fieldValue.text || ""}
|
||||||
|
maxLength={field.type === "text" ? field.maxLength : undefined}
|
||||||
|
onChange={(e) => handleTextChange(fieldKey, e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
rows={rows ?? 6}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full border-none bg-transparent py-1 font-mono text-[13px] text-[var(--p-text)] outline-none placeholder:text-[var(--p-muted)] placeholder:opacity-50"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={fieldValue.text || ""}
|
||||||
|
maxLength={field.type === "text" ? field.maxLength : undefined}
|
||||||
|
onChange={(e) => handleTextChange(fieldKey, e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[16px]">
|
||||||
|
{fields.map((field, index) =>
|
||||||
|
field.type === "files"
|
||||||
|
? renderFileField(field, index)
|
||||||
|
: renderTextField(field, index)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/tool/ToolOptionsPanel.tsx
Normal file
117
src/components/tool/ToolOptionsPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ToolOptionsSchema, ToolOptionValue, ToolOptionsValue } from "../../core/plugins/plugin-types";
|
||||||
|
import { Input } from "../ui/Input";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { Slider } from "../ui/Slider";
|
||||||
|
|
||||||
|
interface ToolOptionsPanelTints {
|
||||||
|
paper: string;
|
||||||
|
ink: string;
|
||||||
|
neon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolOptionsPanel({
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
dark,
|
||||||
|
tints,
|
||||||
|
toolCategory
|
||||||
|
}: {
|
||||||
|
schema: ToolOptionsSchema;
|
||||||
|
value: ToolOptionsValue;
|
||||||
|
onChange: (val: ToolOptionsValue) => void;
|
||||||
|
dark?: boolean;
|
||||||
|
tints?: ToolOptionsPanelTints;
|
||||||
|
toolCategory?: string;
|
||||||
|
}) {
|
||||||
|
const isDark = dark ?? false;
|
||||||
|
|
||||||
|
const handleFieldChange = (key: string, val: ToolOptionValue) => {
|
||||||
|
onChange({ ...value, [key]: val });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWasm = toolCategory === 'image' || toolCategory === 'pdf';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[14px] rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-[16px] h-full min-h-[240px]">
|
||||||
|
<div className="font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
|
||||||
|
options
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[14px] flex-1 overflow-y-auto pr-1">
|
||||||
|
{schema.fields.map((field) => {
|
||||||
|
const currentVal = value[field.key] ?? field.defaultValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex flex-col gap-1.5">
|
||||||
|
<span className="font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{field.type === "text" && (
|
||||||
|
<Input
|
||||||
|
value={String(currentVal ?? "")}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "number" && (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
value={Number(currentVal)}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "boolean" && (
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-[var(--p-border)] bg-[var(--p-surface)] px-3 py-2 text-[13px] text-[var(--p-text)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={currentVal === true}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
||||||
|
className="h-4 w-4 accent-[var(--p-accent)]"
|
||||||
|
/>
|
||||||
|
<span>{currentVal === true ? "Enabled" : "Disabled"}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "select" && field.options && (
|
||||||
|
<Select
|
||||||
|
value={String(currentVal)}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
options={field.options}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "slider" && (
|
||||||
|
<Slider
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
value={Number(currentVal)}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-[12px] border-t border-dashed border-[var(--p-border)] flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] text-[var(--p-muted)]">
|
||||||
|
engine
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: isDark ? tints?.neon : tints?.ink }}
|
||||||
|
>
|
||||||
|
{isWasm ? 'wasm' : 'js worker'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/tool/ToolProgress.tsx
Normal file
54
src/components/tool/ToolProgress.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
interface ToolProgressTints {
|
||||||
|
ink: string;
|
||||||
|
neon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolProgress({
|
||||||
|
percentage,
|
||||||
|
message,
|
||||||
|
onCancel,
|
||||||
|
dark,
|
||||||
|
tints
|
||||||
|
}: {
|
||||||
|
percentage: number;
|
||||||
|
message?: string;
|
||||||
|
onCancel?: () => void;
|
||||||
|
dark?: boolean;
|
||||||
|
tints?: ToolProgressTints;
|
||||||
|
}) {
|
||||||
|
const isDark = dark ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-4 rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface-2)]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-sans text-[13px] text-[var(--p-text)] font-medium">
|
||||||
|
{message || "Processing..."}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-[var(--p-muted)]">
|
||||||
|
{Math.round(percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-1.5 w-full bg-[var(--p-border)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-300 ease-out"
|
||||||
|
style={{
|
||||||
|
width: `${percentage}%`,
|
||||||
|
background: isDark ? (tints?.neon || 'var(--p-accent)') : (tints?.ink || 'var(--p-accent)')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onCancel && (
|
||||||
|
<div className="flex justify-end mt-1">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--p-chip)] text-[var(--p-muted)] font-sans text-xs hover:text-[var(--p-text)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
src/components/tool/ToolResultPanel.tsx
Normal file
295
src/components/tool/ToolResultPanel.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
|
||||||
|
interface ToolResultPanelTints {
|
||||||
|
neon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolResultPanel({
|
||||||
|
result,
|
||||||
|
dark,
|
||||||
|
tints,
|
||||||
|
}: {
|
||||||
|
result: ToolResult;
|
||||||
|
dark?: boolean;
|
||||||
|
tints?: ToolResultPanelTints;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const isDark = dark ?? false;
|
||||||
|
|
||||||
|
const handleCopy = useCallback((text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const downloadBlob = useCallback((blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (result.type === "text") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] min-h-[240px]"
|
||||||
|
style={{
|
||||||
|
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
|
||||||
|
<span>output</span>
|
||||||
|
{result.value && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(result.value)}
|
||||||
|
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto font-mono text-[13px] text-[var(--p-text)] whitespace-pre-wrap break-words">
|
||||||
|
{result.value || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.type === "json") {
|
||||||
|
const value = result.value;
|
||||||
|
const isObject = typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
const valObj = isObject ? (value as Record<string, unknown>) : null;
|
||||||
|
const primaryVal = valObj && "primary" in valObj ? String(valObj.primary) : null;
|
||||||
|
|
||||||
|
// Filter out 'primary' from the fields displayed in the grid
|
||||||
|
const fields = valObj
|
||||||
|
? Object.entries(valObj).filter(([key]) => key !== "primary")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[16px]">
|
||||||
|
{primaryVal !== null && (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-[8px] p-[20px] rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] relative group/primary overflow-hidden"
|
||||||
|
style={{
|
||||||
|
boxShadow: 'inset 0 1px 0 var(--p-shadow-inset)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-[10px] text-[var(--p-accent)] tracking-wider uppercase font-semibold">
|
||||||
|
Primary Result
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-[16px]">
|
||||||
|
<div className="font-mono text-[20px] font-bold text-[var(--p-text)] break-all select-all">
|
||||||
|
{primaryVal}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(primaryVal)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-sans text-[12px] font-semibold cursor-pointer hover:opacity-90 transition-opacity whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] min-h-[160px]"
|
||||||
|
style={{
|
||||||
|
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase mb-2">
|
||||||
|
<span>{primaryVal !== null ? "All Conversions / Details" : "output"}</span>
|
||||||
|
{!primaryVal && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(JSON.stringify(value, null, 2))}
|
||||||
|
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto font-mono text-[13px] text-[var(--p-text)] whitespace-pre-wrap break-words">
|
||||||
|
{isObject ? (
|
||||||
|
<div className="grid grid-cols-1 gap-[10px]">
|
||||||
|
{fields.map(([key, val]) => {
|
||||||
|
const valStr = typeof val === "object" && val !== null ? JSON.stringify(val) : String(val ?? "null");
|
||||||
|
const isValObject = typeof val === "object" && val !== null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-[8px] p-[12px] rounded-[10px] bg-[var(--p-surface)] border border-[var(--p-border)] group/field hover:border-[var(--p-muted)]/30 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[2px] flex-grow min-w-0">
|
||||||
|
<span className="text-[var(--p-muted)] text-[9px] uppercase tracking-wider font-bold">{key}</span>
|
||||||
|
{isValObject ? (
|
||||||
|
<div className="pl-2 border-l border-[var(--p-border)] mt-1">
|
||||||
|
{renderJsonValue(val)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--p-text)] text-[13px] font-mono break-all">{valStr}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isValObject && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(valStr)}
|
||||||
|
className="sm:opacity-0 group-hover/field:opacity-100 focus-visible:opacity-100 self-end sm:self-auto px-2 py-1 rounded bg-[var(--p-chip)] text-[var(--p-muted)] text-[11px] cursor-pointer hover:bg-[var(--p-border)] transition-all whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderJsonValue(value)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.type === "files" && result.files) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px]"
|
||||||
|
style={{
|
||||||
|
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase mb-2">
|
||||||
|
output files
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-2 m-0 p-0 list-none">
|
||||||
|
{result.files.map((file, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between p-3 rounded-xl bg-[var(--p-surface)] border border-[var(--p-border)]">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-sans text-[13px] text-[var(--p-text)] font-medium">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-[var(--p-muted)]">
|
||||||
|
{file.sizeAfter ? `${(file.sizeAfter / 1024).toFixed(1)} KB` : 'Ready'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadBlob(file.blob, file.name)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-sans text-xs font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.type === "table" && result.columns && result.rows) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
|
||||||
|
<span>output table</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const csv = [
|
||||||
|
result.columns.join(","),
|
||||||
|
...result.rows.map(row => row.map(cell => `"${String(cell ?? "").replace(/"/g, '""')}"`).join(","))
|
||||||
|
].join("\n");
|
||||||
|
handleCopy(csv);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
|
||||||
|
>
|
||||||
|
Copy CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto w-full max-h-[400px] mt-2">
|
||||||
|
<table className="w-full border-collapse text-left font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--p-border)]">
|
||||||
|
{result.columns.map((col, i) => (
|
||||||
|
<th key={i} className="pb-[8px] pr-[12px] font-semibold text-[var(--p-muted)] uppercase tracking-wider text-[10px] font-mono">
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.rows.map((row, rIndex) => (
|
||||||
|
<tr
|
||||||
|
key={rIndex}
|
||||||
|
className="border-b border-[var(--p-border)] last:border-none hover:bg-[var(--p-chip)]/30 transition-colors"
|
||||||
|
>
|
||||||
|
{row.map((cell, cIndex) => (
|
||||||
|
<td key={cIndex} className="py-[10px] pr-[12px] font-mono text-[12px] text-[var(--p-text)]">
|
||||||
|
{cell === null ? (
|
||||||
|
<span className="text-[var(--p-muted)] italic">null</span>
|
||||||
|
) : (
|
||||||
|
String(cell)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJsonValue(value: unknown): React.ReactNode {
|
||||||
|
if (value === null || value === undefined) return "null";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return "[]";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{value.map((item, i) => (
|
||||||
|
<div key={i} className="pl-3 border-l-2 border-[var(--p-border)]">
|
||||||
|
{renderJsonValue(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) return "{}";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{entries.map(([key, val]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[var(--p-muted)] text-[11px] uppercase tracking-wider">{key}</span>
|
||||||
|
<div className="pl-3">
|
||||||
|
{typeof val === "object" && val !== null ? (
|
||||||
|
renderJsonValue(val)
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--p-text)]">{String(val ?? "null")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
231
src/components/tool/ToolShell.tsx
Normal file
231
src/components/tool/ToolShell.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type {
|
||||||
|
ToolOptionsValue,
|
||||||
|
UnknownPlimiPlugin,
|
||||||
|
} from "../../core/plugins/plugin-types";
|
||||||
|
import { describeToolPermissions } from "../../core/plugins/plugin-permissions";
|
||||||
|
import { ToolInputPanel } from "./ToolInputPanel";
|
||||||
|
import { ToolOptionsPanel } from "./ToolOptionsPanel";
|
||||||
|
import { ToolResultPanel } from "./ToolResultPanel";
|
||||||
|
import { ToolProgress } from "./ToolProgress";
|
||||||
|
import { useToolExecution } from "./useToolExecution";
|
||||||
|
import { CategoryIcon } from "../ui/CategoryIcon";
|
||||||
|
import { getPluginExamples } from "../../core/plugins/plugin-examples";
|
||||||
|
import { ExampleActionButton } from "./ExampleActionButton";
|
||||||
|
|
||||||
|
const TINTS: Record<string, { paper: string; ink: string; neon: string }> = {
|
||||||
|
image: { paper: "#E8F5E9", ink: "#2E7D32", neon: "#69F0AE" },
|
||||||
|
pdf: { paper: "#FFEBEE", ink: "#C62828", neon: "#FF5252" },
|
||||||
|
developer: { paper: "#E3F2FD", ink: "#1565C0", neon: "#448AFF" },
|
||||||
|
text: { paper: "#FFF8E1", ink: "#F57F17", neon: "#FFD740" },
|
||||||
|
crypto: { paper: "#F3E5F5", ink: "#6A1B9A", neon: "#E040FB" },
|
||||||
|
privacy: { paper: "#E0F2F1", ink: "#00695C", neon: "#64FFDA" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToolHeader({
|
||||||
|
plugin,
|
||||||
|
dark,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
plugin: UnknownPlimiPlugin;
|
||||||
|
dark: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const tints = TINTS[plugin.manifest.category] || TINTS.developer;
|
||||||
|
const permissions = describeToolPermissions(plugin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[14px] md:px-[24px] md:py-[20px]">
|
||||||
|
<div className="flex min-w-0 items-center gap-[14px]">
|
||||||
|
<span
|
||||||
|
className="inline-flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[12px] md:h-[44px] md:w-[44px]"
|
||||||
|
style={{
|
||||||
|
background: dark
|
||||||
|
? `color-mix(in oklab, ${tints.neon} 12%, transparent)`
|
||||||
|
: tints.paper,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
category={plugin.manifest.category}
|
||||||
|
color={dark ? tints.neon : tints.ink}
|
||||||
|
size={24}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-sans text-[15px] font-semibold tracking-tight text-[var(--p-text)] md:text-[18px]">
|
||||||
|
{plugin.manifest.name}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-[11px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
{plugin.manifest.category} / {permissions.join(" / ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 rounded-lg bg-[var(--p-chip)] px-3 py-2 font-sans text-[13px] text-[var(--p-muted)] cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolShell({
|
||||||
|
plugin,
|
||||||
|
onClose,
|
||||||
|
dark,
|
||||||
|
}: {
|
||||||
|
plugin: UnknownPlimiPlugin;
|
||||||
|
onClose?: () => void;
|
||||||
|
dark?: boolean;
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState<ToolInput>({});
|
||||||
|
const [options, setOptions] = useState<ToolOptionsValue>({});
|
||||||
|
const { run, cancel, result, isExecuting, progress, error } =
|
||||||
|
useToolExecution(plugin);
|
||||||
|
|
||||||
|
const tints = TINTS[plugin.manifest.category] || TINTS.developer;
|
||||||
|
const isDark = dark ?? false;
|
||||||
|
const examples = getPluginExamples(plugin);
|
||||||
|
const primaryExample = examples[0];
|
||||||
|
|
||||||
|
const handleRun = useCallback(async () => {
|
||||||
|
await run(input, options);
|
||||||
|
}, [input, options, run]);
|
||||||
|
|
||||||
|
const handleApplyExample = useCallback(() => {
|
||||||
|
if (!primaryExample) return;
|
||||||
|
|
||||||
|
setInput(primaryExample.input);
|
||||||
|
if (primaryExample.options) {
|
||||||
|
const exampleOptions = Object.fromEntries(
|
||||||
|
Object.entries(primaryExample.options).filter(([, value]) => value !== undefined)
|
||||||
|
) as ToolOptionsValue;
|
||||||
|
|
||||||
|
setOptions((currentOptions) => ({
|
||||||
|
...currentOptions,
|
||||||
|
...exampleOptions,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [primaryExample]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (plugin.capabilities?.customUi && plugin.customUi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isExecuting) {
|
||||||
|
handleRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleRun, isExecuting, plugin]);
|
||||||
|
|
||||||
|
if (plugin.capabilities?.customUi && plugin.customUi) {
|
||||||
|
const CustomUi = plugin.customUi;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolHeader plugin={plugin} dark={isDark} onClose={onClose} />
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--p-surface)]">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="p-6 font-sans text-sm text-[var(--p-muted)]">
|
||||||
|
Loading tool...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CustomUi plugin={plugin} dark={isDark} examples={examples} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolHeader plugin={plugin} dark={isDark} onClose={onClose} />
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-[16px] overflow-y-auto bg-[var(--p-surface)] p-[18px] md:grid-cols-[1fr_280px] md:gap-[24px] md:p-[28px]">
|
||||||
|
<div className="flex flex-col gap-[24px]">
|
||||||
|
<ToolInputPanel
|
||||||
|
definition={plugin.manifest.input}
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
dark={isDark}
|
||||||
|
tints={tints}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isExecuting && (
|
||||||
|
<ToolProgress
|
||||||
|
percentage={progress.percentage ?? 0}
|
||||||
|
message={progress.message}
|
||||||
|
onCancel={plugin.capabilities?.cancelable ? cancel : undefined}
|
||||||
|
dark={isDark}
|
||||||
|
tints={tints}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-100 p-4 font-sans text-sm text-red-800">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !isExecuting && (
|
||||||
|
<ToolResultPanel result={result} dark={isDark} tints={tints} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[24px]">
|
||||||
|
{plugin.optionsSchema && (
|
||||||
|
<ToolOptionsPanel
|
||||||
|
schema={plugin.optionsSchema}
|
||||||
|
value={options}
|
||||||
|
onChange={setOptions}
|
||||||
|
dark={isDark}
|
||||||
|
tints={tints}
|
||||||
|
toolCategory={plugin.manifest.category}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[12px] md:px-[24px] md:py-[14px]">
|
||||||
|
<span className="font-mono text-[11px] text-[var(--p-muted)]">
|
||||||
|
ready when you are
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{primaryExample && (
|
||||||
|
<ExampleActionButton
|
||||||
|
label={primaryExample.label}
|
||||||
|
disabled={isExecuting}
|
||||||
|
onClick={handleApplyExample}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={isExecuting}
|
||||||
|
className="cursor-pointer rounded-[10px] bg-[var(--p-accent)] px-[18px] py-[10px] font-sans text-[13px] font-semibold tracking-tight text-[var(--p-accent-ink)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isExecuting ? "Running..." : `Run ${plugin.manifest.name.split(" ")[0]} ->`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/tool/useToolExecution.ts
Normal file
82
src/components/tool/useToolExecution.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { PlimiPlugin, ToolContext, ToolProgress } from "../../core/plugins/plugin-types";
|
||||||
|
import { runPlugin } from "../../core/plugins/plugin-runner";
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
return "Operation was cancelled.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || "An error occurred during execution.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "An error occurred during execution.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToolExecution<TOptions>(plugin: PlimiPlugin<TOptions>) {
|
||||||
|
const [result, setResult] = useState<ToolResult | null>(null);
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<ToolProgress>({
|
||||||
|
percentage: 0,
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const run = useCallback(async (input: ToolInput, options: TOptions) => {
|
||||||
|
setIsExecuting(true);
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
setProgress({ percentage: 0, message: "Initializing..." });
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
const context: ToolContext = {
|
||||||
|
signal: abortController.signal,
|
||||||
|
reportProgress: (nextProgress) => {
|
||||||
|
setProgress({
|
||||||
|
percentage: nextProgress.percentage ?? 0,
|
||||||
|
message: nextProgress.message ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
logger: console,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextResult = await runPlugin(plugin, input, options, context);
|
||||||
|
setResult(nextResult);
|
||||||
|
return nextResult;
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [plugin]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
setProgress({ percentage: 0, message: "" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
result,
|
||||||
|
isExecuting,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/components/ui/Button.tsx
Normal file
26
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
|
||||||
|
const baseStyles = "inline-flex items-center justify-center px-[18px] py-[10px] rounded-[10px] font-sans font-semibold text-[13px] tracking-tight cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-all";
|
||||||
|
|
||||||
|
let variantStyles = "";
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
variantStyles = "bg-[var(--p-accent)] text-[var(--p-accent-ink)] hover:brightness-110";
|
||||||
|
break;
|
||||||
|
case "secondary":
|
||||||
|
variantStyles = "bg-[var(--p-chip)] text-[var(--p-text)] border border-[var(--p-border)] hover:bg-[var(--p-border)]";
|
||||||
|
break;
|
||||||
|
case "danger":
|
||||||
|
variantStyles = "bg-red-500 text-white hover:bg-red-600";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`${baseStyles} ${variantStyles} ${className}`} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/ui/Card.tsx
Normal file
22
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ title, children, className = "" }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-3 p-[18px] md:p-[24px] rounded-[14px] md:rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface-2)] ${className}`}>
|
||||||
|
{title && (
|
||||||
|
<h3 className="font-sans font-semibold text-[15px] md:text-[16px] text-[var(--p-text)] tracking-tight m-0">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/ui/CategoryIcon.tsx
Normal file
145
src/components/ui/CategoryIcon.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface CategoryIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
|
category: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryIcon({
|
||||||
|
category,
|
||||||
|
size = 24,
|
||||||
|
color = "currentColor",
|
||||||
|
strokeWidth = 2,
|
||||||
|
...props
|
||||||
|
}: CategoryIconProps) {
|
||||||
|
const normCategory = category.toLowerCase();
|
||||||
|
|
||||||
|
switch (normCategory) {
|
||||||
|
case "developer":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<polyline points="16 18 22 12 16 6" />
|
||||||
|
<polyline points="8 6 2 12 8 18" />
|
||||||
|
<line x1="14" y1="4" x2="10" y2="20" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="9" cy="9" r="2" />
|
||||||
|
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "pdf":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<path d="M10 9H8" />
|
||||||
|
<path d="M16 13H8" />
|
||||||
|
<path d="M16 17H8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "crypto":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "privacy":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
// Fallback to the original triangle shape
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 22h20L12 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/components/ui/Dropzone.tsx
Normal file
140
src/components/ui/Dropzone.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
interface DropzoneProps {
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
onFilesDrop: (files: File[]) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAccept(file: File, accept?: string): boolean {
|
||||||
|
if (!accept) return true;
|
||||||
|
const rules = accept.split(",").map((rule) => rule.trim()).filter(Boolean);
|
||||||
|
if (rules.length === 0) return true;
|
||||||
|
|
||||||
|
return rules.some((rule) => {
|
||||||
|
if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
|
||||||
|
if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule.toLowerCase());
|
||||||
|
return file.type === rule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dropzone({
|
||||||
|
accept,
|
||||||
|
multiple = false,
|
||||||
|
maxFiles,
|
||||||
|
maxSizeMb,
|
||||||
|
onFilesDrop,
|
||||||
|
className = "",
|
||||||
|
}: DropzoneProps) {
|
||||||
|
const [isDragActive, setIsDragActive] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizeFiles = useCallback((incomingFiles: File[]) => {
|
||||||
|
const pickedFiles = multiple ? incomingFiles : incomingFiles.slice(0, 1);
|
||||||
|
const limitedFiles = maxFiles ? pickedFiles.slice(0, maxFiles) : pickedFiles;
|
||||||
|
const maxBytes = maxSizeMb ? maxSizeMb * 1024 * 1024 : undefined;
|
||||||
|
|
||||||
|
const invalidType = limitedFiles.find((file) => !matchesAccept(file, accept));
|
||||||
|
if (invalidType) {
|
||||||
|
setError(`${invalidType.name} is not an accepted file type.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversized = maxBytes
|
||||||
|
? limitedFiles.find((file) => file.size > maxBytes)
|
||||||
|
: undefined;
|
||||||
|
if (oversized) {
|
||||||
|
setError(`${oversized.name} is larger than ${maxSizeMb} MB.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
return limitedFiles;
|
||||||
|
}, [accept, maxFiles, maxSizeMb, multiple]);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragActive(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDragActive) {
|
||||||
|
setIsDragActive(true);
|
||||||
|
}
|
||||||
|
}, [isDragActive]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
const droppedFiles = normalizeFiles(Array.from(e.dataTransfer.files));
|
||||||
|
if (droppedFiles) onFilesDrop(droppedFiles);
|
||||||
|
e.dataTransfer.clearData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizeFiles, onFilesDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
const selectedFiles = normalizeFiles(Array.from(e.target.files));
|
||||||
|
if (selectedFiles) onFilesDrop(selectedFiles);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizeFiles, onFilesDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-[14px] min-h-[140px] transition-colors cursor-pointer text-center ${
|
||||||
|
isDragActive
|
||||||
|
? "border-[var(--p-accent)] bg-opacity-10 bg-[var(--p-accent)]"
|
||||||
|
: "border-[var(--p-border)] hover:bg-[var(--p-surface)]"
|
||||||
|
} ${className}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-2 pointer-events-none">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--p-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-sans text-[13px] font-medium text-[var(--p-text)]">
|
||||||
|
{isDragActive ? "Drop files now" : "Drop files here or click to browse"}
|
||||||
|
</span>
|
||||||
|
{error && (
|
||||||
|
<span className="font-sans text-[12px] text-red-600">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/ui/Input.tsx
Normal file
41
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, className = "", ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className="px-3 py-2 bg-[var(--p-surface)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] placeholder:text-[var(--p-muted)] font-sans focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ label, className = "", ...props }: TextareaProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="px-3 py-2 bg-[var(--p-surface)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] placeholder:text-[var(--p-muted)] font-mono focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ui/Select.tsx
Normal file
33
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, options, className = "", ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className="px-3 py-2 bg-[var(--p-chip)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] font-sans focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full cursor-pointer appearance-none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ui/Slider.tsx
Normal file
28
src/components/ui/Slider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider({ label, className = "", ...props }: SliderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="font-sans text-[13px] text-[var(--p-text)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-[11px] text-[var(--p-muted)]">
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="w-full h-1.5 bg-[var(--p-border)] rounded-full appearance-none cursor-pointer outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--p-accent)]"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/core/io/input-types.ts
Normal file
87
src/core/io/input-types.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export interface ToolInputValue {
|
||||||
|
text?: string;
|
||||||
|
files?: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseToolInputFieldDefinition {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
example?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolInputFieldDefinition =
|
||||||
|
| (BaseToolInputFieldDefinition & {
|
||||||
|
type: "text";
|
||||||
|
maxLength?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
})
|
||||||
|
| (BaseToolInputFieldDefinition & {
|
||||||
|
type: "files";
|
||||||
|
accept?: string[];
|
||||||
|
multiple?: boolean;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
})
|
||||||
|
| (BaseToolInputFieldDefinition & {
|
||||||
|
type: "text-or-files";
|
||||||
|
accept?: string[];
|
||||||
|
maxSizeMb?: number;
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ToolInputDefinition =
|
||||||
|
| {
|
||||||
|
type: "none";
|
||||||
|
}
|
||||||
|
| ToolInputFieldDefinition
|
||||||
|
| {
|
||||||
|
type: "group";
|
||||||
|
fields: ToolInputFieldDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolInput extends ToolInputValue {
|
||||||
|
values?: Record<string, ToolInputValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputValue(input: ToolInput, key = "input"): ToolInputValue {
|
||||||
|
if (key === "input") {
|
||||||
|
return {
|
||||||
|
text: input.text,
|
||||||
|
files: input.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.values?.[key] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextInput(input: ToolInput, key = "input"): string {
|
||||||
|
return getInputValue(input, key).text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilesInput(input: ToolInput, key = "input"): File[] | undefined {
|
||||||
|
return getInputValue(input, key).files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInputValue(
|
||||||
|
input: ToolInput,
|
||||||
|
key: string,
|
||||||
|
value: ToolInputValue
|
||||||
|
): ToolInput {
|
||||||
|
if (key === "input") {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
text: value.text,
|
||||||
|
files: value.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
values: {
|
||||||
|
...(input.values ?? {}),
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/core/io/output-types.ts
Normal file
33
src/core/io/output-types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface OutputFile {
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
blob: Blob;
|
||||||
|
sizeBefore?: number;
|
||||||
|
sizeAfter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolOutputDefinition =
|
||||||
|
| { type: "text" }
|
||||||
|
| { type: "files" }
|
||||||
|
| { type: "json" }
|
||||||
|
| { type: "table" };
|
||||||
|
|
||||||
|
export type ToolResult =
|
||||||
|
| {
|
||||||
|
type: "text";
|
||||||
|
value: string;
|
||||||
|
language?: "plain" | "json" | "html" | "markdown";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "files";
|
||||||
|
files: OutputFile[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "json";
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "table";
|
||||||
|
columns: string[];
|
||||||
|
rows: Array<Array<string | number | boolean | null>>;
|
||||||
|
};
|
||||||
70
src/core/plugins/plugin-examples.ts
Normal file
70
src/core/plugins/plugin-examples.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
setInputValue,
|
||||||
|
type ToolInput,
|
||||||
|
type ToolInputDefinition,
|
||||||
|
type ToolInputFieldDefinition,
|
||||||
|
} from "../io/input-types";
|
||||||
|
import type {
|
||||||
|
ToolExample,
|
||||||
|
ToolOptionsValue,
|
||||||
|
UnknownPlimiPlugin,
|
||||||
|
} from "./plugin-types";
|
||||||
|
|
||||||
|
const DEFAULT_LABEL = "Try example";
|
||||||
|
|
||||||
|
function exampleForField(
|
||||||
|
field: ToolInputFieldDefinition,
|
||||||
|
fallback?: string
|
||||||
|
): string | undefined {
|
||||||
|
if (field.type === "files") return undefined;
|
||||||
|
return field.example ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputFromDefinition(
|
||||||
|
definition: ToolInputDefinition,
|
||||||
|
manifestExample?: string
|
||||||
|
): ToolInput | undefined {
|
||||||
|
if (definition.type === "none") {
|
||||||
|
return manifestExample ? {} : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type !== "group") {
|
||||||
|
const example = exampleForField(definition, manifestExample);
|
||||||
|
return example
|
||||||
|
? setInputValue({}, definition.key ?? "input", { text: example })
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextInput: ToolInput = {};
|
||||||
|
let hasExample = false;
|
||||||
|
|
||||||
|
definition.fields.forEach((field) => {
|
||||||
|
const example = exampleForField(field);
|
||||||
|
if (!example) return;
|
||||||
|
|
||||||
|
hasExample = true;
|
||||||
|
nextInput = setInputValue(nextInput, field.key ?? "input", {
|
||||||
|
text: example,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasExample ? nextInput : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginExamples(
|
||||||
|
plugin: UnknownPlimiPlugin
|
||||||
|
): ToolExample<ToolOptionsValue>[] {
|
||||||
|
if (plugin.examples && plugin.examples.length > 0) {
|
||||||
|
return plugin.examples.map((example) => ({
|
||||||
|
...example,
|
||||||
|
label: example.label ?? DEFAULT_LABEL,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = inputFromDefinition(
|
||||||
|
plugin.manifest.input,
|
||||||
|
plugin.manifest.example
|
||||||
|
);
|
||||||
|
|
||||||
|
return input ? [{ label: DEFAULT_LABEL, input }] : [];
|
||||||
|
}
|
||||||
12
src/core/plugins/plugin-loader.ts
Normal file
12
src/core/plugins/plugin-loader.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UnknownPlimiPlugin } from "./plugin-types";
|
||||||
|
import { getPluginById, getAllPlugins } from "./plugin-registry";
|
||||||
|
|
||||||
|
// In V1, loading is static since we bundle the plugins.
|
||||||
|
// In the future, this file will handle lazy loading of external plugins.
|
||||||
|
export async function loadPlugin(id: string): Promise<UnknownPlimiPlugin | undefined> {
|
||||||
|
return getPluginById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllPlugins(): Promise<UnknownPlimiPlugin[]> {
|
||||||
|
return getAllPlugins();
|
||||||
|
}
|
||||||
35
src/core/plugins/plugin-permissions.ts
Normal file
35
src/core/plugins/plugin-permissions.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolPermissions, UnknownPlimiPlugin } from "./plugin-types";
|
||||||
|
|
||||||
|
export const DEFAULT_PERMISSIONS: ToolPermissions = {
|
||||||
|
network: "none",
|
||||||
|
clipboard: "none",
|
||||||
|
fileSystem: "read",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getToolPermissions(plugin: UnknownPlimiPlugin): ToolPermissions {
|
||||||
|
return {
|
||||||
|
...DEFAULT_PERMISSIONS,
|
||||||
|
...plugin.permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeToolPermissions(plugin: UnknownPlimiPlugin): string[] {
|
||||||
|
const permissions = getToolPermissions(plugin);
|
||||||
|
const descriptions = ["Runs locally"];
|
||||||
|
|
||||||
|
if (permissions.network === "none") {
|
||||||
|
descriptions.push("No network");
|
||||||
|
} else {
|
||||||
|
descriptions.push(`${permissions.network} network`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.clipboard && permissions.clipboard !== "none") {
|
||||||
|
descriptions.push(`Clipboard ${permissions.clipboard}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.fileSystem && permissions.fileSystem !== "none") {
|
||||||
|
descriptions.push(`Files ${permissions.fileSystem}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
74
src/core/plugins/plugin-registry.ts
Normal file
74
src/core/plugins/plugin-registry.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { PlimiPlugin, UnknownPlimiPlugin } from "./plugin-types";
|
||||||
|
import { base64Plugin } from "../../tools/base64";
|
||||||
|
import { imageOptimizerPlugin } from "../../tools/image-optimizer";
|
||||||
|
import { imageEditorPlugin } from "../../tools/image-editor";
|
||||||
|
import { pdfMergerPlugin } from "../../tools/pdf-merger";
|
||||||
|
import { hashGeneratorPlugin } from "../../tools/hash-generator";
|
||||||
|
import { uuidGeneratorPlugin } from "../../tools/uuid-generator";
|
||||||
|
import { jsonFormatterPlugin } from "../../tools/json-formatter";
|
||||||
|
import { textCasePlugin } from "../../tools/text-case";
|
||||||
|
import { urlEncoderPlugin } from "../../tools/url-encoder";
|
||||||
|
import { loremIpsumPlugin } from "../../tools/lorem-ipsum";
|
||||||
|
import { colorConverterPlugin } from "../../tools/color-converter";
|
||||||
|
import { regexTesterPlugin } from "../../tools/regex-tester";
|
||||||
|
import { numberBasePlugin } from "../../tools/number-base";
|
||||||
|
import { timestampConverterPlugin } from "../../tools/timestamp-converter";
|
||||||
|
import { markdownToHtmlPlugin } from "../../tools/markdown-to-html";
|
||||||
|
import { textDiffPlugin } from "../../tools/text-diff";
|
||||||
|
import { htmlEntityPlugin } from "../../tools/html-entity";
|
||||||
|
import { variablesConverterPlugin } from "../../tools/variables-converter";
|
||||||
|
import { pdfSplitterPlugin } from "../../tools/pdf-splitter";
|
||||||
|
import { imageRedactorPlugin } from "../../tools/image-redactor";
|
||||||
|
import { exifScrubberPlugin } from "../../tools/exif-scrubber";
|
||||||
|
import { jwtDecoderPlugin } from "../../tools/jwt-decoder";
|
||||||
|
import { csvToolsPlugin } from "../../tools/csv-tools";
|
||||||
|
import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator";
|
||||||
|
import { passwordGeneratorPlugin } from "../../tools/password-generator";
|
||||||
|
import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
|
||||||
|
|
||||||
|
function erasePlugin<TOptions>(plugin: PlimiPlugin<TOptions>): UnknownPlimiPlugin {
|
||||||
|
return plugin as unknown as UnknownPlimiPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginRegistry: UnknownPlimiPlugin[] = [
|
||||||
|
erasePlugin(base64Plugin),
|
||||||
|
erasePlugin(hashGeneratorPlugin),
|
||||||
|
erasePlugin(uuidGeneratorPlugin),
|
||||||
|
erasePlugin(jsonFormatterPlugin),
|
||||||
|
erasePlugin(textCasePlugin),
|
||||||
|
erasePlugin(urlEncoderPlugin),
|
||||||
|
erasePlugin(loremIpsumPlugin),
|
||||||
|
erasePlugin(colorConverterPlugin),
|
||||||
|
erasePlugin(regexTesterPlugin),
|
||||||
|
erasePlugin(numberBasePlugin),
|
||||||
|
erasePlugin(timestampConverterPlugin),
|
||||||
|
erasePlugin(variablesConverterPlugin),
|
||||||
|
erasePlugin(markdownToHtmlPlugin),
|
||||||
|
erasePlugin(textDiffPlugin),
|
||||||
|
erasePlugin(htmlEntityPlugin),
|
||||||
|
erasePlugin(imageEditorPlugin),
|
||||||
|
erasePlugin(imageOptimizerPlugin),
|
||||||
|
erasePlugin(pdfMergerPlugin),
|
||||||
|
erasePlugin(pdfSplitterPlugin),
|
||||||
|
erasePlugin(imageRedactorPlugin),
|
||||||
|
erasePlugin(exifScrubberPlugin),
|
||||||
|
erasePlugin(jwtDecoderPlugin),
|
||||||
|
erasePlugin(csvToolsPlugin),
|
||||||
|
erasePlugin(qrCodeGeneratorPlugin),
|
||||||
|
erasePlugin(passwordGeneratorPlugin),
|
||||||
|
erasePlugin(fileChecksumVerifierPlugin),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getAllPlugins(): UnknownPlimiPlugin[] {
|
||||||
|
return pluginRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginById(id: string): UnknownPlimiPlugin | undefined {
|
||||||
|
return pluginRegistry.find((plugin) => plugin.manifest.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginsByCategory(category: string): UnknownPlimiPlugin[] {
|
||||||
|
return pluginRegistry.filter(
|
||||||
|
(plugin) => plugin.manifest.category === category
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/core/plugins/plugin-runner.ts
Normal file
84
src/core/plugins/plugin-runner.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { PlimiPlugin, ToolContext } from "./plugin-types";
|
||||||
|
import type { ToolInput } from "../io/input-types";
|
||||||
|
import type { ToolResult } from "../io/output-types";
|
||||||
|
import type { ToolWorkerRequest, ToolWorkerResponse } from "./worker-protocol";
|
||||||
|
import { normalizeToolOptions, validateToolInput } from "./plugin-validation";
|
||||||
|
|
||||||
|
export async function runPlugin<TOptions>(
|
||||||
|
plugin: PlimiPlugin<TOptions>,
|
||||||
|
input: ToolInput,
|
||||||
|
options: TOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
validateToolInput(plugin.manifest.input, input);
|
||||||
|
const normalizedOptions = plugin.optionsSchema
|
||||||
|
? normalizeToolOptions(plugin.optionsSchema, options) as TOptions
|
||||||
|
: options;
|
||||||
|
|
||||||
|
if (plugin.capabilities?.worker && plugin.worker) {
|
||||||
|
return runPluginInWorker(plugin, input, normalizedOptions, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin.run) {
|
||||||
|
throw new Error(`Plugin ${plugin.manifest.id} has no runner`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugin.run(input, normalizedOptions, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPluginInWorker<TOptions>(
|
||||||
|
plugin: PlimiPlugin<TOptions>,
|
||||||
|
input: ToolInput,
|
||||||
|
options: TOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!plugin.worker) {
|
||||||
|
return reject(new Error("Worker is not defined for this plugin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = plugin.worker();
|
||||||
|
const jobId = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
|
// Handle cancellation
|
||||||
|
const onAbort = () => {
|
||||||
|
worker.terminate();
|
||||||
|
reject(new DOMException("Operation cancelled by user.", "AbortError"));
|
||||||
|
};
|
||||||
|
context.signal.addEventListener("abort", onAbort);
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent<ToolWorkerResponse>) => {
|
||||||
|
const { type, id } = e.data;
|
||||||
|
|
||||||
|
if (id !== jobId) return;
|
||||||
|
|
||||||
|
if (type === "progress") {
|
||||||
|
context.reportProgress(e.data.progress);
|
||||||
|
} else if (type === "success") {
|
||||||
|
context.signal.removeEventListener("abort", onAbort);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(e.data.result);
|
||||||
|
} else if (type === "error") {
|
||||||
|
context.signal.removeEventListener("abort", onAbort);
|
||||||
|
worker.terminate();
|
||||||
|
reject(new Error(e.data.error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = (e) => {
|
||||||
|
context.signal.removeEventListener("abort", onAbort);
|
||||||
|
worker.terminate();
|
||||||
|
reject(new Error("Worker error: " + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize input payload (can't send DOM elements or complex functions)
|
||||||
|
const request: ToolWorkerRequest<TOptions> = {
|
||||||
|
type: "run",
|
||||||
|
id: jobId,
|
||||||
|
input,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.postMessage(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
155
src/core/plugins/plugin-types.ts
Normal file
155
src/core/plugins/plugin-types.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { ToolInputDefinition, ToolInput } from "../io/input-types";
|
||||||
|
import type { ToolOutputDefinition, ToolResult } from "../io/output-types";
|
||||||
|
|
||||||
|
export type ToolCategory =
|
||||||
|
| "image"
|
||||||
|
| "pdf"
|
||||||
|
| "text"
|
||||||
|
| "developer"
|
||||||
|
| "crypto"
|
||||||
|
| "conversion"
|
||||||
|
| "privacy";
|
||||||
|
|
||||||
|
export interface ToolManifest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: ToolCategory;
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
input: ToolInputDefinition;
|
||||||
|
output: ToolOutputDefinition;
|
||||||
|
|
||||||
|
tags?: string[];
|
||||||
|
icon?: string;
|
||||||
|
example?: string;
|
||||||
|
offlineReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolOptionField =
|
||||||
|
| {
|
||||||
|
type: "text";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "number";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "boolean";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "select";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: string;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "slider";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolOptionsSchema {
|
||||||
|
fields: ToolOptionField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolOptionValue = string | number | boolean;
|
||||||
|
export type ToolOptionsValue = Record<string, ToolOptionValue>;
|
||||||
|
|
||||||
|
export interface ToolCapabilities {
|
||||||
|
batch?: boolean;
|
||||||
|
worker?: boolean;
|
||||||
|
wasm?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
|
streaming?: boolean;
|
||||||
|
cancelable?: boolean;
|
||||||
|
customUi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolPermissions {
|
||||||
|
network: "none" | "optional" | "required";
|
||||||
|
clipboard?: "none" | "read" | "write" | "read-write";
|
||||||
|
fileSystem?: "none" | "read" | "write" | "read-write";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolProgress {
|
||||||
|
percentage?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExample<TOptions = unknown> {
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
input: ToolInput;
|
||||||
|
options?: Partial<TOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolContext {
|
||||||
|
signal: AbortSignal;
|
||||||
|
reportProgress: (progress: ToolProgress) => void;
|
||||||
|
logger: {
|
||||||
|
info: (message: string) => void;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolRunner<TOptions = unknown> = (
|
||||||
|
input: ToolInput,
|
||||||
|
options: TOptions,
|
||||||
|
context: ToolContext
|
||||||
|
) => Promise<ToolResult>;
|
||||||
|
|
||||||
|
export interface ToolUiProps<TOptions = unknown> {
|
||||||
|
plugin: PlimiPlugin<TOptions>;
|
||||||
|
dark?: boolean;
|
||||||
|
examples?: ToolExample<TOptions>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlimiPlugin<TOptions = unknown> {
|
||||||
|
manifest: ToolManifest;
|
||||||
|
optionsSchema?: ToolOptionsSchema;
|
||||||
|
examples?: ToolExample<TOptions>[];
|
||||||
|
capabilities?: ToolCapabilities;
|
||||||
|
permissions?: ToolPermissions;
|
||||||
|
|
||||||
|
run?: ToolRunner<TOptions>;
|
||||||
|
|
||||||
|
worker?: () => Worker;
|
||||||
|
|
||||||
|
customUi?: React.LazyExoticComponent<
|
||||||
|
React.ComponentType<ToolUiProps<TOptions>>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnknownPlimiPlugin = Omit<
|
||||||
|
PlimiPlugin<never>,
|
||||||
|
"run" | "customUi"
|
||||||
|
> & {
|
||||||
|
run?: ToolRunner<ToolOptionsValue>;
|
||||||
|
examples?: ToolExample<ToolOptionsValue>[];
|
||||||
|
customUi?: React.LazyExoticComponent<
|
||||||
|
React.ComponentType<ToolUiProps<ToolOptionsValue>>
|
||||||
|
>;
|
||||||
|
};
|
||||||
151
src/core/plugins/plugin-validation.ts
Normal file
151
src/core/plugins/plugin-validation.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type {
|
||||||
|
ToolOptionField,
|
||||||
|
ToolOptionValue,
|
||||||
|
ToolOptionsSchema,
|
||||||
|
ToolOptionsValue,
|
||||||
|
} from "./plugin-types";
|
||||||
|
import type {
|
||||||
|
ToolInput,
|
||||||
|
ToolInputDefinition,
|
||||||
|
ToolInputFieldDefinition,
|
||||||
|
} from "../io/input-types";
|
||||||
|
import { getInputValue } from "../io/input-types";
|
||||||
|
|
||||||
|
function describeInput(field: ToolInputFieldDefinition): string {
|
||||||
|
return field.label ?? field.key ?? "input";
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAccept(file: File, accept: string[]): boolean {
|
||||||
|
if (accept.length === 0) return true;
|
||||||
|
|
||||||
|
return accept.some((rule) => {
|
||||||
|
if (rule.endsWith("/*")) {
|
||||||
|
return file.type.startsWith(rule.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.startsWith(".")) {
|
||||||
|
return file.name.toLowerCase().endsWith(rule.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.type === rule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateText(field: Extract<ToolInputFieldDefinition, { type: "text" }>, text?: string) {
|
||||||
|
if (field.maxLength !== undefined && (text?.length ?? 0) > field.maxLength) {
|
||||||
|
throw new Error(`${describeInput(field)} must be ${field.maxLength} characters or fewer.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFiles(
|
||||||
|
field: Extract<ToolInputFieldDefinition, { type: "files" | "text-or-files" }>,
|
||||||
|
files?: File[]
|
||||||
|
) {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
if ("multiple" in field && field.multiple === false && files.length > 1) {
|
||||||
|
throw new Error(`${describeInput(field)} accepts only one file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("maxFiles" in field && field.maxFiles !== undefined && files.length > field.maxFiles) {
|
||||||
|
throw new Error(`${describeInput(field)} accepts at most ${field.maxFiles} files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.accept) {
|
||||||
|
const invalidFile = files.find((file) => !matchesAccept(file, field.accept ?? []));
|
||||||
|
if (invalidFile) {
|
||||||
|
throw new Error(`${invalidFile.name} is not an accepted file type.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.maxSizeMb !== undefined) {
|
||||||
|
const maxBytes = field.maxSizeMb * 1024 * 1024;
|
||||||
|
const oversized = files.find((file) => file.size > maxBytes);
|
||||||
|
if (oversized) {
|
||||||
|
throw new Error(`${oversized.name} is larger than ${field.maxSizeMb} MB.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(field: ToolInputFieldDefinition, input: ToolInput) {
|
||||||
|
const value = getInputValue(input, field.key ?? "input");
|
||||||
|
|
||||||
|
if (field.type === "text") {
|
||||||
|
validateText(field, value.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "files") {
|
||||||
|
validateFiles(field, value.files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateFiles(field, value.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateToolInput(definition: ToolInputDefinition, input: ToolInput) {
|
||||||
|
if (definition.type === "none") return;
|
||||||
|
|
||||||
|
const fields = definition.type === "group" ? definition.fields : [definition];
|
||||||
|
fields.forEach((field) => validateField(field, input));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionField(
|
||||||
|
field: ToolOptionField,
|
||||||
|
rawValue: unknown
|
||||||
|
): ToolOptionValue {
|
||||||
|
const value = rawValue ?? field.defaultValue;
|
||||||
|
|
||||||
|
if (field.type === "text") {
|
||||||
|
return typeof value === "string" ? value : String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new Error(`${field.label} must be true or false.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "select") {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new Error(`${field.label} must be one of the available options.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!field.options.some((option) => option.value === value)) {
|
||||||
|
throw new Error(`${field.label} has an invalid option.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
throw new Error(`${field.label} must be a number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.min !== undefined && value < field.min) {
|
||||||
|
throw new Error(`${field.label} must be at least ${field.min}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.max !== undefined && value > field.max) {
|
||||||
|
throw new Error(`${field.label} must be at most ${field.max}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeToolOptions(
|
||||||
|
schema: ToolOptionsSchema | undefined,
|
||||||
|
options: unknown
|
||||||
|
): ToolOptionsValue {
|
||||||
|
if (!schema) return {};
|
||||||
|
|
||||||
|
const raw = options && typeof options === "object"
|
||||||
|
? (options as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return schema.fields.reduce<ToolOptionsValue>((acc, field) => {
|
||||||
|
acc[field.key] = normalizeOptionField(field, raw[field.key]);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
27
src/core/plugins/worker-protocol.ts
Normal file
27
src/core/plugins/worker-protocol.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ToolInput } from "../io/input-types";
|
||||||
|
import type { ToolProgress } from "./plugin-types";
|
||||||
|
import type { ToolResult } from "../io/output-types";
|
||||||
|
|
||||||
|
export interface ToolWorkerRequest<TOptions = unknown> {
|
||||||
|
type: "run";
|
||||||
|
id: string;
|
||||||
|
input: ToolInput;
|
||||||
|
options: TOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolWorkerResponse =
|
||||||
|
| {
|
||||||
|
type: "progress";
|
||||||
|
id: string;
|
||||||
|
progress: ToolProgress;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "success";
|
||||||
|
id: string;
|
||||||
|
result: ToolResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "error";
|
||||||
|
id: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
127
src/index.css
Normal file
127
src/index.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Geist', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Light mode (warm paper) */
|
||||||
|
--p-bg-light: #F4EEE3;
|
||||||
|
--p-surface-light: #FBF6ED;
|
||||||
|
--p-surface-2-light: #FFFBF2;
|
||||||
|
--p-border-light: #E4DACA;
|
||||||
|
--p-chip-light: #EDE3D0;
|
||||||
|
--p-text-light: #1A1714;
|
||||||
|
--p-muted-light: #786E60;
|
||||||
|
--p-shadow-soft-light: rgba(60, 40, 20, 0.22);
|
||||||
|
--p-shadow-inset-light: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
--p-bg-dark: #0F1117;
|
||||||
|
--p-surface-dark: #161922;
|
||||||
|
--p-surface-2-dark: #1B1F2A;
|
||||||
|
--p-border-dark: #262A36;
|
||||||
|
--p-chip-dark: #1E222D;
|
||||||
|
--p-text-dark: #F2F2EF;
|
||||||
|
--p-muted-dark: #8B919C;
|
||||||
|
--p-shadow-soft-dark: rgba(0, 0, 0, 0.6);
|
||||||
|
--p-shadow-inset-dark: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
|
/* Accent — default lime */
|
||||||
|
--p-accent-light: #5B8C3E;
|
||||||
|
--p-accent-dark: #A8FF60;
|
||||||
|
--p-accent-ink-light: #FFFBF2;
|
||||||
|
--p-accent-ink-dark: #0F1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plimi-theme-light {
|
||||||
|
--p-bg: var(--p-bg-light);
|
||||||
|
--p-surface: var(--p-surface-light);
|
||||||
|
--p-surface-2: var(--p-surface-2-light);
|
||||||
|
--p-border: var(--p-border-light);
|
||||||
|
--p-chip: var(--p-chip-light);
|
||||||
|
--p-text: var(--p-text-light);
|
||||||
|
--p-muted: var(--p-muted-light);
|
||||||
|
--p-shadow-soft: var(--p-shadow-soft-light);
|
||||||
|
--p-shadow-inset: var(--p-shadow-inset-light);
|
||||||
|
--p-accent: var(--p-accent-light);
|
||||||
|
--p-accent-ink: var(--p-accent-ink-light);
|
||||||
|
--p-paper-noise: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 0.42 0 0 0 0 0.34 0 0 0 0 0.22 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
.plimi-theme-dark {
|
||||||
|
--p-bg: var(--p-bg-dark);
|
||||||
|
--p-surface: var(--p-surface-dark);
|
||||||
|
--p-surface-2: var(--p-surface-2-dark);
|
||||||
|
--p-border: var(--p-border-dark);
|
||||||
|
--p-chip: var(--p-chip-dark);
|
||||||
|
--p-text: var(--p-text-dark);
|
||||||
|
--p-muted: var(--p-muted-dark);
|
||||||
|
--p-shadow-soft: var(--p-shadow-soft-dark);
|
||||||
|
--p-shadow-inset: var(--p-shadow-inset-dark);
|
||||||
|
--p-accent: var(--p-accent-dark);
|
||||||
|
--p-accent-ink: var(--p-accent-ink-dark);
|
||||||
|
--p-paper-noise: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.03 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes plimi-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes plimi-slide {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-plimi-fade {
|
||||||
|
animation: plimi-fade 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-plimi-slide {
|
||||||
|
animation: plimi-slide 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--p-bg);
|
||||||
|
color: var(--p-text);
|
||||||
|
font-family: 'Geist', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--p-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--p-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling to match the sleek design */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--p-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--p-muted);
|
||||||
|
}
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import { ThemeProvider } from "./app/ThemeProvider";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
5
src/pages/HomePage.tsx
Normal file
5
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
return <Navigate to="/tools" replace />;
|
||||||
|
}
|
||||||
202
src/pages/HowItWorksPage.tsx
Normal file
202
src/pages/HowItWorksPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const workflow = [
|
||||||
|
{
|
||||||
|
label: "Input",
|
||||||
|
title: "Choose a tool",
|
||||||
|
copy: "Paste text, pick a file, or adjust options in a generated tool panel.",
|
||||||
|
icon: (
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Run",
|
||||||
|
title: "Process locally",
|
||||||
|
copy: "The plugin runs in your browser using JavaScript, workers, Canvas, or PDF libraries.",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Output",
|
||||||
|
title: "Copy or download",
|
||||||
|
copy: "Results appear immediately in the page. Files stay on the device unless you export them.",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M12 4v11" />
|
||||||
|
<path d="m7 10 5 5 5-5" />
|
||||||
|
<path d="M5 20h14" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const principles = [
|
||||||
|
{
|
||||||
|
title: "No upload step",
|
||||||
|
copy: "Plimi does not send your inputs to an application server for conversion.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Plugin boundaries",
|
||||||
|
copy: "Each utility declares its own inputs, options, permissions, and result type.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Responsive execution",
|
||||||
|
copy: "Heavier work can move off the main interface thread so the page stays usable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Explicit results",
|
||||||
|
copy: "Tools return structured outputs: copied text, rendered previews, or downloadable files.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const layers = [
|
||||||
|
{ name: "Tool manifest", detail: "Inputs, options, permissions" },
|
||||||
|
{ name: "Generated UI", detail: "Forms, sliders, selects, validation" },
|
||||||
|
{ name: "Runner", detail: "Browser APIs, workers, local libraries" },
|
||||||
|
{ name: "Result panel", detail: "Preview, copy, download" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function IconFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] text-[var(--p-text)]">
|
||||||
|
<svg
|
||||||
|
width="19"
|
||||||
|
height="19"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HowItWorksPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[1120px] flex-col gap-10 pb-16 animate-plimi-slide">
|
||||||
|
<section className="grid grid-cols-1 gap-8 md:grid-cols-[0.95fr_1.05fr] md:items-end">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||||
|
browser-first architecture
|
||||||
|
</span>
|
||||||
|
<h1 className="m-0 max-w-[620px] font-sans text-4xl font-bold leading-[1.02] tracking-tight text-[var(--p-text)] text-balance md:text-[56px]">
|
||||||
|
Your files stay where they started.
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
||||||
|
Plimi is a collection of small local utilities. The interface loads the tool, runs the
|
||||||
|
work in the browser, then hands the result back to you without a server round trip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4 md:p-5"
|
||||||
|
style={{
|
||||||
|
boxShadow:
|
||||||
|
"0 18px 38px -28px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-3">
|
||||||
|
{workflow.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.label}
|
||||||
|
className="relative flex min-h-[170px] flex-col gap-4 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<IconFrame>{step.icon}</IconFrame>
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-accent)]">
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
<h2 className="m-0 font-sans text-[17px] font-semibold tracking-tight text-[var(--p-text)]">
|
||||||
|
{step.title}
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{step.copy}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
{principles.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.title}
|
||||||
|
className="flex min-h-[150px] flex-col justify-between gap-5 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5"
|
||||||
|
>
|
||||||
|
<h2 className="m-0 font-sans text-[16px] font-semibold tracking-tight text-[var(--p-text)]">
|
||||||
|
{item.title}
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{item.copy}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-6 rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5 md:grid-cols-[0.75fr_1.25fr] md:p-7">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||||
|
plugin model
|
||||||
|
</span>
|
||||||
|
<h2 className="m-0 font-sans text-2xl font-bold tracking-tight text-[var(--p-text)]">
|
||||||
|
One shell, many tools.
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">
|
||||||
|
Plimi keeps the application shell simple. Each tool contributes a compact manifest and
|
||||||
|
a runner, so new utilities can share the same dependable interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{layers.map((layer, index) => (
|
||||||
|
<div
|
||||||
|
key={layer.name}
|
||||||
|
className="flex items-start gap-4 rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--p-chip)] font-mono text-[11px] text-[var(--p-muted)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="m-0 font-sans text-sm font-semibold text-[var(--p-text)]">
|
||||||
|
{layer.name}
|
||||||
|
</h3>
|
||||||
|
<p className="m-0 text-[13px] leading-relaxed text-[var(--p-muted)]">
|
||||||
|
{layer.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col items-start justify-between gap-4 border-t border-[var(--p-border)] pt-7 md:flex-row md:items-center">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
|
||||||
|
Ready to run something?
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm text-[var(--p-muted)]">
|
||||||
|
Pick a utility and the same local workflow applies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/tools"
|
||||||
|
className="inline-flex items-center justify-center rounded-[10px] bg-[var(--p-accent)] px-5 py-3 font-sans text-sm font-semibold tracking-tight text-[var(--p-accent-ink)] no-underline transition-[filter] hover:brightness-110"
|
||||||
|
>
|
||||||
|
Browse tools
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/pages/ToolDetailPage.tsx
Normal file
47
src/pages/ToolDetailPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
||||||
|
import { ToolShell } from "../components/tool/ToolShell";
|
||||||
|
import { useTheme } from "../app/useTheme";
|
||||||
|
|
||||||
|
export function ToolDetailPage() {
|
||||||
|
const { toolId } = useParams<{ toolId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
|
const plugin = pluginRegistry.find((p) => p.manifest.id === toolId);
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-[var(--p-text)]">Tool not found</h2>
|
||||||
|
<button onClick={() => navigate("/tools")} className="text-blue-500 hover:underline">
|
||||||
|
Return to directory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate("/tools");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-0 md:p-10 animate-plimi-fade"
|
||||||
|
style={{
|
||||||
|
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full md:w-[min(900px,100%)] h-[92%] md:h-auto md:max-h-full bg-[var(--p-surface)] rounded-t-[20px] md:rounded-[24px] border-[1.5px] border-[var(--p-border)] flex flex-col animate-plimi-slide self-end md:self-center overflow-hidden"
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 40px 80px -30px var(--p-shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToolShell plugin={plugin} onClose={handleClose} dark={dark} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/pages/ToolsPage.tsx
Normal file
153
src/pages/ToolsPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
||||||
|
import type { UnknownPlimiPlugin } from "../core/plugins/plugin-types";
|
||||||
|
import { useTheme } from "../app/useTheme";
|
||||||
|
import { PlimiSearch, CategoryChips, ToolTile, SectionHeader } from "../components/directory/DirectoryComponents";
|
||||||
|
|
||||||
|
const PLIMI_CATEGORIES = [
|
||||||
|
{ id: "all", label: "All tools" },
|
||||||
|
{ id: "developer", label: "Developer" },
|
||||||
|
{ id: "image", label: "Image" },
|
||||||
|
{ id: "text", label: "Text" },
|
||||||
|
{ id: "pdf", label: "PDF" },
|
||||||
|
{ id: "crypto", label: "Crypto" },
|
||||||
|
{ id: "privacy", label: "Privacy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ToolsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [cat, setCat] = useState("all");
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return pluginRegistry.filter(
|
||||||
|
(p) =>
|
||||||
|
(cat === "all" || p.manifest.category === cat) &&
|
||||||
|
(p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||||
|
p.manifest.description.toLowerCase().includes(q.toLowerCase()))
|
||||||
|
);
|
||||||
|
}, [q, cat]);
|
||||||
|
|
||||||
|
const safeFocusedIndex =
|
||||||
|
filtered.length === 0 ? -1 : Math.min(focusedIndex, filtered.length - 1);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
PLIMI_CATEGORIES.forEach((c) => { out[c.id] = 0; });
|
||||||
|
|
||||||
|
pluginRegistry.forEach((p) => {
|
||||||
|
const matchSearch = p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||||
|
p.manifest.description.toLowerCase().includes(q.toLowerCase());
|
||||||
|
if (!matchSearch) return;
|
||||||
|
|
||||||
|
out.all += 1;
|
||||||
|
if (out[p.manifest.category] !== undefined) {
|
||||||
|
out[p.manifest.category] += 1;
|
||||||
|
} else {
|
||||||
|
out[p.manifest.category] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
if (cat !== 'all' || q) return null;
|
||||||
|
const map: Record<string, typeof pluginRegistry> = {};
|
||||||
|
filtered.forEach((t) => {
|
||||||
|
(map[t.manifest.category] = map[t.manifest.category] || []).push(t);
|
||||||
|
});
|
||||||
|
return PLIMI_CATEGORIES.filter((c) => c.id !== 'all' && map[c.id]?.length).map((c) => ({ cat: c, tools: map[c.id] }));
|
||||||
|
}, [filtered, cat, q]);
|
||||||
|
|
||||||
|
const handleOpenTool = (plugin: UnknownPlimiPlugin) => {
|
||||||
|
navigate(`/tools/${plugin.manifest.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-7 max-w-[1200px] mx-auto pb-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1.1fr_1fr] gap-12 items-end">
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
|
||||||
|
your digital pencil case
|
||||||
|
</span>
|
||||||
|
<h1 className="m-0 font-sans text-4xl md:text-[56px] font-bold tracking-tight leading-[1.02] text-[var(--p-text)] text-balance">
|
||||||
|
Small tools.<br />
|
||||||
|
<span className="text-[var(--p-muted)]">Big trust.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
||||||
|
{pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No upload. No account. No server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlimiSearch
|
||||||
|
value={q}
|
||||||
|
onChange={(nextQuery) => {
|
||||||
|
setQ(nextQuery);
|
||||||
|
setFocusedIndex(0);
|
||||||
|
}}
|
||||||
|
count={filtered.length}
|
||||||
|
total={pluginRegistry.length}
|
||||||
|
onArrow={(dir) => {
|
||||||
|
if (filtered.length === 0) return;
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const next = prev + dir;
|
||||||
|
if (next < 0) return filtered.length - 1;
|
||||||
|
if (next >= filtered.length) return 0;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onEnter={() => {
|
||||||
|
if (safeFocusedIndex >= 0 && safeFocusedIndex < filtered.length) {
|
||||||
|
handleOpenTool(filtered[safeFocusedIndex]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CategoryChips
|
||||||
|
active={cat}
|
||||||
|
onPick={(nextCategory) => {
|
||||||
|
setCat(nextCategory);
|
||||||
|
setFocusedIndex(0);
|
||||||
|
}}
|
||||||
|
counts={counts}
|
||||||
|
categories={PLIMI_CATEGORIES}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{grouped ? (
|
||||||
|
<div className="flex flex-col gap-7 mt-4">
|
||||||
|
{grouped.map(({ cat: c, tools }) => (
|
||||||
|
<div key={c.id} className="flex flex-col gap-3.5">
|
||||||
|
<SectionHeader catLabel={c.label} catId={c.id} n={tools.length} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{tools.map((t) => {
|
||||||
|
const flatIdx = filtered.indexOf(t);
|
||||||
|
return (
|
||||||
|
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-[var(--p-muted)] font-sans">
|
||||||
|
No tools found matching "{q}".
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||||
|
{filtered.map((t) => {
|
||||||
|
const flatIdx = filtered.indexOf(t);
|
||||||
|
return (
|
||||||
|
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/tools/base64/index.ts
Normal file
43
src/tools/base64/index.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runBase64 } from "./run";
|
||||||
|
|
||||||
|
export interface Base64Options {
|
||||||
|
mode: "encode" | "decode";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const base64Plugin: PlimiPlugin<Base64Options> = {
|
||||||
|
manifest: {
|
||||||
|
id: "base64",
|
||||||
|
name: "Base64 Encoder / Decoder",
|
||||||
|
description: "Encode or decode Base64 text locally.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["base64", "encode", "decode"],
|
||||||
|
input: { type: "text" },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: "Hello, Plimi!",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "mode",
|
||||||
|
label: "Mode",
|
||||||
|
defaultValue: "encode",
|
||||||
|
options: [
|
||||||
|
{ label: "Encode", value: "encode" },
|
||||||
|
{ label: "Decode", value: "decode" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false, // Runs synchronously since it's very fast for normal text
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runBase64,
|
||||||
|
};
|
||||||
48
src/tools/base64/run.test.ts
Normal file
48
src/tools/base64/run.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runBase64 } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Base64 Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should encode text correctly", async () => {
|
||||||
|
const result = await runBase64(
|
||||||
|
{ text: "Hello World" },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: "text", value: "SGVsbG8gV29ybGQ=" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode text correctly", async () => {
|
||||||
|
const result = await runBase64(
|
||||||
|
{ text: "SGVsbG8gV29ybGQ=" },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: "text", value: "Hello World" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty string for empty input", async () => {
|
||||||
|
const result = await runBase64(
|
||||||
|
{ text: "" },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: "text", value: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error on invalid base64 decode", async () => {
|
||||||
|
await expect(
|
||||||
|
runBase64({ text: "NotBase64!" }, { mode: "decode" }, mockContext)
|
||||||
|
).rejects.toThrow("Invalid Base64 input string or encoding error.");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/tools/base64/run.ts
Normal file
37
src/tools/base64/run.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { Base64Options } from "./index";
|
||||||
|
|
||||||
|
export async function runBase64(
|
||||||
|
input: ToolInput,
|
||||||
|
options: Base64Options,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = input.text ?? "";
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 50, message: "Processing..." });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultValue =
|
||||||
|
options.mode === "encode"
|
||||||
|
? btoa(unescape(encodeURIComponent(text)))
|
||||||
|
: decodeURIComponent(escape(atob(text)));
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: resultValue,
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error("Invalid Base64 input string or encoding error.", { cause });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/tools/color-converter/index.ts
Normal file
44
src/tools/color-converter/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runColorConverter } from "./run";
|
||||||
|
|
||||||
|
export interface ColorConverterOptions {
|
||||||
|
format: "hex" | "rgb" | "hsl";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const colorConverterPlugin: PlimiPlugin<ColorConverterOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "dev-color",
|
||||||
|
name: "Color Converter",
|
||||||
|
description: "Convert colors between HEX, RGB, and HSL formats.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["color", "hex", "rgb", "hsl", "converter"],
|
||||||
|
input: { type: "text", placeholder: "#ff0000 or rgb(255,0,0) or hsl(0,100%,50%)", multiline: false },
|
||||||
|
output: { type: "json" },
|
||||||
|
example: "#3b82f6",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "format",
|
||||||
|
label: "Output Format",
|
||||||
|
defaultValue: "hex",
|
||||||
|
options: [
|
||||||
|
{ label: "HEX", value: "hex" },
|
||||||
|
{ label: "RGB", value: "rgb" },
|
||||||
|
{ label: "HSL", value: "hsl" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runColorConverter,
|
||||||
|
};
|
||||||
93
src/tools/color-converter/run.test.ts
Normal file
93
src/tools/color-converter/run.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runColorConverter } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Color Converter", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should parse a 6-digit hex color and return all formats", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "#ff0000" },
|
||||||
|
{ format: "hex" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("json");
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.hex).toBe("#ff0000");
|
||||||
|
expect(value.rgb).toBe("rgb(255, 0, 0)");
|
||||||
|
expect(value.channels).toEqual({ r: 255, g: 0, b: 0 });
|
||||||
|
expect(value.primary).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse an RGB color string", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "rgb(0, 128, 255)" },
|
||||||
|
{ format: "hex" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.channels).toEqual({ r: 0, g: 128, b: 255 });
|
||||||
|
expect(value.hex).toBe("#0080ff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse an HSL color string", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "hsl(0, 100%, 50%)" },
|
||||||
|
{ format: "rgb" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
const channels = value.channels as Record<string, unknown>;
|
||||||
|
expect(channels.r).toBe(255);
|
||||||
|
expect(channels.g).toBe(0);
|
||||||
|
expect(channels.b).toBe(0);
|
||||||
|
expect(value.primary).toBe("rgb(255, 0, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse a 3-digit shorthand hex", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "#f00" },
|
||||||
|
{ format: "hex" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.hex).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse hex without hash prefix", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "00ff00" },
|
||||||
|
{ format: "rgb" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.channels).toEqual({ r: 0, g: 255, b: 0 });
|
||||||
|
expect(value.primary).toBe("rgb(0, 255, 0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return primary in hsl format when requested", async () => {
|
||||||
|
const result = await runColorConverter(
|
||||||
|
{ text: "#ffffff" },
|
||||||
|
{ format: "hsl" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.primary).toContain("hsl(");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on unrecognized color format", async () => {
|
||||||
|
await expect(
|
||||||
|
runColorConverter({ text: "not-a-color" }, { format: "hex" }, mockContext)
|
||||||
|
).rejects.toThrow("Unrecognized color format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on empty input", async () => {
|
||||||
|
await expect(
|
||||||
|
runColorConverter({ text: "" }, { format: "hex" }, mockContext)
|
||||||
|
).rejects.toThrow("No color value provided");
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/tools/color-converter/run.ts
Normal file
151
src/tools/color-converter/run.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ColorConverterOptions } from "./index";
|
||||||
|
|
||||||
|
interface RGB { r: number; g: number; b: number; }
|
||||||
|
|
||||||
|
function parseHex(input: string): RGB | null {
|
||||||
|
const match = input.match(/^#?([0-9a-f]{3,8})$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
let hex = match[1];
|
||||||
|
if (hex.length === 3) {
|
||||||
|
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||||
|
}
|
||||||
|
if (hex.length === 4) {
|
||||||
|
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||||
|
}
|
||||||
|
if (hex.length >= 6) {
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.substring(0, 2), 16),
|
||||||
|
g: parseInt(hex.substring(2, 4), 16),
|
||||||
|
b: parseInt(hex.substring(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgb(input: string): RGB | null {
|
||||||
|
const match = input.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
r: Math.min(255, parseInt(match[1], 10)),
|
||||||
|
g: Math.min(255, parseInt(match[2], 10)),
|
||||||
|
b: Math.min(255, parseInt(match[3], 10)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHsl(input: string): RGB | null {
|
||||||
|
const match = input.match(/hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?/);
|
||||||
|
if (!match) return null;
|
||||||
|
const h = parseInt(match[1], 10) / 360;
|
||||||
|
const s = parseInt(match[2], 10) / 100;
|
||||||
|
const l = parseInt(match[3], 10) / 100;
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
const v = Math.round(l * 255);
|
||||||
|
return { r: v, g: v, b: v };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hue2rgb = (p: number, q: number, t: number): number => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||||
|
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||||
|
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColor(input: string): RGB | null {
|
||||||
|
const trimmed = input.trim().toLowerCase();
|
||||||
|
return parseHex(trimmed) || parseRgb(trimmed) || parseHsl(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(rgb: RGB): string {
|
||||||
|
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||||
|
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsl(rgb: RGB): { h: number; s: number; l: number } {
|
||||||
|
const r = rgb.r / 255;
|
||||||
|
const g = rgb.g / 255;
|
||||||
|
const b = rgb.b / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||||
|
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
|
||||||
|
const h =
|
||||||
|
max === r
|
||||||
|
? ((g - b) / d + (g < b ? 6 : 0)) / 6
|
||||||
|
: max === g
|
||||||
|
? ((b - r) / d + 2) / 6
|
||||||
|
: ((r - g) / d + 4) / 6;
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runColorConverter(
|
||||||
|
input: ToolInput,
|
||||||
|
options: ColorConverterOptions,
|
||||||
|
context?: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
void context;
|
||||||
|
|
||||||
|
const text = input.text || "";
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error("No color value provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = parseColor(text);
|
||||||
|
if (!rgb) {
|
||||||
|
throw new Error("Unrecognized color format. Use HEX (#ff0000), RGB (rgb(255,0,0)), or HSL (hsl(0,100%,50%)).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = rgbToHex(rgb);
|
||||||
|
const hsl = rgbToHsl(rgb);
|
||||||
|
|
||||||
|
let primary: string;
|
||||||
|
switch (options.format) {
|
||||||
|
case "hex":
|
||||||
|
primary = hex;
|
||||||
|
break;
|
||||||
|
case "rgb":
|
||||||
|
primary = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
||||||
|
break;
|
||||||
|
case "hsl":
|
||||||
|
primary = `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
primary = hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "json",
|
||||||
|
value: {
|
||||||
|
hex,
|
||||||
|
rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
|
||||||
|
hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
|
||||||
|
primary,
|
||||||
|
channels: { r: rgb.r, g: rgb.g, b: rgb.b },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/tools/csv-tools/index.ts
Normal file
75
src/tools/csv-tools/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runCsvTools } from "./run";
|
||||||
|
|
||||||
|
export interface CsvToolsOptions {
|
||||||
|
mode: "csv-to-json" | "json-to-csv";
|
||||||
|
delimiter: "," | ";" | "\t";
|
||||||
|
hasHeaderRow: boolean;
|
||||||
|
prettyJson: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const csvToolsPlugin: PlimiPlugin<CsvToolsOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "csv-tools",
|
||||||
|
name: "CSV <-> JSON Converter",
|
||||||
|
description: "Convert CSV text to JSON objects, or convert JSON array of objects/arrays to CSV.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["csv", "json", "converter", "parser", "format"],
|
||||||
|
input: {
|
||||||
|
type: "text",
|
||||||
|
label: "Input Content",
|
||||||
|
placeholder: "Paste CSV text or JSON array here...",
|
||||||
|
multiline: true,
|
||||||
|
rows: 10,
|
||||||
|
},
|
||||||
|
output: { type: "text" },
|
||||||
|
example: "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "mode",
|
||||||
|
label: "Conversion Mode",
|
||||||
|
defaultValue: "csv-to-json",
|
||||||
|
options: [
|
||||||
|
{ label: "CSV to JSON", value: "csv-to-json" },
|
||||||
|
{ label: "JSON to CSV", value: "json-to-csv" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "delimiter",
|
||||||
|
label: "Delimiter / Separator",
|
||||||
|
defaultValue: ",",
|
||||||
|
options: [
|
||||||
|
{ label: "Comma (,)", value: "," },
|
||||||
|
{ label: "Semicolon (;)", value: ";" },
|
||||||
|
{ label: "Tab (\\t)", value: "\t" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
key: "hasHeaderRow",
|
||||||
|
label: "First row is header (CSV -> JSON)",
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
key: "prettyJson",
|
||||||
|
label: "Pretty Print JSON",
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runCsvTools,
|
||||||
|
};
|
||||||
138
src/tools/csv-tools/run.test.ts
Normal file
138
src/tools/csv-tools/run.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runCsvTools } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("CSV <-> JSON Converter Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should convert simple CSV to JSON with headers", async () => {
|
||||||
|
const csv = "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles";
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: csv },
|
||||||
|
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
const parsed = JSON.parse(result.value);
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{ name: "John Doe", age: "30", city: "New York" },
|
||||||
|
{ name: "Jane Smith", age: "25", city: "Los Angeles" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle commas and newlines inside quoted fields", async () => {
|
||||||
|
const csv = 'name,notes\nJohn,"Likes apples, oranges, and bananas"\nJane,"Likes reading\nand cycling"';
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: csv },
|
||||||
|
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
const parsed = JSON.parse(result.value);
|
||||||
|
expect(parsed[0].notes).toBe("Likes apples, oranges, and bananas");
|
||||||
|
expect(parsed[1].notes).toBe("Likes reading\nand cycling");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle escaped quotes inside quotes", async () => {
|
||||||
|
const csv = 'name,description\nJohn,"Known as ""The Apple King"""';
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: csv },
|
||||||
|
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
const parsed = JSON.parse(result.value);
|
||||||
|
expect(parsed[0].description).toBe('Known as "The Apple King"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support custom delimiters like Semicolon", async () => {
|
||||||
|
const csv = "name;age\nJohn Doe;30";
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: csv },
|
||||||
|
{ mode: "csv-to-json", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
const parsed = JSON.parse(result.value);
|
||||||
|
expect(parsed[0]).toEqual({ name: "John Doe", age: "30" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse without header rows", async () => {
|
||||||
|
const csv = "John,30\nJane,25";
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: csv },
|
||||||
|
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: false, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
const parsed = JSON.parse(result.value);
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
["John", "30"],
|
||||||
|
["Jane", "25"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert JSON array of objects to CSV", async () => {
|
||||||
|
const json = JSON.stringify([
|
||||||
|
{ name: "John Doe", age: 30, city: "New York" },
|
||||||
|
{ name: "Jane Smith", age: 25, city: "Los Angeles" },
|
||||||
|
]);
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: json },
|
||||||
|
{ mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
expect(result.value).toBe(
|
||||||
|
"name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert JSON array of arrays to CSV", async () => {
|
||||||
|
const json = JSON.stringify([
|
||||||
|
["John", 30],
|
||||||
|
["Jane", 25],
|
||||||
|
]);
|
||||||
|
const result = await runCsvTools(
|
||||||
|
{ text: json },
|
||||||
|
{ mode: "json-to-csv", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
if (result.type === "text") {
|
||||||
|
expect(result.value).toBe("John;30\nJane;25");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid JSON in JSON-to-CSV mode", async () => {
|
||||||
|
await expect(
|
||||||
|
runCsvTools({ text: "not-json" }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||||
|
).rejects.toThrow("Invalid input: Not a valid JSON string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for non-array JSON", async () => {
|
||||||
|
await expect(
|
||||||
|
runCsvTools({ text: '{"a": 1}' }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||||
|
).rejects.toThrow("JSON content must be an array");
|
||||||
|
});
|
||||||
|
});
|
||||||
199
src/tools/csv-tools/run.ts
Normal file
199
src/tools/csv-tools/run.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { CsvToolsOptions } from "./index";
|
||||||
|
|
||||||
|
export function parseCsv(text: string, delimiter: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let currentRow: string[] = [];
|
||||||
|
let currentField = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const nextChar = text[i + 1];
|
||||||
|
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (nextChar === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
currentField += '"';
|
||||||
|
i++; // Skip next quote
|
||||||
|
} else {
|
||||||
|
// Closing quote
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (char === delimiter) {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
currentField = "";
|
||||||
|
} else if (char === "\n" || char === "\r") {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
currentField = "";
|
||||||
|
rows.push(currentRow);
|
||||||
|
currentRow = [];
|
||||||
|
|
||||||
|
if (char === "\r" && nextChar === "\n") {
|
||||||
|
i++; // Skip \n in \r\n
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push final field/row if anything remains
|
||||||
|
if (currentField !== "" || currentRow.length > 0) {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out completely empty trailing row (e.g. from file ending with a newline)
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const lastRow = rows[rows.length - 1];
|
||||||
|
if (lastRow.length === 1 && lastRow[0] === "") {
|
||||||
|
rows.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(val: unknown, delimiter: string): string {
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
const str = String(val);
|
||||||
|
const needsQuotes =
|
||||||
|
str.includes(delimiter) ||
|
||||||
|
str.includes('"') ||
|
||||||
|
str.includes("\n") ||
|
||||||
|
str.includes("\r");
|
||||||
|
|
||||||
|
if (needsQuotes) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCsvTools(
|
||||||
|
input: ToolInput,
|
||||||
|
options: CsvToolsOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = (input.text ?? "").trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("Please enter input text to convert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mode, delimiter, hasHeaderRow, prettyJson } = options;
|
||||||
|
|
||||||
|
if (mode === "csv-to-json") {
|
||||||
|
context.reportProgress({ percentage: 20, message: "Parsing CSV..." });
|
||||||
|
const parsedRows = parseCsv(text, delimiter);
|
||||||
|
|
||||||
|
if (parsedRows.length === 0) {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: "[]",
|
||||||
|
language: "json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 60, message: "Structuring JSON..." });
|
||||||
|
|
||||||
|
if (hasHeaderRow) {
|
||||||
|
const headers = parsedRows[0].map(h => h.trim());
|
||||||
|
const objects: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < parsedRows.length; i++) {
|
||||||
|
const row = parsedRows[i];
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (let j = 0; j < headers.length; j++) {
|
||||||
|
obj[headers[j]] = row[j] ?? "";
|
||||||
|
}
|
||||||
|
objects.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: JSON.stringify(objects, null, prettyJson ? 2 : undefined),
|
||||||
|
language: "json",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: JSON.stringify(parsedRows, null, prettyJson ? 2 : undefined),
|
||||||
|
language: "json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// json-to-csv mode
|
||||||
|
context.reportProgress({ percentage: 25, message: "Parsing JSON..." });
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Invalid input: Not a valid JSON string. JSON to CSV mode requires a valid JSON array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error("Invalid input: JSON content must be an array of objects or an array of arrays.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: "",
|
||||||
|
language: "plain",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 65, message: "Serializing to CSV..." });
|
||||||
|
|
||||||
|
const firstItem = data[0];
|
||||||
|
|
||||||
|
if (Array.isArray(firstItem)) {
|
||||||
|
// Array of arrays
|
||||||
|
const csvLines = (data as unknown[][]).map(row =>
|
||||||
|
row.map(cell => escapeCsvField(cell, delimiter)).join(delimiter)
|
||||||
|
);
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: csvLines.join("\n"),
|
||||||
|
language: "plain",
|
||||||
|
};
|
||||||
|
} else if (typeof firstItem === "object" && firstItem !== null) {
|
||||||
|
// Array of objects
|
||||||
|
// Collect unique keys across all objects to ensure all properties are included
|
||||||
|
const keysSet = new Set<string>();
|
||||||
|
data.forEach(item => {
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
Object.keys(item).forEach(k => keysSet.add(k));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const keys = Array.from(keysSet);
|
||||||
|
|
||||||
|
const headerLine = keys.map(k => escapeCsvField(k, delimiter)).join(delimiter);
|
||||||
|
const csvLines = (data as Record<string, unknown>[]).map(item =>
|
||||||
|
keys.map(key => escapeCsvField(item[key], delimiter)).join(delimiter)
|
||||||
|
);
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: [headerLine, ...csvLines].join("\n"),
|
||||||
|
language: "plain",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid input: Array elements must be either objects or arrays.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/tools/exif-scrubber/index.ts
Normal file
27
src/tools/exif-scrubber/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runExifScrubber } from "./run";
|
||||||
|
|
||||||
|
export const exifScrubberPlugin: PlimiPlugin = {
|
||||||
|
manifest: {
|
||||||
|
id: "exif-scrubber",
|
||||||
|
name: "EXIF Scrubber",
|
||||||
|
description: "Instantly strip all GPS coordinates, camera logs, and timestamps from your photos locally.",
|
||||||
|
category: "privacy",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["privacy", "metadata", "exif", "gps", "strip", "clear", "photo"],
|
||||||
|
input: {
|
||||||
|
type: "files",
|
||||||
|
accept: ["image/jpeg", "image/png", "image/webp"],
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
output: { type: "files" },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: true,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runExifScrubber,
|
||||||
|
};
|
||||||
25
src/tools/exif-scrubber/run.test.ts
Normal file
25
src/tools/exif-scrubber/run.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runExifScrubber } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("EXIF Scrubber Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should throw error if no files provided", async () => {
|
||||||
|
await expect(
|
||||||
|
runExifScrubber(
|
||||||
|
{ files: [] },
|
||||||
|
null,
|
||||||
|
mockContext
|
||||||
|
)
|
||||||
|
).rejects.toThrow("No files uploaded for scrubbing.");
|
||||||
|
});
|
||||||
|
});
|
||||||
103
src/tools/exif-scrubber/run.ts
Normal file
103
src/tools/exif-scrubber/run.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error("Failed to load image."));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
mimeType: string,
|
||||||
|
quality = 0.95
|
||||||
|
): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to export image from canvas."));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runExifScrubber(
|
||||||
|
input: ToolInput,
|
||||||
|
_options: unknown,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||||
|
throw new Error("No files uploaded for scrubbing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFiles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (context.signal?.aborted) {
|
||||||
|
throw new DOMException("Operation cancelled", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[i];
|
||||||
|
context.reportProgress({
|
||||||
|
percentage: (i / files.length) * 100,
|
||||||
|
message: `Scrubbing metadata for ${file.name} (${i + 1}/${files.length})...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await loadImage(url);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Could not acquire 2D canvas context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing to canvas discards EXIF headers
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const mimeType = file.type || "image/jpeg";
|
||||||
|
const blob = await canvasToBlob(canvas, mimeType);
|
||||||
|
|
||||||
|
// Create new clean file name
|
||||||
|
const dotIdx = file.name.lastIndexOf(".");
|
||||||
|
const name = dotIdx !== -1
|
||||||
|
? `${file.name.substring(0, dotIdx)}_scrubbed${file.name.substring(dotIdx)}`
|
||||||
|
: `${file.name}_scrubbed.jpg`;
|
||||||
|
|
||||||
|
outFiles.push({
|
||||||
|
name: name,
|
||||||
|
mimeType: mimeType,
|
||||||
|
blob: blob,
|
||||||
|
sizeAfter: blob.size,
|
||||||
|
sizeBefore: file.size,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error scrubbing file ${file.name}:`, err);
|
||||||
|
// Fallback or rethrow depending on strict requirements; we rethrow for safety
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Metadata scrubbed successfully!" });
|
||||||
|
return {
|
||||||
|
type: "files",
|
||||||
|
files: outFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/tools/file-checksum-verifier/index.ts
Normal file
60
src/tools/file-checksum-verifier/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runFileChecksumVerifier } from "./run";
|
||||||
|
|
||||||
|
export interface FileChecksumVerifierOptions {
|
||||||
|
algorithm: "SHA-256" | "SHA-512";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileChecksumVerifierPlugin: PlimiPlugin<FileChecksumVerifierOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "file-checksum-verifier",
|
||||||
|
name: "File Checksum Verifier",
|
||||||
|
description: "Calculate cryptographic SHA-256 or SHA-512 checksums of local files entirely offline in the browser.",
|
||||||
|
category: "crypto",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["checksum", "verifier", "hash", "sha256", "sha512", "file"],
|
||||||
|
input: {
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "files",
|
||||||
|
key: "files",
|
||||||
|
label: "Select File(s)",
|
||||||
|
multiple: true,
|
||||||
|
description: "Select one or more files to calculate checksums for.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
key: "expectedChecksum",
|
||||||
|
label: "Expected Checksum (Optional)",
|
||||||
|
placeholder: "Paste expected hash to compare against...",
|
||||||
|
description: "Case-insensitive. Will be compared against computed hashes.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
output: { type: "table" },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "algorithm",
|
||||||
|
label: "Hash Algorithm",
|
||||||
|
defaultValue: "SHA-256",
|
||||||
|
options: [
|
||||||
|
{ label: "SHA-256", value: "SHA-256" },
|
||||||
|
{ label: "SHA-512", value: "SHA-512" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runFileChecksumVerifier,
|
||||||
|
};
|
||||||
87
src/tools/file-checksum-verifier/run.test.ts
Normal file
87
src/tools/file-checksum-verifier/run.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runFileChecksumVerifier } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("File Checksum Verifier Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const textFile = new File(
|
||||||
|
[new TextEncoder().encode("hello world")],
|
||||||
|
"test.txt",
|
||||||
|
{ type: "text/plain" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const helloWorldSha256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||||
|
|
||||||
|
it("should calculate SHA-256 checksum and verify successfully", async () => {
|
||||||
|
const result = await runFileChecksumVerifier(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
files: { files: [textFile] },
|
||||||
|
expectedChecksum: { text: helloWorldSha256 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ algorithm: "SHA-256" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.type).toBe("table");
|
||||||
|
if (result.type === "table") {
|
||||||
|
expect(result.columns).toEqual(["File Name", "Size", "Computed Hash", "Expected Hash", "Status"]);
|
||||||
|
expect(result.rows).toHaveLength(1);
|
||||||
|
expect(result.rows[0][0]).toBe("test.txt");
|
||||||
|
expect(result.rows[0][1]).toBe("11 B");
|
||||||
|
expect(result.rows[0][2]).toBe(helloWorldSha256);
|
||||||
|
expect(result.rows[0][3]).toBe(helloWorldSha256);
|
||||||
|
expect(result.rows[0][4]).toBe("✅ Match");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect checksum mismatch", async () => {
|
||||||
|
const result = await runFileChecksumVerifier(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
files: { files: [textFile] },
|
||||||
|
expectedChecksum: { text: "wrongchecksum" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ algorithm: "SHA-256" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.type === "table") {
|
||||||
|
expect(result.rows[0][4]).toBe("❌ Mismatch");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should run with no expected checksum and mark status as N/A", async () => {
|
||||||
|
const result = await runFileChecksumVerifier(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
files: { files: [textFile] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ algorithm: "SHA-256" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.type === "table") {
|
||||||
|
expect(result.rows[0][3]).toBe("(none)");
|
||||||
|
expect(result.rows[0][4]).toBe("N/A");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if no files are supplied", async () => {
|
||||||
|
await expect(
|
||||||
|
runFileChecksumVerifier({}, { algorithm: "SHA-256" }, mockContext)
|
||||||
|
).rejects.toThrow("Please select at least one file to verify.");
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/tools/file-checksum-verifier/run.ts
Normal file
82
src/tools/file-checksum-verifier/run.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { getFilesInput, getTextInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { FileChecksumVerifierOptions } from "./index";
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runFileChecksumVerifier(
|
||||||
|
input: ToolInput,
|
||||||
|
options: FileChecksumVerifierOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const files = getFilesInput(input, "files");
|
||||||
|
const expectedChecksum = getTextInput(input, "expectedChecksum").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error("Please select at least one file to verify.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { algorithm } = options;
|
||||||
|
const rows: Array<[string, string, string, string, string]> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const fileNum = i + 1;
|
||||||
|
const totalFiles = files.length;
|
||||||
|
|
||||||
|
context.reportProgress({
|
||||||
|
percentage: Math.round(((i) / totalFiles) * 100),
|
||||||
|
message: `Reading ${file.name} (${fileNum}/${totalFiles})...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let arrayBuffer: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
arrayBuffer = await file.arrayBuffer();
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Failed to read file "${file.name}": ${err.message ?? err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({
|
||||||
|
percentage: Math.round(((i + 0.5) / totalFiles) * 100),
|
||||||
|
message: `Computing ${algorithm} for ${file.name}...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let hashHex = "";
|
||||||
|
try {
|
||||||
|
const hashBuffer = await crypto.subtle.digest(algorithm, arrayBuffer);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Failed to compute hash for file "${file.name}": ${err.message ?? err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchStatus = "N/A";
|
||||||
|
if (expectedChecksum) {
|
||||||
|
matchStatus = hashHex === expectedChecksum ? "✅ Match" : "❌ Mismatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
file.name,
|
||||||
|
formatSize(file.size),
|
||||||
|
hashHex,
|
||||||
|
expectedChecksum || "(none)",
|
||||||
|
matchStatus,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "table",
|
||||||
|
columns: ["File Name", "Size", "Computed Hash", "Expected Hash", "Status"],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
src/tools/hash-generator/index.ts
Normal file
56
src/tools/hash-generator/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runHashGenerator } from "./run";
|
||||||
|
|
||||||
|
export interface HashOptions {
|
||||||
|
algorithm: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
|
||||||
|
output: "hex" | "base64";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hashGeneratorPlugin: PlimiPlugin<HashOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "crypto-hash",
|
||||||
|
name: "Hash Generator",
|
||||||
|
description: "Generate cryptographic hashes securely in your browser.",
|
||||||
|
category: "crypto",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["hash", "sha256", "crypto", "digest"],
|
||||||
|
input: { type: "text" },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: "Hello, Plimi!",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "algorithm",
|
||||||
|
label: "Algorithm",
|
||||||
|
defaultValue: "SHA-256",
|
||||||
|
options: [
|
||||||
|
{ label: "SHA-1", value: "SHA-1" },
|
||||||
|
{ label: "SHA-256", value: "SHA-256" },
|
||||||
|
{ label: "SHA-384", value: "SHA-384" },
|
||||||
|
{ label: "SHA-512", value: "SHA-512" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "output",
|
||||||
|
label: "Output Format",
|
||||||
|
defaultValue: "hex",
|
||||||
|
options: [
|
||||||
|
{ label: "Hex", value: "hex" },
|
||||||
|
{ label: "Base64", value: "base64" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runHashGenerator,
|
||||||
|
};
|
||||||
33
src/tools/hash-generator/run.ts
Normal file
33
src/tools/hash-generator/run.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { HashOptions } from "./index";
|
||||||
|
|
||||||
|
export async function runHashGenerator(
|
||||||
|
input: ToolInput,
|
||||||
|
options: HashOptions
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = input.text || "";
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("No text provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(text);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest(options.algorithm, data);
|
||||||
|
|
||||||
|
let outputStr = "";
|
||||||
|
if (options.output === "hex") {
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
outputStr = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
} else if (options.output === "base64") {
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const binStr = String.fromCharCode(...hashArray);
|
||||||
|
outputStr = btoa(binStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: outputStr,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/tools/html-entity/index.ts
Normal file
43
src/tools/html-entity/index.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runHtmlEntity } from "./run";
|
||||||
|
|
||||||
|
export interface HtmlEntityOptions {
|
||||||
|
mode: "encode" | "decode";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const htmlEntityPlugin: PlimiPlugin<HtmlEntityOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "dev-htmlentity",
|
||||||
|
name: "HTML Entity Encoder",
|
||||||
|
description: "Encode special characters to HTML entities or decode them back.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["html", "entity", "encode", "decode", "escape"],
|
||||||
|
input: { type: "text", placeholder: "Enter text with < > & \" characters..." },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: '<div class="hero">Hello & "World"</div>',
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "mode",
|
||||||
|
label: "Mode",
|
||||||
|
defaultValue: "encode",
|
||||||
|
options: [
|
||||||
|
{ label: "Encode", value: "encode" },
|
||||||
|
{ label: "Decode", value: "decode" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runHtmlEntity,
|
||||||
|
};
|
||||||
109
src/tools/html-entity/run.test.ts
Normal file
109
src/tools/html-entity/run.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runHtmlEntity } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("HTML Entity Encoder/Decoder", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should encode basic HTML special characters", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: '<div class="test">Hello & World</div>' },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toBe("<div class="test">Hello & World</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode basic HTML entities", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "<div>Hello & World</div>" },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toBe("<div>Hello & World</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encode special symbols", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "Price: 10€ © 2024" },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("€");
|
||||||
|
expect(value).toContain("©");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode special symbol entities", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "© 2024 — All rights reserved" },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("©");
|
||||||
|
expect(value).toContain("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode numeric character references (decimal)", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "ABC" },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toBe("ABC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode numeric character references (hex)", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "ABC" },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toBe("ABC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encode single quotes", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "it's a test" },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty string for empty input", async () => {
|
||||||
|
const result = await runHtmlEntity(
|
||||||
|
{ text: "" },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be reversible: encode then decode gives original", async () => {
|
||||||
|
const original = '<p>Hello & "World"</p>';
|
||||||
|
const encoded = await runHtmlEntity(
|
||||||
|
{ text: original },
|
||||||
|
{ mode: "encode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const decoded = await runHtmlEntity(
|
||||||
|
{ text: (encoded as { type: "text"; value: string }).value },
|
||||||
|
{ mode: "decode" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect((decoded as { type: "text"; value: string }).value).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/tools/html-entity/run.ts
Normal file
82
src/tools/html-entity/run.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { HtmlEntityOptions } from "./index";
|
||||||
|
|
||||||
|
const ENTITY_MAP: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
"©": "©",
|
||||||
|
"®": "®",
|
||||||
|
"™": "™",
|
||||||
|
"—": "—",
|
||||||
|
"–": "–",
|
||||||
|
"«": "«",
|
||||||
|
"»": "»",
|
||||||
|
"°": "°",
|
||||||
|
"±": "±",
|
||||||
|
"×": "×",
|
||||||
|
"÷": "÷",
|
||||||
|
"€": "€",
|
||||||
|
"£": "£",
|
||||||
|
"¥": "¥",
|
||||||
|
"¢": "¢",
|
||||||
|
"§": "§",
|
||||||
|
"¶": "¶",
|
||||||
|
"•": "•",
|
||||||
|
"…": "…",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVERSE_ENTITY_MAP: Record<string, string> = {};
|
||||||
|
for (const [char, entity] of Object.entries(ENTITY_MAP)) {
|
||||||
|
REVERSE_ENTITY_MAP[entity] = char;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeHtmlEntities(text: string): string {
|
||||||
|
return text.replace(/[&<>"']|[©®™—–«»°±×÷€£¥¢§¶•…]/g, (char) => {
|
||||||
|
return ENTITY_MAP[char] || char;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
for (const [entity, char] of Object.entries(REVERSE_ENTITY_MAP)) {
|
||||||
|
result = result.replaceAll(entity, char);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.replace(/&#(\d+);/g, (_, code) => {
|
||||||
|
return String.fromCharCode(parseInt(code, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
result = result.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => {
|
||||||
|
return String.fromCharCode(parseInt(code, 16));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHtmlEntity(
|
||||||
|
input: ToolInput,
|
||||||
|
options: HtmlEntityOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = input.text ?? "";
|
||||||
|
if (!text) {
|
||||||
|
return { type: "text", value: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 50, message: "Processing..." });
|
||||||
|
|
||||||
|
const result = options.mode === "encode" ? encodeHtmlEntities(text) : decodeHtmlEntities(text);
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
780
src/tools/image-editor/ImageEditorUi.tsx
Normal file
780
src/tools/image-editor/ImageEditorUi.tsx
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type WheelEvent as ReactWheelEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Canvas,
|
||||||
|
Circle,
|
||||||
|
FabricImage,
|
||||||
|
Line,
|
||||||
|
PencilBrush,
|
||||||
|
Rect,
|
||||||
|
Textbox,
|
||||||
|
Triangle,
|
||||||
|
type FabricObject,
|
||||||
|
} from "fabric";
|
||||||
|
import type { ToolUiProps } from "../../core/plugins/plugin-types";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Dropzone } from "../../components/ui/Dropzone";
|
||||||
|
import { Select } from "../../components/ui/Select";
|
||||||
|
import { Slider } from "../../components/ui/Slider";
|
||||||
|
import { ToolResultPanel } from "../../components/tool/ToolResultPanel";
|
||||||
|
import { useToolExecution } from "../../components/tool/useToolExecution";
|
||||||
|
import type { ImageEditorOptions } from "./index";
|
||||||
|
|
||||||
|
type ToolMode = "select" | "draw" | "pan";
|
||||||
|
type ShapeKind = "rect" | "circle" | "triangle" | "line";
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
selectedType: string;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
fontSize: number;
|
||||||
|
brushWidth: number;
|
||||||
|
mode: ToolMode;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerRow {
|
||||||
|
object: FabricObject;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WIDTH = 960;
|
||||||
|
const DEFAULT_HEIGHT = 640;
|
||||||
|
const HISTORY_LIMIT = 40;
|
||||||
|
|
||||||
|
const FONT_OPTIONS = [
|
||||||
|
{ label: "Inter", value: "Inter, system-ui, sans-serif" },
|
||||||
|
{ label: "Georgia", value: "Georgia, serif" },
|
||||||
|
{ label: "Mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" },
|
||||||
|
{ label: "Arial", value: "Arial, sans-serif" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMAT_OPTIONS = [
|
||||||
|
{ label: "PNG", value: "image/png" },
|
||||||
|
{ label: "JPEG", value: "image/jpeg" },
|
||||||
|
{ label: "WebP", value: "image/webp" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function objectType(object: FabricObject | undefined): string {
|
||||||
|
if (!object) return "None";
|
||||||
|
if (object.type === "textbox" || object.type === "i-text") return "Text";
|
||||||
|
if (object.type === "path") return "Drawing";
|
||||||
|
return object.type ? object.type.charAt(0).toUpperCase() + object.type.slice(1) : "Object";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useElementSize<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
setSize({
|
||||||
|
width: entry.contentRect.width,
|
||||||
|
height: entry.contentRect.height,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(ref.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ref, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageEditorUi({
|
||||||
|
plugin,
|
||||||
|
}: ToolUiProps<ImageEditorOptions>) {
|
||||||
|
const canvasElRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const canvasRef = useRef<Canvas | null>(null);
|
||||||
|
const historyRef = useRef<string[]>([]);
|
||||||
|
const historyIndexRef = useRef(-1);
|
||||||
|
const restoringRef = useRef(false);
|
||||||
|
const sourceFileRef = useRef<File | undefined>(undefined);
|
||||||
|
const isPanningRef = useRef(false);
|
||||||
|
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
||||||
|
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
|
||||||
|
|
||||||
|
const [sourceFile, setSourceFile] = useState<File | undefined>();
|
||||||
|
const [canvasReady, setCanvasReady] = useState(false);
|
||||||
|
const [canvasSize, setCanvasSize] = useState({
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
|
});
|
||||||
|
const [activeObject, setActiveObject] = useState<FabricObject | undefined>();
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
const [layerRows, setLayerRows] = useState<LayerRow[]>([]);
|
||||||
|
const [exportFormat, setExportFormat] =
|
||||||
|
useState<ImageEditorOptions["format"]>("image/png");
|
||||||
|
const [quality, setQuality] = useState(92);
|
||||||
|
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
|
||||||
|
const [state, setState] = useState<EditorState>({
|
||||||
|
selectedType: "None",
|
||||||
|
fill: "#ffffff",
|
||||||
|
stroke: "#1a1714",
|
||||||
|
fontSize: 48,
|
||||||
|
brushWidth: 8,
|
||||||
|
mode: "select",
|
||||||
|
zoom: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { run, result, isExecuting, error } = useToolExecution(plugin);
|
||||||
|
|
||||||
|
const refreshHistoryControls = useCallback(() => {
|
||||||
|
setCanUndo(historyIndexRef.current > 0);
|
||||||
|
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshLayerRows = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const rows = (canvas?.getObjects() ?? [])
|
||||||
|
.filter((object) => object.selectable !== false)
|
||||||
|
.map((object, index) => ({
|
||||||
|
object,
|
||||||
|
id: `${object.type}-${index}`,
|
||||||
|
label: `${objectType(object)} ${index + 1}`,
|
||||||
|
}))
|
||||||
|
.reverse();
|
||||||
|
setLayerRows(rows);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateActiveState = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const object = canvas?.getActiveObject();
|
||||||
|
setActiveObject(object);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedType: objectType(object),
|
||||||
|
fill: typeof object?.get("fill") === "string" ? String(object.get("fill")) : prev.fill,
|
||||||
|
stroke: typeof object?.get("stroke") === "string" ? String(object.get("stroke")) : prev.stroke,
|
||||||
|
fontSize: typeof object?.get("fontSize") === "number" ? Number(object.get("fontSize")) : prev.fontSize,
|
||||||
|
}));
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshLayerRows]);
|
||||||
|
|
||||||
|
const pushHistory = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || restoringRef.current) return;
|
||||||
|
|
||||||
|
const snapshot = JSON.stringify(canvas.toJSON());
|
||||||
|
const current = historyRef.current[historyIndexRef.current];
|
||||||
|
if (snapshot === current) return;
|
||||||
|
|
||||||
|
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||||
|
nextHistory.push(snapshot);
|
||||||
|
if (nextHistory.length > HISTORY_LIMIT) {
|
||||||
|
nextHistory.shift();
|
||||||
|
}
|
||||||
|
historyRef.current = nextHistory;
|
||||||
|
historyIndexRef.current = nextHistory.length - 1;
|
||||||
|
refreshHistoryControls();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshHistoryControls, refreshLayerRows]);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (index: number) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const snapshot = historyRef.current[index];
|
||||||
|
if (!canvas || !snapshot) return;
|
||||||
|
|
||||||
|
restoringRef.current = true;
|
||||||
|
await canvas.loadFromJSON(snapshot);
|
||||||
|
canvas.renderAll();
|
||||||
|
restoringRef.current = false;
|
||||||
|
historyIndexRef.current = index;
|
||||||
|
updateActiveState();
|
||||||
|
refreshHistoryControls();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshHistoryControls, refreshLayerRows, updateActiveState]);
|
||||||
|
|
||||||
|
const applyDisplayZoom = useCallback((
|
||||||
|
zoom: number,
|
||||||
|
size = canvasSize
|
||||||
|
) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const nextZoom = Math.max(0.12, Math.min(3, zoom));
|
||||||
|
const displayWidth = Math.max(1, Math.round(size.width * nextZoom));
|
||||||
|
const displayHeight = Math.max(1, Math.round(size.height * nextZoom));
|
||||||
|
|
||||||
|
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
canvas.setDimensions({
|
||||||
|
width: `${displayWidth}px`,
|
||||||
|
height: `${displayHeight}px`,
|
||||||
|
}, {
|
||||||
|
cssOnly: true,
|
||||||
|
});
|
||||||
|
canvas.calcOffset();
|
||||||
|
setState((prev) => ({ ...prev, zoom: nextZoom }));
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
}, [canvasSize]);
|
||||||
|
|
||||||
|
const fitCanvasToWorkspace = useCallback(() => {
|
||||||
|
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
|
||||||
|
|
||||||
|
const zoom = Math.max(0.12, Math.min(
|
||||||
|
1,
|
||||||
|
(workspaceSize.width - 56) / canvasSize.width,
|
||||||
|
(workspaceSize.height - 56) / canvasSize.height
|
||||||
|
));
|
||||||
|
|
||||||
|
applyDisplayZoom(zoom);
|
||||||
|
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
|
||||||
|
|
||||||
|
const setCanvasZoom = useCallback((zoom: number) => {
|
||||||
|
applyDisplayZoom(zoom);
|
||||||
|
}, [applyDisplayZoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasElRef.current || canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = new Canvas(canvasElRef.current, {
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
preserveObjectStacking: true,
|
||||||
|
selection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.freeDrawingBrush = new PencilBrush(canvas);
|
||||||
|
canvas.freeDrawingBrush.color = "#1a1714";
|
||||||
|
canvas.freeDrawingBrush.width = 8;
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
|
||||||
|
const handleModified = () => {
|
||||||
|
updateActiveState();
|
||||||
|
pushHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.on("selection:created", updateActiveState);
|
||||||
|
canvas.on("selection:updated", updateActiveState);
|
||||||
|
canvas.on("selection:cleared", updateActiveState);
|
||||||
|
canvas.on("object:modified", handleModified);
|
||||||
|
canvas.on("object:added", pushHistory);
|
||||||
|
canvas.on("object:removed", pushHistory);
|
||||||
|
canvas.on("path:created", pushHistory);
|
||||||
|
|
||||||
|
pushHistory();
|
||||||
|
setCanvasReady(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.dispose();
|
||||||
|
canvasRef.current = null;
|
||||||
|
};
|
||||||
|
}, [pushHistory, updateActiveState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fitCanvasToWorkspace();
|
||||||
|
}, [fitCanvasToWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas?.freeDrawingBrush) return;
|
||||||
|
|
||||||
|
canvas.isDrawingMode = state.mode === "draw";
|
||||||
|
canvas.selection = state.mode === "select";
|
||||||
|
canvas.skipTargetFind = state.mode === "pan";
|
||||||
|
canvas.freeDrawingBrush.color = state.stroke;
|
||||||
|
canvas.freeDrawingBrush.width = state.brushWidth;
|
||||||
|
}, [state.brushWidth, state.mode, state.stroke]);
|
||||||
|
|
||||||
|
const loadImageFile = useCallback(async (file: File) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
sourceFileRef.current = file;
|
||||||
|
setSourceFile(file);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const image = await FabricImage.fromURL(url);
|
||||||
|
const maxWidth = 1400;
|
||||||
|
const maxHeight = 1000;
|
||||||
|
const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
|
||||||
|
const width = Math.max(320, Math.round(image.width * scale));
|
||||||
|
const height = Math.max(240, Math.round(image.height * scale));
|
||||||
|
|
||||||
|
canvas.clear();
|
||||||
|
canvas.setDimensions({ width, height });
|
||||||
|
setCanvasSize({ width, height });
|
||||||
|
canvas.backgroundColor = "#ffffff";
|
||||||
|
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
|
||||||
|
image.set({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
scaleX: width / image.width,
|
||||||
|
scaleY: height / image.height,
|
||||||
|
});
|
||||||
|
canvas.add(image);
|
||||||
|
canvas.sendObjectToBack(image);
|
||||||
|
canvas.renderAll();
|
||||||
|
|
||||||
|
historyRef.current = [];
|
||||||
|
historyIndexRef.current = -1;
|
||||||
|
pushHistory();
|
||||||
|
refreshHistoryControls();
|
||||||
|
refreshLayerRows();
|
||||||
|
const zoom = Math.max(0.12, Math.min(
|
||||||
|
1,
|
||||||
|
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
|
||||||
|
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
|
||||||
|
));
|
||||||
|
applyDisplayZoom(zoom, { width, height });
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, [applyDisplayZoom, pushHistory, refreshHistoryControls, refreshLayerRows, workspaceSize.height, workspaceSize.width]);
|
||||||
|
|
||||||
|
const applyToActive = useCallback((props: Record<string, unknown>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const object = canvas?.getActiveObject();
|
||||||
|
if (!canvas || !object) return;
|
||||||
|
|
||||||
|
object.set(props);
|
||||||
|
object.setCoords();
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
pushHistory();
|
||||||
|
}, [pushHistory, updateActiveState]);
|
||||||
|
|
||||||
|
const addText = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const text = new Textbox("Edit text", {
|
||||||
|
left: 80,
|
||||||
|
top: 80,
|
||||||
|
width: 320,
|
||||||
|
fontSize: state.fontSize,
|
||||||
|
fontFamily,
|
||||||
|
fill: state.stroke,
|
||||||
|
backgroundColor: "rgba(255,255,255,0)",
|
||||||
|
});
|
||||||
|
canvas.add(text);
|
||||||
|
canvas.setActiveObject(text);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [fontFamily, refreshLayerRows, state.fontSize, state.stroke, updateActiveState]);
|
||||||
|
|
||||||
|
const addShape = useCallback((kind: ShapeKind) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
left: 110,
|
||||||
|
top: 110,
|
||||||
|
fill: kind === "line" ? "" : state.fill,
|
||||||
|
stroke: state.stroke,
|
||||||
|
strokeWidth: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const object =
|
||||||
|
kind === "rect"
|
||||||
|
? new Rect({ ...common, width: 180, height: 120, rx: 4, ry: 4 })
|
||||||
|
: kind === "circle"
|
||||||
|
? new Circle({ ...common, radius: 72 })
|
||||||
|
: kind === "triangle"
|
||||||
|
? new Triangle({ ...common, width: 160, height: 140 })
|
||||||
|
: new Line([120, 120, 320, 120], {
|
||||||
|
stroke: state.stroke,
|
||||||
|
strokeWidth: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(object);
|
||||||
|
canvas.setActiveObject(object);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshLayerRows, state.fill, state.stroke, updateActiveState]);
|
||||||
|
|
||||||
|
const deleteActive = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const objects = canvas?.getActiveObjects() ?? [];
|
||||||
|
if (!canvas || objects.length === 0) return;
|
||||||
|
|
||||||
|
objects.forEach((object) => {
|
||||||
|
if (object.selectable !== false) canvas.remove(object);
|
||||||
|
});
|
||||||
|
canvas.discardActiveObject();
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshLayerRows, updateActiveState]);
|
||||||
|
|
||||||
|
const duplicateActive = useCallback(async () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const object = canvas?.getActiveObject();
|
||||||
|
if (!canvas || !object) return;
|
||||||
|
|
||||||
|
const clone = await object.clone();
|
||||||
|
clone.set({
|
||||||
|
left: (clone.left ?? 0) + 24,
|
||||||
|
top: (clone.top ?? 0) + 24,
|
||||||
|
evented: true,
|
||||||
|
});
|
||||||
|
canvas.add(clone);
|
||||||
|
canvas.setActiveObject(clone);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshLayerRows, updateActiveState]);
|
||||||
|
|
||||||
|
const moveLayer = useCallback((direction: "forward" | "backward") => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const object = canvas?.getActiveObject();
|
||||||
|
if (!canvas || !object) return;
|
||||||
|
|
||||||
|
if (direction === "forward") {
|
||||||
|
canvas.bringObjectForward(object);
|
||||||
|
} else {
|
||||||
|
canvas.sendObjectBackwards(object);
|
||||||
|
}
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
pushHistory();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [pushHistory, refreshLayerRows]);
|
||||||
|
|
||||||
|
const clearObjects = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.getObjects().forEach((object) => {
|
||||||
|
if (object.selectable !== false) canvas.remove(object);
|
||||||
|
});
|
||||||
|
canvas.discardActiveObject();
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
refreshLayerRows();
|
||||||
|
}, [refreshLayerRows, updateActiveState]);
|
||||||
|
|
||||||
|
const exportImage = useCallback(async () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL({
|
||||||
|
format: exportFormat.replace("image/", "") as "png" | "jpeg" | "webp",
|
||||||
|
quality: quality / 100,
|
||||||
|
multiplier: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await run(
|
||||||
|
{ files: sourceFileRef.current ? [sourceFileRef.current] : undefined },
|
||||||
|
{ format: exportFormat, quality, dataUrl }
|
||||||
|
);
|
||||||
|
}, [exportFormat, quality, run]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const isTyping = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA";
|
||||||
|
if (isTyping) return;
|
||||||
|
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextCanRedo = historyIndexRef.current < historyRef.current.length - 1;
|
||||||
|
const nextCanUndo = historyIndexRef.current > 0;
|
||||||
|
if (event.shiftKey && nextCanRedo) {
|
||||||
|
void loadHistory(historyIndexRef.current + 1);
|
||||||
|
} else if (nextCanUndo) {
|
||||||
|
void loadHistory(historyIndexRef.current - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((event.key === "Delete" || event.key === "Backspace") && activeObject) {
|
||||||
|
event.preventDefault();
|
||||||
|
deleteActive();
|
||||||
|
}
|
||||||
|
}, [activeObject, deleteActive, loadHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
const handleWorkspacePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (state.mode !== "pan") return;
|
||||||
|
const workspace = workspaceRef.current;
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
isPanningRef.current = true;
|
||||||
|
panStartRef.current = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
scrollLeft: workspace.scrollLeft,
|
||||||
|
scrollTop: workspace.scrollTop,
|
||||||
|
};
|
||||||
|
workspace.setPointerCapture(event.pointerId);
|
||||||
|
}, [state.mode, workspaceRef]);
|
||||||
|
|
||||||
|
const handleWorkspacePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isPanningRef.current) return;
|
||||||
|
const workspace = workspaceRef.current;
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
const start = panStartRef.current;
|
||||||
|
workspace.scrollLeft = start.scrollLeft - (event.clientX - start.x);
|
||||||
|
workspace.scrollTop = start.scrollTop - (event.clientY - start.y);
|
||||||
|
}, [workspaceRef]);
|
||||||
|
|
||||||
|
const handleWorkspacePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isPanningRef.current) return;
|
||||||
|
isPanningRef.current = false;
|
||||||
|
workspaceRef.current?.releasePointerCapture(event.pointerId);
|
||||||
|
}, [workspaceRef]);
|
||||||
|
|
||||||
|
const handleWorkspaceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!event.ctrlKey && !event.metaKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.deltaY > 0 ? -0.08 : 0.08;
|
||||||
|
setCanvasZoom(state.zoom + delta);
|
||||||
|
}, [setCanvasZoom, state.zoom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
|
||||||
|
<Dropzone
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple={false}
|
||||||
|
maxSizeMb={25}
|
||||||
|
onFilesDrop={(files) => {
|
||||||
|
if (files[0]) void loadImageFile(files[0]);
|
||||||
|
}}
|
||||||
|
className="min-h-0 w-full p-3 sm:w-[260px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={state.mode === "select" ? "primary" : "secondary"}
|
||||||
|
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={state.mode === "draw" ? "primary" : "secondary"}
|
||||||
|
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
|
||||||
|
>
|
||||||
|
Draw
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={state.mode === "pan" ? "primary" : "secondary"}
|
||||||
|
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
|
||||||
|
>
|
||||||
|
Pan
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={addText}>Text</Button>
|
||||||
|
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
|
||||||
|
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
|
||||||
|
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
|
||||||
|
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
|
||||||
|
-
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={fitCanvasToWorkspace}
|
||||||
|
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
|
||||||
|
>
|
||||||
|
{Math.round(state.zoom * 100)}%
|
||||||
|
</button>
|
||||||
|
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
|
||||||
|
100%
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
|
<div
|
||||||
|
ref={workspaceRef}
|
||||||
|
onPointerDown={handleWorkspacePointerDown}
|
||||||
|
onPointerMove={handleWorkspacePointerMove}
|
||||||
|
onPointerUp={handleWorkspacePointerUp}
|
||||||
|
onPointerCancel={handleWorkspacePointerUp}
|
||||||
|
onWheel={handleWorkspaceWheel}
|
||||||
|
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
|
||||||
|
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-full min-w-max items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="shrink-0 border border-[var(--p-border)] bg-white shadow-[0_24px_60px_-36px_var(--p-shadow-soft)]"
|
||||||
|
style={{
|
||||||
|
width: canvasSize.width * state.zoom,
|
||||||
|
height: canvasSize.height * state.zoom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasElRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
properties
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
|
||||||
|
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
|
||||||
|
{state.selectedType}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||||
|
Fill
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={state.fill}
|
||||||
|
onChange={(event) => {
|
||||||
|
setState((prev) => ({ ...prev, fill: event.target.value }));
|
||||||
|
applyToActive({ fill: event.target.value });
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
||||||
|
Stroke / text
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={state.stroke}
|
||||||
|
onChange={(event) => {
|
||||||
|
setState((prev) => ({ ...prev, stroke: event.target.value }));
|
||||||
|
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
<Select
|
||||||
|
label="Font"
|
||||||
|
value={fontFamily}
|
||||||
|
options={FONT_OPTIONS}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFontFamily(event.target.value);
|
||||||
|
applyToActive({ fontFamily: event.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Text size"
|
||||||
|
min={12}
|
||||||
|
max={160}
|
||||||
|
value={state.fontSize}
|
||||||
|
onChange={(event) => {
|
||||||
|
const fontSize = Number(event.target.value);
|
||||||
|
setState((prev) => ({ ...prev, fontSize }));
|
||||||
|
applyToActive({ fontSize });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Brush"
|
||||||
|
min={1}
|
||||||
|
max={48}
|
||||||
|
value={state.brushWidth}
|
||||||
|
onChange={(event) => {
|
||||||
|
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
arrange
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
|
||||||
|
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
|
||||||
|
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
|
||||||
|
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
|
||||||
|
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
|
||||||
|
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={clearObjects}>
|
||||||
|
Clear editable objects
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col gap-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
layers
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
|
||||||
|
{layerRows.length === 0 ? (
|
||||||
|
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
|
||||||
|
No editable layers yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
layerRows.map((row) => (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => {
|
||||||
|
canvasRef.current?.setActiveObject(row.object);
|
||||||
|
canvasRef.current?.requestRenderAll();
|
||||||
|
updateActiveState();
|
||||||
|
}}
|
||||||
|
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
|
||||||
|
activeObject === row.object
|
||||||
|
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
|
||||||
|
: "bg-[var(--p-chip)] text-[var(--p-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-auto flex flex-col gap-3">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
||||||
|
export
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Format"
|
||||||
|
value={exportFormat}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Quality"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={quality}
|
||||||
|
onChange={(event) => setQuality(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
|
||||||
|
{isExecuting ? "Exporting..." : "Export image"}
|
||||||
|
</Button>
|
||||||
|
{sourceFile && (
|
||||||
|
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
|
||||||
|
source: {sourceFile.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="text-sm text-red-700">{error}</div>}
|
||||||
|
{result && <ToolResultPanel result={result} />}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/tools/image-editor/index.ts
Normal file
47
src/tools/image-editor/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runImageEditor } from "./run";
|
||||||
|
|
||||||
|
export interface ImageEditorOptions {
|
||||||
|
format: "image/png" | "image/jpeg" | "image/webp";
|
||||||
|
quality: number;
|
||||||
|
dataUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageEditorUi = lazy(() => import("./ImageEditorUi"));
|
||||||
|
|
||||||
|
export const imageEditorPlugin: PlimiPlugin<ImageEditorOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "image-editor",
|
||||||
|
name: "Image Editor",
|
||||||
|
description:
|
||||||
|
"Edit images with text, drawing, shapes, colors, layers, and export locally.",
|
||||||
|
category: "image",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["image", "editor", "text", "draw", "shapes", "fabric"],
|
||||||
|
input: {
|
||||||
|
type: "files",
|
||||||
|
accept: ["image/jpeg", "image/png", "image/webp"],
|
||||||
|
multiple: false,
|
||||||
|
maxSizeMb: 25,
|
||||||
|
},
|
||||||
|
output: { type: "files" },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
customUi: true,
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
permissions: {
|
||||||
|
network: "none",
|
||||||
|
fileSystem: "read-write",
|
||||||
|
clipboard: "none",
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runImageEditor,
|
||||||
|
customUi: ImageEditorUi,
|
||||||
|
};
|
||||||
60
src/tools/image-editor/run.ts
Normal file
60
src/tools/image-editor/run.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ImageEditorOptions } from "./index";
|
||||||
|
|
||||||
|
function dataUrlToBlob(dataUrl: string): Blob {
|
||||||
|
const [header, payload] = dataUrl.split(",");
|
||||||
|
const mimeMatch = header.match(/^data:(.*?);base64$/);
|
||||||
|
if (!mimeMatch || !payload) {
|
||||||
|
throw new Error("Invalid image export data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = atob(payload);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: mimeMatch[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForMime(mimeType: ImageEditorOptions["format"]): string {
|
||||||
|
if (mimeType === "image/jpeg") return "jpg";
|
||||||
|
if (mimeType === "image/webp") return "webp";
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImageEditor(
|
||||||
|
input: ToolInput,
|
||||||
|
options: ImageEditorOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
if (!options.dataUrl) {
|
||||||
|
throw new Error("No edited image is ready to export.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 60, message: "Preparing export..." });
|
||||||
|
|
||||||
|
const blob = dataUrlToBlob(options.dataUrl);
|
||||||
|
const sourceName = input.files?.[0]?.name ?? "image";
|
||||||
|
const baseName = sourceName.includes(".")
|
||||||
|
? sourceName.slice(0, sourceName.lastIndexOf("."))
|
||||||
|
: sourceName;
|
||||||
|
const name = `${baseName}_edited.${extensionForMime(options.format)}`;
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "files",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
mimeType: options.format,
|
||||||
|
blob,
|
||||||
|
sizeBefore: input.files?.[0]?.size,
|
||||||
|
sizeAfter: blob.size,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
224
src/tools/image-optimizer/ImageOptimizerUi.tsx
Normal file
224
src/tools/image-optimizer/ImageOptimizerUi.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import type { ToolUiProps } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ImageOptimizerOptions } from "./index";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Card } from "../../components/ui/Card";
|
||||||
|
import { Dropzone } from "../../components/ui/Dropzone";
|
||||||
|
import { Slider } from "../../components/ui/Slider";
|
||||||
|
import { Select } from "../../components/ui/Select";
|
||||||
|
import { useToolExecution } from "../../components/tool/useToolExecution";
|
||||||
|
|
||||||
|
const IMAGE_FORMATS = ["image/jpeg", "image/webp", "image/png"] as const;
|
||||||
|
type ImageFormat = (typeof IMAGE_FORMATS)[number];
|
||||||
|
|
||||||
|
function isImageFormat(value: string): value is ImageFormat {
|
||||||
|
return IMAGE_FORMATS.includes(value as ImageFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageOptimizerUi({
|
||||||
|
plugin,
|
||||||
|
}: ToolUiProps<ImageOptimizerOptions>) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [quality, setQuality] = useState(80);
|
||||||
|
const [format, setFormat] = useState<
|
||||||
|
"image/jpeg" | "image/webp" | "image/png"
|
||||||
|
>("image/webp");
|
||||||
|
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [previewUrls]);
|
||||||
|
|
||||||
|
const handleFilesDrop = useCallback((nextFiles: File[]) => {
|
||||||
|
setFiles(nextFiles);
|
||||||
|
setPreviewUrls(nextFiles.map((file) => URL.createObjectURL(file)));
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const handleRun = useCallback(async () => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
await run({ files }, { quality, format });
|
||||||
|
}, [files, format, quality, run]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isExecuting && files.length > 0) {
|
||||||
|
handleRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleRun, isExecuting, files]);
|
||||||
|
|
||||||
|
const downloadBlob = useCallback((blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card title="Upload Images">
|
||||||
|
<Dropzone
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
maxSizeMb={20}
|
||||||
|
onFilesDrop={handleFilesDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{previewUrls.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Previews:
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
{previewUrls.map((url, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative aspect-square rounded-lg overflow-hidden border border-gray-200"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt="Preview"
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border-red-200">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.type === "files" && (
|
||||||
|
<Card
|
||||||
|
title="Optimized Results"
|
||||||
|
className="border-green-200 shadow-md"
|
||||||
|
>
|
||||||
|
{result.files.length > 1 && (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 pb-4 border-b border-gray-100">
|
||||||
|
<span className="text-sm text-gray-500 font-medium">
|
||||||
|
Successfully optimized {result.files.length} images
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
result.files.forEach((file) => {
|
||||||
|
downloadBlob(file.blob, file.name);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full sm:w-auto shrink-0"
|
||||||
|
>
|
||||||
|
Download All ({result.files.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
||||||
|
{result.files.map((file, i) => {
|
||||||
|
const savedBytes =
|
||||||
|
(file.sizeBefore || 0) - (file.sizeAfter || 0);
|
||||||
|
const savedPercentage = file.sizeBefore
|
||||||
|
? (savedBytes / file.sizeBefore) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-gray-900 truncate" title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<div className="text-sm text-gray-500 mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="line-through">
|
||||||
|
{((file.sizeBefore || 0) / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
<span className="text-green-600 font-medium">
|
||||||
|
{((file.sizeAfter || 0) / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
|
||||||
|
-{savedPercentage.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => downloadBlob(file.blob, file.name)}
|
||||||
|
className="w-full sm:w-auto shrink-0 text-center"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card title="Settings">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Select
|
||||||
|
label="Output Format"
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (isImageFormat(e.target.value)) {
|
||||||
|
setFormat(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: "WebP (Recommended)", value: "image/webp" },
|
||||||
|
{ label: "JPEG", value: "image/jpeg" },
|
||||||
|
{ label: "PNG", value: "image/png" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
label="Quality"
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<Button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={isExecuting || files.length === 0}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isExecuting ? "Optimizing..." : "Optimize Images"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/tools/image-optimizer/index.ts
Normal file
67
src/tools/image-optimizer/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runImageOptimizer } from "./run";
|
||||||
|
|
||||||
|
export interface ImageOptimizerOptions {
|
||||||
|
quality: number;
|
||||||
|
format: "image/jpeg" | "image/webp" | "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load the custom UI to keep the main bundle small
|
||||||
|
const ImageOptimizerUi = lazy(() => import("./ImageOptimizerUi"));
|
||||||
|
|
||||||
|
export const imageOptimizerPlugin: PlimiPlugin<ImageOptimizerOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "image-optimizer",
|
||||||
|
name: "Image Optimizer",
|
||||||
|
description:
|
||||||
|
"Compress and convert images entirely in your browser using the Canvas API.",
|
||||||
|
category: "image",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["image", "compress", "resize", "webp", "jpeg"],
|
||||||
|
input: {
|
||||||
|
type: "files",
|
||||||
|
accept: ["image/jpeg", "image/png", "image/webp"],
|
||||||
|
multiple: true,
|
||||||
|
maxSizeMb: 20,
|
||||||
|
},
|
||||||
|
output: { type: "files" },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "slider",
|
||||||
|
key: "quality",
|
||||||
|
label: "Quality",
|
||||||
|
defaultValue: 80,
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "format",
|
||||||
|
label: "Output Format",
|
||||||
|
defaultValue: "image/webp",
|
||||||
|
options: [
|
||||||
|
{ label: "WebP", value: "image/webp" },
|
||||||
|
{ label: "JPEG", value: "image/jpeg" },
|
||||||
|
{ label: "PNG", value: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
customUi: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runImageOptimizer,
|
||||||
|
|
||||||
|
// Provide the custom UI
|
||||||
|
customUi: ImageOptimizerUi,
|
||||||
|
};
|
||||||
40
src/tools/image-optimizer/run.test.ts
Normal file
40
src/tools/image-optimizer/run.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runImageOptimizer } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Image Optimizer Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should throw error if no files provided", async () => {
|
||||||
|
await expect(
|
||||||
|
runImageOptimizer(
|
||||||
|
{ files: [] },
|
||||||
|
{ quality: 80, format: "image/webp" },
|
||||||
|
mockContext
|
||||||
|
)
|
||||||
|
).rejects.toThrow("No files provided for optimization.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if file is not an image", async () => {
|
||||||
|
const textFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runImageOptimizer(
|
||||||
|
{ files: [textFile] },
|
||||||
|
{ quality: 80, format: "image/webp" },
|
||||||
|
mockContext
|
||||||
|
)
|
||||||
|
).rejects.toThrow("File hello.txt is not an image.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Testing actual Canvas API compression requires a full browser environment
|
||||||
|
// or a mock canvas library in Vitest. For V1 MVP, we test the core validation logic.
|
||||||
|
});
|
||||||
108
src/tools/image-optimizer/run.ts
Normal file
108
src/tools/image-optimizer/run.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ImageOptimizerOptions } from "./index";
|
||||||
|
|
||||||
|
export async function runImageOptimizer(
|
||||||
|
input: ToolInput,
|
||||||
|
options: ImageOptimizerOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error("No files provided for optimization.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Check if it's an image
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
throw new Error(`File ${file.name} is not an image.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({
|
||||||
|
percentage: (processed / files.length) * 100,
|
||||||
|
message: `Optimizing ${file.name}...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const optimizedBlob = await optimizeImage(file, options);
|
||||||
|
|
||||||
|
let ext = "jpg";
|
||||||
|
if (options.format === "image/webp") ext = "webp";
|
||||||
|
if (options.format === "image/png") ext = "png";
|
||||||
|
|
||||||
|
const originalNameWithoutExt =
|
||||||
|
file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
|
||||||
|
const newName = `${originalNameWithoutExt}_optimized.${ext}`;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name: newName,
|
||||||
|
mimeType: options.format,
|
||||||
|
blob: optimizedBlob,
|
||||||
|
sizeBefore: file.size,
|
||||||
|
sizeAfter: optimizedBlob.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "files",
|
||||||
|
files: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeImage(
|
||||||
|
file: File,
|
||||||
|
options: ImageOptimizerOptions
|
||||||
|
): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const width = img.width;
|
||||||
|
const height = img.height;
|
||||||
|
|
||||||
|
// Optional: resize logic could go here based on max width/height options
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
return reject(new Error("Could not get 2D context"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
const quality = options.quality / 100;
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to create blob from canvas"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options.format,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Failed to load image for optimization"));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
304
src/tools/image-redactor/ImageRedactorUi.tsx
Normal file
304
src/tools/image-redactor/ImageRedactorUi.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { ToolUiProps } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ImageRedactorOptions, RedactRect } from "./index";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Card } from "../../components/ui/Card";
|
||||||
|
import { Dropzone } from "../../components/ui/Dropzone";
|
||||||
|
import { useToolExecution } from "../../components/tool/useToolExecution";
|
||||||
|
|
||||||
|
export default function ImageRedactorUi({
|
||||||
|
plugin,
|
||||||
|
}: ToolUiProps<ImageRedactorOptions>) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>("");
|
||||||
|
const [rects, setRects] = useState<RedactRect[]>([]);
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
||||||
|
const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
|
||||||
|
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewUrl) return;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const handleFilesDrop = useCallback((nextFiles: File[]) => {
|
||||||
|
setFiles(nextFiles);
|
||||||
|
setPreviewUrl(nextFiles.length > 0 ? URL.createObjectURL(nextFiles[0]) : "");
|
||||||
|
setRects([]);
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const handleClearFiles = useCallback(() => {
|
||||||
|
setFiles([]);
|
||||||
|
setPreviewUrl("");
|
||||||
|
setRects([]);
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
setImageSize({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPercentageCoords = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!containerRef.current) return { x: 0, y: 0 };
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
// Clamp to 0-100 range
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(100, x)),
|
||||||
|
y: Math.max(0, Math.min(100, y)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (result) return; // Disable drawing if result is shown
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const coords = getPercentageCoords(e);
|
||||||
|
setStartPos(coords);
|
||||||
|
setCurrentPos(coords);
|
||||||
|
setIsDrawing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
const coords = getPercentageCoords(e);
|
||||||
|
setCurrentPos(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
setIsDrawing(false);
|
||||||
|
|
||||||
|
const coords = getPercentageCoords(e);
|
||||||
|
const x = Math.min(startPos.x, coords.x);
|
||||||
|
const y = Math.min(startPos.y, coords.y);
|
||||||
|
const w = Math.abs(coords.x - startPos.x);
|
||||||
|
const h = Math.abs(coords.y - startPos.y);
|
||||||
|
|
||||||
|
// Save rectangle if it has some minimum size to avoid tiny single click boxes
|
||||||
|
if (w > 0.4 && h > 0.4) {
|
||||||
|
setRects((prev) => [...prev, { x, y, w, h }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
setRects((prev) => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setRects([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRun = useCallback(async () => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
await run({ files }, { rects, color: "#000000" });
|
||||||
|
}, [files, rects, run]);
|
||||||
|
|
||||||
|
const downloadBlob = useCallback((blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hotkeys: Ctrl+Enter to redact
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isExecuting && files.length > 0 && !result) {
|
||||||
|
handleRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [files.length, handleRun, isExecuting, result]);
|
||||||
|
|
||||||
|
// Temporary rectangle styles (while drawing)
|
||||||
|
const tempRectStyle = isDrawing
|
||||||
|
? {
|
||||||
|
left: `${Math.min(startPos.x, currentPos.x)}%`,
|
||||||
|
top: `${Math.min(startPos.y, currentPos.y)}%`,
|
||||||
|
width: `${Math.abs(currentPos.x - startPos.x)}%`,
|
||||||
|
height: `${Math.abs(currentPos.y - startPos.y)}%`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<Card title="Upload Image to Redact">
|
||||||
|
<Dropzone
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple={false}
|
||||||
|
onFilesDrop={handleFilesDrop}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card title="Redaction Canvas">
|
||||||
|
<div className="text-xs text-gray-400 mb-3">
|
||||||
|
Pointer drawing active. Click and drag to place black redaction blocks.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bounding box wrapper */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
className="relative select-none overflow-hidden cursor-crosshair border border-[var(--p-border)] rounded-lg bg-[var(--p-bg)] max-w-full flex items-center justify-center"
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Redaction source"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
className="max-w-full max-h-[60vh] object-contain pointer-events-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Draw saved rectangles */}
|
||||||
|
{rects.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute bg-black pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${r.x}%`,
|
||||||
|
top: `${r.y}%`,
|
||||||
|
width: `${r.w}%`,
|
||||||
|
height: `${r.h}%`,
|
||||||
|
boxShadow: "0 0 0 1px rgba(255, 255, 255, 0.15)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Draw active temporary rectangle */}
|
||||||
|
{tempRectStyle && (
|
||||||
|
<div
|
||||||
|
className="absolute bg-black/60 border-2 border-red-500 border-dashed pointer-events-none"
|
||||||
|
style={tempRectStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border-red-200">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.type === "files" && (
|
||||||
|
<Card
|
||||||
|
title="Redacted Result"
|
||||||
|
className="border-green-200 shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 border border-green-200 bg-green-50/50 rounded-xl">
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-green-950 truncate" title={result.files[0].name}>
|
||||||
|
{result.files[0].name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-green-700 mt-1">
|
||||||
|
Metadata flattened & redacted successfully.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => downloadBlob(result.files[0].blob, result.files[0].name)}
|
||||||
|
className="w-full sm:w-auto shrink-0"
|
||||||
|
>
|
||||||
|
Download Redacted Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card title="Controls">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-800">
|
||||||
|
File details
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 truncate">
|
||||||
|
Name: {files[0].name}
|
||||||
|
</div>
|
||||||
|
{imageSize.width > 0 && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Dimensions: {imageSize.width} × {imageSize.height} px
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Active redactions: {rects.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={rects.length === 0 || isExecuting}
|
||||||
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
Undo Last Block
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={rects.length === 0 || isExecuting}
|
||||||
|
className="w-full justify-center text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Clear All Blocks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-100 flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={isExecuting || rects.length === 0 || result !== null}
|
||||||
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
{isExecuting ? "Processing..." : "Apply Redactions"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleClearFiles}
|
||||||
|
disabled={isExecuting}
|
||||||
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
Upload Another Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/tools/image-redactor/index.ts
Normal file
45
src/tools/image-redactor/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runImageRedactor } from "./run";
|
||||||
|
|
||||||
|
export interface RedactRect {
|
||||||
|
x: number; // Absolute X coordinate in image natural pixels
|
||||||
|
y: number; // Absolute Y coordinate in image natural pixels
|
||||||
|
w: number; // Absolute width in image natural pixels
|
||||||
|
h: number; // Absolute height in image natural pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRedactorOptions {
|
||||||
|
rects: RedactRect[];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageRedactorUi = lazy(() => import("./ImageRedactorUi"));
|
||||||
|
|
||||||
|
export const imageRedactorPlugin: PlimiPlugin<ImageRedactorOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "image-redactor",
|
||||||
|
name: "Image Redactor",
|
||||||
|
description: "Safely paint black boxes over sensitive data like addresses or credit cards entirely in your browser.",
|
||||||
|
category: "privacy",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["privacy", "redact", "hide", "censor", "blur", "image"],
|
||||||
|
input: {
|
||||||
|
type: "files",
|
||||||
|
accept: ["image/jpeg", "image/png", "image/webp"],
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
output: { type: "files" },
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
customUi: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runImageRedactor,
|
||||||
|
|
||||||
|
customUi: ImageRedactorUi,
|
||||||
|
};
|
||||||
25
src/tools/image-redactor/run.test.ts
Normal file
25
src/tools/image-redactor/run.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runImageRedactor } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Image Redactor Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should throw error if no files provided", async () => {
|
||||||
|
await expect(
|
||||||
|
runImageRedactor(
|
||||||
|
{ files: [] },
|
||||||
|
{ rects: [], color: "#000000" },
|
||||||
|
mockContext
|
||||||
|
)
|
||||||
|
).rejects.toThrow("No image file uploaded.");
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/tools/image-redactor/run.ts
Normal file
106
src/tools/image-redactor/run.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { ImageRedactorOptions } from "./index";
|
||||||
|
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error("Failed to load image."));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
mimeType: string,
|
||||||
|
quality = 0.95
|
||||||
|
): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to export image from canvas."));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImageRedactor(
|
||||||
|
input: ToolInput,
|
||||||
|
options: ImageRedactorOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||||
|
throw new Error("No image file uploaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.reportProgress({ percentage: 20, message: "Loading image file..." });
|
||||||
|
const img = await loadImage(url);
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 50, message: "Drawing base canvas..." });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Could not acquire 2D canvas context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw original image
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Apply redactions
|
||||||
|
context.reportProgress({ percentage: 70, message: "Applying redaction blocks..." });
|
||||||
|
ctx.fillStyle = options.color || "#000000";
|
||||||
|
const rects = options.rects || [];
|
||||||
|
|
||||||
|
for (const rect of rects) {
|
||||||
|
const rx = (rect.x / 100) * img.naturalWidth;
|
||||||
|
const ry = (rect.y / 100) * img.naturalHeight;
|
||||||
|
const rw = (rect.w / 100) * img.naturalWidth;
|
||||||
|
const rh = (rect.h / 100) * img.naturalHeight;
|
||||||
|
ctx.fillRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 90, message: "Exporting redacted image..." });
|
||||||
|
const mimeType = file.type || "image/png";
|
||||||
|
const blob = await canvasToBlob(canvas, mimeType);
|
||||||
|
|
||||||
|
// Generate output file name
|
||||||
|
const dotIdx = file.name.lastIndexOf(".");
|
||||||
|
const name = dotIdx !== -1
|
||||||
|
? `${file.name.substring(0, dotIdx)}_redacted${file.name.substring(dotIdx)}`
|
||||||
|
: `${file.name}_redacted.png`;
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Completed!" });
|
||||||
|
return {
|
||||||
|
type: "files",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
mimeType: mimeType,
|
||||||
|
blob: blob,
|
||||||
|
sizeAfter: blob.size,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Image Redaction Error:", error);
|
||||||
|
throw error instanceof Error ? error : new Error("Unknown error occurred during image redaction");
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/tools/json-formatter/index.ts
Normal file
45
src/tools/json-formatter/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runJsonFormatter } from "./run";
|
||||||
|
|
||||||
|
export interface JsonOptions {
|
||||||
|
indent: "2 spaces" | "4 spaces" | "tabs" | "minified";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jsonFormatterPlugin: PlimiPlugin<JsonOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "dev-json",
|
||||||
|
name: "JSON Formatter",
|
||||||
|
description: "Format, minify, and validate JSON strings.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["json", "format", "minify", "validate"],
|
||||||
|
input: { type: "text" },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: '{"name":"Plimi","version":1,"tools":["base64","hash"],"offline":true}',
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "indent",
|
||||||
|
label: "Indent",
|
||||||
|
defaultValue: "2 spaces",
|
||||||
|
options: [
|
||||||
|
{ label: "2 Spaces", value: "2 spaces" },
|
||||||
|
{ label: "4 Spaces", value: "4 spaces" },
|
||||||
|
{ label: "Tabs", value: "tabs" },
|
||||||
|
{ label: "Minified", value: "minified" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runJsonFormatter,
|
||||||
|
};
|
||||||
35
src/tools/json-formatter/run.ts
Normal file
35
src/tools/json-formatter/run.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { JsonOptions } from "./index";
|
||||||
|
|
||||||
|
export async function runJsonFormatter(
|
||||||
|
input: ToolInput,
|
||||||
|
options: JsonOptions
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = input.text || "";
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("No text provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let indentSpace: string | number = 2;
|
||||||
|
if (options.indent === "4 spaces") {
|
||||||
|
indentSpace = 4;
|
||||||
|
} else if (options.indent === "tabs") {
|
||||||
|
indentSpace = "\t";
|
||||||
|
} else if (options.indent === "minified") {
|
||||||
|
indentSpace = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
const formatted = JSON.stringify(parsed, null, indentSpace);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: formatted,
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
throw new Error(`Invalid JSON: ${message}`, { cause });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/tools/jwt-decoder/index.ts
Normal file
30
src/tools/jwt-decoder/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runJwtDecoder } from "./run";
|
||||||
|
|
||||||
|
export const jwtDecoderPlugin: PlimiPlugin = {
|
||||||
|
manifest: {
|
||||||
|
id: "jwt-decoder",
|
||||||
|
name: "JWT Decoder",
|
||||||
|
description: "Decode and inspect JSON Web Tokens (JWT) locally in your browser.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["jwt", "token", "decoder", "auth", "json"],
|
||||||
|
input: {
|
||||||
|
type: "text",
|
||||||
|
label: "JWT Token",
|
||||||
|
placeholder: "Paste your JWT token here (encoded header.payload.signature)...",
|
||||||
|
multiline: true,
|
||||||
|
rows: 6,
|
||||||
|
},
|
||||||
|
output: { type: "json" },
|
||||||
|
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE4MTYyMzkwMjJ9.signature-placeholder",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runJwtDecoder,
|
||||||
|
};
|
||||||
81
src/tools/jwt-decoder/run.test.ts
Normal file
81
src/tools/jwt-decoder/run.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runJwtDecoder } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("JWT Decoder Plugin", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
|
||||||
|
// {"sub":"123","name":"John Doe","iat":1516239022} -> eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9
|
||||||
|
const validNoExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.sig";
|
||||||
|
|
||||||
|
// {"sub":"123","exp":32503680000} (Year 3000) -> eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ
|
||||||
|
const futureExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ.sig";
|
||||||
|
|
||||||
|
// {"sub":"123","exp":946684800} (Year 2000) -> eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0
|
||||||
|
const expiredExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0.sig";
|
||||||
|
|
||||||
|
it("should decode a valid JWT without exp claim", async () => {
|
||||||
|
const result = await runJwtDecoder({ text: validNoExp }, {}, mockContext);
|
||||||
|
expect(result.type).toBe("json");
|
||||||
|
if (result.type === "json") {
|
||||||
|
const data = result.value as any;
|
||||||
|
expect(data.status).toBe("valid");
|
||||||
|
expect(data.primary).toContain("Valid (No Expiration claim)");
|
||||||
|
expect(data.header.alg).toBe("HS256");
|
||||||
|
expect(data.payload.sub).toBe("123");
|
||||||
|
expect(data.issuedAt).toBe("2018-01-18T01:30:22.000Z");
|
||||||
|
expect(data.expiresAt).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode a valid JWT with future exp claim", async () => {
|
||||||
|
const result = await runJwtDecoder({ text: futureExp }, {}, mockContext);
|
||||||
|
expect(result.type).toBe("json");
|
||||||
|
if (result.type === "json") {
|
||||||
|
const data = result.value as any;
|
||||||
|
expect(data.status).toBe("valid");
|
||||||
|
expect(data.primary).toContain("Valid (Expires:");
|
||||||
|
expect(data.expiresAt).toBe("3000-01-01T00:00:00.000Z");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decode an expired JWT with past exp claim", async () => {
|
||||||
|
const result = await runJwtDecoder({ text: expiredExp }, {}, mockContext);
|
||||||
|
expect(result.type).toBe("json");
|
||||||
|
if (result.type === "json") {
|
||||||
|
const data = result.value as any;
|
||||||
|
expect(data.status).toBe("expired");
|
||||||
|
expect(data.primary).toContain("Expired (at");
|
||||||
|
expect(data.expiresAt).toBe("2000-01-01T00:00:00.000Z");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for empty token", async () => {
|
||||||
|
await expect(
|
||||||
|
runJwtDecoder({ text: "" }, {}, mockContext)
|
||||||
|
).rejects.toThrow("Please enter a JWT token to decode.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for malformed token format", async () => {
|
||||||
|
await expect(
|
||||||
|
runJwtDecoder({ text: "one.two" }, {}, mockContext)
|
||||||
|
).rejects.toThrow("A JWT must consist of three parts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid JSON payload", async () => {
|
||||||
|
// Header ok, payload invalid base64/json: "invalid" -> aW52YWxpZA==
|
||||||
|
const invalidJson = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aW52YWxpZA.sig";
|
||||||
|
await expect(
|
||||||
|
runJwtDecoder({ text: invalidJson }, {}, mockContext)
|
||||||
|
).rejects.toThrow("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/tools/jwt-decoder/run.ts
Normal file
92
src/tools/jwt-decoder/run.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): string {
|
||||||
|
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const paddingNeeded = (4 - (base64.length % 4)) % 4;
|
||||||
|
const padded = base64 + "=".repeat(paddingNeeded);
|
||||||
|
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runJwtDecoder(
|
||||||
|
input: ToolInput,
|
||||||
|
_options: unknown,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const token = (input.text ?? "").trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Please enter a JWT token to decode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 20, message: "Parsing token parts..." });
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("Invalid JWT token format. A JWT must consist of three parts (header, payload, signature) separated by dots.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureHex] = parts;
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 50, message: "Decoding JSON content..." });
|
||||||
|
|
||||||
|
let header: Record<string, unknown>;
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedHeader = base64urlDecode(headerB64);
|
||||||
|
header = JSON.parse(decodedHeader);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to decode JWT Header: invalid Base64URL encoding or malformed JSON content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedPayload = base64urlDecode(payloadB64);
|
||||||
|
payload = JSON.parse(decodedPayload);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 85, message: "Checking claims..." });
|
||||||
|
|
||||||
|
let expiresAt: string | null = null;
|
||||||
|
let issuedAt: string | null = null;
|
||||||
|
let status: "valid" | "expired" = "valid";
|
||||||
|
let primary = "Valid (No Expiration claim)";
|
||||||
|
|
||||||
|
if (typeof payload.exp === "number") {
|
||||||
|
const expTime = payload.exp * 1000;
|
||||||
|
expiresAt = new Date(expTime).toISOString();
|
||||||
|
if (Date.now() > expTime) {
|
||||||
|
status = "expired";
|
||||||
|
primary = `Expired (at ${expiresAt})`;
|
||||||
|
} else {
|
||||||
|
primary = `Valid (Expires: ${expiresAt})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.iat === "number") {
|
||||||
|
issuedAt = new Date(payload.iat * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "json",
|
||||||
|
value: {
|
||||||
|
primary,
|
||||||
|
status,
|
||||||
|
expiresAt,
|
||||||
|
issuedAt,
|
||||||
|
header,
|
||||||
|
payload,
|
||||||
|
signature: signatureHex || "(empty)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/tools/lorem-ipsum/index.ts
Normal file
54
src/tools/lorem-ipsum/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runLoremIpsum } from "./run";
|
||||||
|
|
||||||
|
export interface LoremIpsumOptions {
|
||||||
|
type: "paragraphs" | "sentences" | "words";
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loremIpsumPlugin: PlimiPlugin<LoremIpsumOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "txt-lorem",
|
||||||
|
name: "Lorem Ipsum Generator",
|
||||||
|
description: "Generate placeholder text in paragraphs, sentences, or words.",
|
||||||
|
category: "text",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["lorem", "ipsum", "placeholder", "text"],
|
||||||
|
input: { type: "none" },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: "Click 'Try example' to generate placeholder text.",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "type",
|
||||||
|
label: "Unit",
|
||||||
|
defaultValue: "paragraphs",
|
||||||
|
options: [
|
||||||
|
{ label: "Paragraphs", value: "paragraphs" },
|
||||||
|
{ label: "Sentences", value: "sentences" },
|
||||||
|
{ label: "Words", value: "words" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "slider",
|
||||||
|
key: "count",
|
||||||
|
label: "Count",
|
||||||
|
defaultValue: 3,
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runLoremIpsum,
|
||||||
|
};
|
||||||
82
src/tools/lorem-ipsum/run.test.ts
Normal file
82
src/tools/lorem-ipsum/run.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runLoremIpsum } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { LoremIpsumOptions } from "./index";
|
||||||
|
|
||||||
|
describe("Lorem Ipsum Generator", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should generate the requested number of words", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "words", count: 5 },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
const words = (result as { type: "text"; value: string }).value.split(" ");
|
||||||
|
expect(words).toHaveLength(5);
|
||||||
|
words.forEach((w) => expect(w.length).toBeGreaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate the requested number of sentences", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "sentences", count: 3 },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
|
||||||
|
expect(sentences).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate the requested number of paragraphs", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "paragraphs", count: 4 },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
const paragraphs = value.split("\n\n").filter((p) => p.trim().length > 0);
|
||||||
|
expect(paragraphs).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start sentences with a capital letter", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "sentences", count: 5 },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
|
||||||
|
sentences.forEach((s) => {
|
||||||
|
const trimmed = s.trim();
|
||||||
|
expect(trimmed[0]).toBe(trimmed[0].toUpperCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should end sentences with a period", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "sentences", count: 3 },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value.endsWith(".")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default to paragraphs when type is unrecognized", async () => {
|
||||||
|
const result = await runLoremIpsum(
|
||||||
|
{},
|
||||||
|
{ type: "unknown", count: 2 } as unknown as LoremIpsumOptions,
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/tools/lorem-ipsum/run.ts
Normal file
89
src/tools/lorem-ipsum/run.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { LoremIpsumOptions } from "./index";
|
||||||
|
|
||||||
|
const LOREM_WORDS = [
|
||||||
|
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
|
||||||
|
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
|
||||||
|
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
|
||||||
|
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
|
||||||
|
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
|
||||||
|
"velit", "esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint",
|
||||||
|
"occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia",
|
||||||
|
"deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis", "unde",
|
||||||
|
"omnis", "iste", "natus", "error", "voluptatem", "accusantium", "doloremque",
|
||||||
|
"laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae", "ab", "illo",
|
||||||
|
"inventore", "veritatis", "quasi", "architecto", "beatae", "vitae", "dicta",
|
||||||
|
"explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut", "odit",
|
||||||
|
"fugit", "consequuntur", "magni", "dolores", "eos", "ratione", "sequi", "nesciunt",
|
||||||
|
];
|
||||||
|
|
||||||
|
function randomWord(): string {
|
||||||
|
return LOREM_WORDS[Math.floor(Math.random() * LOREM_WORDS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSentence(wordCount: number): string {
|
||||||
|
const count = wordCount || (Math.floor(Math.random() * 10) + 6);
|
||||||
|
const words: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
words.push(randomWord());
|
||||||
|
}
|
||||||
|
words[0] = capitalize(words[0]);
|
||||||
|
return words.join(" ") + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateParagraph(sentenceCount: number): string {
|
||||||
|
const count = sentenceCount || (Math.floor(Math.random() * 4) + 4);
|
||||||
|
const sentences: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
sentences.push(generateSentence(0));
|
||||||
|
}
|
||||||
|
return sentences.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLoremIpsum(
|
||||||
|
_input: ToolInput,
|
||||||
|
options: LoremIpsumOptions,
|
||||||
|
context?: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
void context;
|
||||||
|
|
||||||
|
const count = options.count || 3;
|
||||||
|
|
||||||
|
const result = (() => {
|
||||||
|
switch (options.type) {
|
||||||
|
case "words": {
|
||||||
|
const words: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
words.push(randomWord());
|
||||||
|
}
|
||||||
|
return words.join(" ");
|
||||||
|
}
|
||||||
|
case "sentences": {
|
||||||
|
const sentences: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
sentences.push(generateSentence(0));
|
||||||
|
}
|
||||||
|
return sentences.join(" ");
|
||||||
|
}
|
||||||
|
case "paragraphs":
|
||||||
|
default: {
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
paragraphs.push(generateParagraph(0));
|
||||||
|
}
|
||||||
|
return paragraphs.join("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
src/tools/markdown-to-html/index.ts
Normal file
32
src/tools/markdown-to-html/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runMarkdownToHtml } from "./run";
|
||||||
|
|
||||||
|
export interface MarkdownToHtmlOptions {
|
||||||
|
headingStyle?: "atx" | "setext";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markdownToHtmlPlugin: PlimiPlugin<MarkdownToHtmlOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "txt-markdown",
|
||||||
|
name: "Markdown to HTML",
|
||||||
|
description: "Convert Markdown text to raw HTML without sending anything to a server.",
|
||||||
|
category: "text",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["markdown", "html", "convert", "md"],
|
||||||
|
input: { type: "text", placeholder: "Type or paste Markdown here..." },
|
||||||
|
output: { type: "text" },
|
||||||
|
example: "# Hello World\n\nThis is **bold** and *italic*.\n\n- Item 1\n- Item 2\n\n[Visit Plimi](https://plimi.app)",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runMarkdownToHtml,
|
||||||
|
};
|
||||||
106
src/tools/markdown-to-html/run.test.ts
Normal file
106
src/tools/markdown-to-html/run.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runMarkdownToHtml } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Markdown to HTML Converter", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should convert headings h1, h2, h3", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "# Hello\n## World\n### Sub" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("text");
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<h1>Hello</h1>");
|
||||||
|
expect(value).toContain("<h2>World</h2>");
|
||||||
|
expect(value).toContain("<h3>Sub</h3>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert bold and italic text", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "This is **bold** and *italic* and ***both***" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<strong>bold</strong>");
|
||||||
|
expect(value).toContain("<em>italic</em>");
|
||||||
|
expect(value).toContain("<strong><em>both</em></strong>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert inline code", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "Use `console.log` to debug" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<code>console.log</code>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert links", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "Visit [Google](https://google.com)" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain('<a href="https://google.com">Google</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert images", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain('<img src="image.png" alt="Alt text">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert horizontal rules", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "Above\n---\nBelow" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<hr>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert unordered lists", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "- item 1\n- item 2\n- item 3" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<ul>");
|
||||||
|
expect(value).toContain("<li>item 1</li>");
|
||||||
|
expect(value).toContain("<li>item 2</li>");
|
||||||
|
expect(value).toContain("<li>item 3</li>");
|
||||||
|
expect(value).toContain("</ul>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert strikethrough", async () => {
|
||||||
|
const result = await runMarkdownToHtml(
|
||||||
|
{ text: "This is ~~deleted~~ text" },
|
||||||
|
{},
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "text"; value: string }).value;
|
||||||
|
expect(value).toContain("<del>deleted</del>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on empty input", async () => {
|
||||||
|
await expect(
|
||||||
|
runMarkdownToHtml({ text: "" }, {}, mockContext)
|
||||||
|
).rejects.toThrow("No Markdown text provided");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/tools/markdown-to-html/run.ts
Normal file
65
src/tools/markdown-to-html/run.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { MarkdownToHtmlOptions } from "./index";
|
||||||
|
|
||||||
|
function convertMarkdown(md: string): string {
|
||||||
|
let html = md;
|
||||||
|
|
||||||
|
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
||||||
|
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
||||||
|
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
||||||
|
|
||||||
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||||
|
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
||||||
|
|
||||||
|
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
|
||||||
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
|
||||||
|
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||||
|
|
||||||
|
html = html.replace(/^>\s(.+)$/gm, "<blockquote>$1</blockquote>");
|
||||||
|
|
||||||
|
html = html.replace(/^---$/gm, "<hr>");
|
||||||
|
|
||||||
|
html = html.replace(/^\s*[-*+]\s(.+)$/gm, "<li>$1</li>");
|
||||||
|
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>\n${match}</ul>\n`);
|
||||||
|
|
||||||
|
html = html.replace(/^\s*\d+\.\s(.+)$/gm, "<li>$1</li>");
|
||||||
|
|
||||||
|
html = html.replace(/\n{2,}/g, "\n</p>\n<p>\n");
|
||||||
|
html = `<p>${html}</p>`;
|
||||||
|
|
||||||
|
html = html.replace(/<p>\s*<(h[1-6]|ul|ol|blockquote|hr)/g, "<$1");
|
||||||
|
html = html.replace(/<\/(h[1-6]|ul|ol|blockquote|hr)>\s*<\/p>/g, "</$1>");
|
||||||
|
|
||||||
|
html = html.replace(/<p>\s*<\/p>/g, "");
|
||||||
|
|
||||||
|
return html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMarkdownToHtml(
|
||||||
|
input: ToolInput,
|
||||||
|
_options: MarkdownToHtmlOptions,
|
||||||
|
context: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const text = input.text || "";
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error("No Markdown text provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 50, message: "Converting..." });
|
||||||
|
|
||||||
|
const html = convertMarkdown(text);
|
||||||
|
|
||||||
|
context.reportProgress({ percentage: 100, message: "Done" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: html,
|
||||||
|
language: "html",
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/tools/number-base/index.ts
Normal file
58
src/tools/number-base/index.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||||
|
import { runNumberBase } from "./run";
|
||||||
|
|
||||||
|
export interface NumberBaseOptions {
|
||||||
|
fromBase: "2" | "8" | "10" | "16";
|
||||||
|
toBase: "2" | "8" | "10" | "16";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numberBasePlugin: PlimiPlugin<NumberBaseOptions> = {
|
||||||
|
manifest: {
|
||||||
|
id: "dev-numbase",
|
||||||
|
name: "Number Base Converter",
|
||||||
|
description: "Convert numbers between binary, octal, decimal, and hexadecimal.",
|
||||||
|
category: "developer",
|
||||||
|
version: "1.0.0",
|
||||||
|
tags: ["number", "binary", "hex", "decimal", "octal", "base"],
|
||||||
|
input: { type: "text", placeholder: "Enter a number...", multiline: false },
|
||||||
|
output: { type: "json" },
|
||||||
|
example: "255",
|
||||||
|
offlineReady: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
optionsSchema: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "fromBase",
|
||||||
|
label: "From Base",
|
||||||
|
defaultValue: "10",
|
||||||
|
options: [
|
||||||
|
{ label: "Binary (2)", value: "2" },
|
||||||
|
{ label: "Octal (8)", value: "8" },
|
||||||
|
{ label: "Decimal (10)", value: "10" },
|
||||||
|
{ label: "Hexadecimal (16)", value: "16" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "toBase",
|
||||||
|
label: "To Base",
|
||||||
|
defaultValue: "16",
|
||||||
|
options: [
|
||||||
|
{ label: "Binary (2)", value: "2" },
|
||||||
|
{ label: "Octal (8)", value: "8" },
|
||||||
|
{ label: "Decimal (10)", value: "10" },
|
||||||
|
{ label: "Hexadecimal (16)", value: "16" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
cancelable: false,
|
||||||
|
worker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
run: runNumberBase,
|
||||||
|
};
|
||||||
102
src/tools/number-base/run.test.ts
Normal file
102
src/tools/number-base/run.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { runNumberBase } from "./run";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
|
||||||
|
describe("Number Base Converter", () => {
|
||||||
|
const mockContext: ToolContext = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
reportProgress: vi.fn(),
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should convert decimal to hexadecimal", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "255" },
|
||||||
|
{ fromBase: "10", toBase: "16" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
expect(result.type).toBe("json");
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.decimal).toBe("255");
|
||||||
|
expect(value.hexadecimal).toBe("0xFF");
|
||||||
|
expect(value.primary).toBe("0xFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert hexadecimal to decimal", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "0xFF" },
|
||||||
|
{ fromBase: "16", toBase: "10" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.decimal).toBe("255");
|
||||||
|
expect(value.primary).toBe("255");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert decimal to binary", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "10" },
|
||||||
|
{ fromBase: "10", toBase: "2" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.binary).toBe("0b1010");
|
||||||
|
expect(value.primary).toBe("0b1010");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert binary to decimal", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "1010" },
|
||||||
|
{ fromBase: "2", toBase: "10" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.decimal).toBe("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert decimal to octal", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "64" },
|
||||||
|
{ fromBase: "10", toBase: "8" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.octal).toBe("0o100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all base representations in every conversion", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "42" },
|
||||||
|
{ fromBase: "10", toBase: "16" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.binary).toBeDefined();
|
||||||
|
expect(value.octal).toBeDefined();
|
||||||
|
expect(value.decimal).toBeDefined();
|
||||||
|
expect(value.hexadecimal).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on empty input", async () => {
|
||||||
|
await expect(
|
||||||
|
runNumberBase({ text: "" }, { fromBase: "10", toBase: "16" }, mockContext)
|
||||||
|
).rejects.toThrow("No number provided");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on invalid number for given base", async () => {
|
||||||
|
await expect(
|
||||||
|
runNumberBase({ text: "xyz" }, { fromBase: "10", toBase: "16" }, mockContext)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero", async () => {
|
||||||
|
const result = await runNumberBase(
|
||||||
|
{ text: "0" },
|
||||||
|
{ fromBase: "10", toBase: "2" },
|
||||||
|
mockContext
|
||||||
|
);
|
||||||
|
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
|
||||||
|
expect(value.decimal).toBe("0");
|
||||||
|
expect(value.binary).toBe("0b0");
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/tools/number-base/run.ts
Normal file
53
src/tools/number-base/run.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ToolInput } from "../../core/io/input-types";
|
||||||
|
import type { ToolResult } from "../../core/io/output-types";
|
||||||
|
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||||
|
import type { NumberBaseOptions } from "./index";
|
||||||
|
|
||||||
|
function formatNumber(value: bigint, base: number): string {
|
||||||
|
if (base === 2) return "0b" + value.toString(2);
|
||||||
|
if (base === 8) return "0o" + value.toString(8);
|
||||||
|
if (base === 10) return value.toString(10);
|
||||||
|
if (base === 16) return "0x" + value.toString(16).toUpperCase();
|
||||||
|
return value.toString(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNumberBase(
|
||||||
|
input: ToolInput,
|
||||||
|
options: NumberBaseOptions,
|
||||||
|
context?: ToolContext
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
void context;
|
||||||
|
|
||||||
|
const text = (input.text || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("No number provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromBase = parseInt(options.fromBase, 10);
|
||||||
|
const toBase = parseInt(options.toBase, 10);
|
||||||
|
|
||||||
|
const cleaned = text.replace(/^0[box]/i, "");
|
||||||
|
|
||||||
|
let value: bigint;
|
||||||
|
try {
|
||||||
|
value = BigInt(parseInt(cleaned, fromBase));
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(Number(value))) {
|
||||||
|
throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "json",
|
||||||
|
value: {
|
||||||
|
input: text,
|
||||||
|
binary: formatNumber(value, 2),
|
||||||
|
octal: formatNumber(value, 8),
|
||||||
|
decimal: formatNumber(value, 10),
|
||||||
|
hexadecimal: formatNumber(value, 16),
|
||||||
|
primary: formatNumber(value, toBase),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user