@pixelvault-dev/paste 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PixelVault
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @pixelvault-dev/paste
2
+
3
+ Paste-and-host for any textarea. Paste, drop, or select an image and get a
4
+ hosted CDN URL inserted at the caret — like GitHub's markdown editor, backed by
5
+ [PixelVault](https://pixelvault.dev) publishable keys.
6
+
7
+ Framework-agnostic core. No dependencies.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { attachPaste } from "@pixelvault-dev/paste";
13
+
14
+ const detach = attachPaste(document.querySelector("textarea"), {
15
+ publishableKey: "pv_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
16
+ });
17
+
18
+ // later, to remove the listeners:
19
+ detach();
20
+ ```
21
+
22
+ Pasting or dropping an image inserts an `![Uploading…]()` placeholder, uploads
23
+ the file, then replaces the placeholder with `![name](https://img.pixelvault.dev/…)`.
24
+
25
+ ### Options
26
+
27
+ | Option | Type | Description |
28
+ | --- | --- | --- |
29
+ | `publishableKey` | `string` | **Required.** A `pv_pub_` key. Create one in the PixelVault dashboard with an origin allowlist. |
30
+ | `endpoint` | `string` | Upload endpoint. Defaults to `https://api.pixelvault.dev/v1/images`. |
31
+ | `folder` | `string` | Optional folder to store uploads under. |
32
+ | `render` | `(result, file) => string` | Text inserted on success. Default: a markdown image tag. Override for HTML, BBCode, a bare URL, etc. |
33
+ | `onUploadStart` | `(file) => void` | Called when an upload begins. |
34
+ | `onUploadComplete` | `(result, file) => void` | Called on success. |
35
+ | `onError` | `(error, file) => string \| void` | Called on failure. Return a string to replace the placeholder with it; return nothing to remove the placeholder. |
36
+
37
+ ### Get a publishable key
38
+
39
+ Publishable keys are safe to ship in browser code: they are **upload-only** and
40
+ restricted to the request `Origin`s you allowlist. Create one in the dashboard
41
+ under your project's "Publishable keys" section.
42
+
43
+ ## Lower-level API
44
+
45
+ `uploadImage(file, options)` uploads a single `File` and resolves with
46
+ `{ id, url, mimeType, size }` (throws `PixelVaultPasteError` on failure). The
47
+ event helpers (`imageFilesFromClipboard`, `imageFilesFromDrop`) and text helpers
48
+ (`buildImageMarkdown`, `insertText`) are exported for building custom flows.
@@ -0,0 +1,34 @@
1
+ import { type UploadResult } from "./upload.js";
2
+ /** A text field paste-and-host can attach to. */
3
+ export type PasteTarget = HTMLTextAreaElement | HTMLInputElement;
4
+ export interface PasteOptions {
5
+ /** Publishable key (pv_pub_…). Required. */
6
+ publishableKey: string;
7
+ /** Upload endpoint. Defaults to the PixelVault production endpoint. */
8
+ endpoint?: string;
9
+ /** Optional folder to store uploads under. */
10
+ folder?: string;
11
+ /**
12
+ * Produce the text inserted on success. Defaults to a markdown image tag.
13
+ * Override to insert HTML, BBCode, a bare URL, etc.
14
+ */
15
+ render?: (result: UploadResult, file: File) => string;
16
+ /** Called when an upload starts. */
17
+ onUploadStart?: (file: File) => void;
18
+ /** Called when an upload completes successfully. */
19
+ onUploadComplete?: (result: UploadResult, file: File) => void;
20
+ /**
21
+ * Called on upload failure. Return a string to replace the placeholder with
22
+ * that text; return nothing to remove the placeholder. If omitted, the
23
+ * placeholder is removed and the error is logged to the console.
24
+ */
25
+ onError?: (error: Error, file: File) => string | void;
26
+ }
27
+ /**
28
+ * Attach paste-and-host behavior to a textarea or text input: pasting or
29
+ * dropping an image uploads it to PixelVault and inserts a markdown link at the
30
+ * caret (with an "Uploading…" placeholder in between).
31
+ *
32
+ * Returns a detach function that removes the listeners.
33
+ */
34
+ export declare function attachPaste(target: PasteTarget, options: PasteOptions): () => void;
package/dist/attach.js ADDED
@@ -0,0 +1,99 @@
1
+ import { uploadImage } from "./upload.js";
2
+ import { imageFilesFromClipboard, imageFilesFromDrop } from "./events.js";
3
+ import { buildImageMarkdown, buildPlaceholder, insertText, replaceFirst, } from "./text.js";
4
+ /** A unique token per in-flight upload so concurrent placeholders don't collide. */
5
+ function makeToken() {
6
+ const c = globalThis.crypto;
7
+ if (c && typeof c.randomUUID === "function")
8
+ return c.randomUUID();
9
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
10
+ }
11
+ /** Apply a pure text edit to the element and notify listeners (input event). */
12
+ function applyEdit(field, value, caret) {
13
+ field.value = value;
14
+ try {
15
+ field.selectionStart = caret;
16
+ field.selectionEnd = caret;
17
+ }
18
+ catch {
19
+ // Some input types don't support selection — safe to ignore.
20
+ }
21
+ field.dispatchEvent(new Event("input", { bubbles: true }));
22
+ }
23
+ async function handleFile(target, file, options) {
24
+ const token = makeToken();
25
+ const placeholder = buildPlaceholder(file.name, token);
26
+ const inserted = insertText(target, placeholder);
27
+ applyEdit(target, inserted.value, inserted.caret);
28
+ options.onUploadStart?.(file);
29
+ try {
30
+ const result = await uploadImage(file, {
31
+ publishableKey: options.publishableKey,
32
+ endpoint: options.endpoint,
33
+ folder: options.folder,
34
+ });
35
+ const finalText = options.render
36
+ ? options.render(result, file)
37
+ : buildImageMarkdown(result.url, file.name);
38
+ applyEdit(target, replaceFirst(target.value, placeholder, finalText), target.value.length);
39
+ options.onUploadComplete?.(result, file);
40
+ }
41
+ catch (err) {
42
+ const error = err instanceof Error ? err : new Error(String(err));
43
+ let replacement = "";
44
+ if (options.onError) {
45
+ const r = options.onError(error, file);
46
+ if (typeof r === "string")
47
+ replacement = r;
48
+ }
49
+ else {
50
+ // eslint-disable-next-line no-console
51
+ console.error("[pixelvault-paste] upload failed:", error);
52
+ }
53
+ applyEdit(target, replaceFirst(target.value, placeholder, replacement), target.value.length);
54
+ }
55
+ }
56
+ function handleFiles(target, files, options) {
57
+ for (const file of files)
58
+ void handleFile(target, file, options);
59
+ }
60
+ /**
61
+ * Attach paste-and-host behavior to a textarea or text input: pasting or
62
+ * dropping an image uploads it to PixelVault and inserts a markdown link at the
63
+ * caret (with an "Uploading…" placeholder in between).
64
+ *
65
+ * Returns a detach function that removes the listeners.
66
+ */
67
+ export function attachPaste(target, options) {
68
+ if (!options.publishableKey) {
69
+ throw new Error("attachPaste: options.publishableKey is required.");
70
+ }
71
+ const onPaste = (e) => {
72
+ const files = imageFilesFromClipboard(e);
73
+ if (files.length) {
74
+ e.preventDefault();
75
+ handleFiles(target, files, options);
76
+ }
77
+ };
78
+ const onDrop = (e) => {
79
+ const files = imageFilesFromDrop(e);
80
+ if (files.length) {
81
+ e.preventDefault();
82
+ handleFiles(target, files, options);
83
+ }
84
+ };
85
+ const onDragOver = (e) => {
86
+ // Only claim the drop when files are being dragged, so text drags still work.
87
+ const types = e.dataTransfer?.types;
88
+ if (types && Array.from(types).includes("Files"))
89
+ e.preventDefault();
90
+ };
91
+ target.addEventListener("paste", onPaste);
92
+ target.addEventListener("drop", onDrop);
93
+ target.addEventListener("dragover", onDragOver);
94
+ return () => {
95
+ target.removeEventListener("paste", onPaste);
96
+ target.removeEventListener("drop", onDrop);
97
+ target.removeEventListener("dragover", onDragOver);
98
+ };
99
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Error thrown when an upload fails. Carries the API error code and HTTP status
3
+ * when the failure came from the server (vs. a network/other error).
4
+ */
5
+ export declare class PixelVaultPasteError extends Error {
6
+ /** Machine-readable code from the API (e.g. "origin_not_allowed"), if any. */
7
+ readonly code: string | undefined;
8
+ /** HTTP status of the failed response, if the failure was an HTTP error. */
9
+ readonly status: number | undefined;
10
+ constructor(message: string, code?: string, status?: number);
11
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Error thrown when an upload fails. Carries the API error code and HTTP status
3
+ * when the failure came from the server (vs. a network/other error).
4
+ */
5
+ export class PixelVaultPasteError extends Error {
6
+ /** Machine-readable code from the API (e.g. "origin_not_allowed"), if any. */
7
+ code;
8
+ /** HTTP status of the failed response, if the failure was an HTTP error. */
9
+ status;
10
+ constructor(message, code, status) {
11
+ super(message);
12
+ this.name = "PixelVaultPasteError";
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ }
@@ -0,0 +1,6 @@
1
+ /** Extract image `File`s from a DataTransfer (clipboard paste or drag-drop). */
2
+ export declare function imageFilesFromDataTransfer(dt: DataTransfer | null | undefined): File[];
3
+ /** Image files from a paste event (empty if none). */
4
+ export declare function imageFilesFromClipboard(event: ClipboardEvent): File[];
5
+ /** Image files from a drop event (empty if none). */
6
+ export declare function imageFilesFromDrop(event: DragEvent): File[];
package/dist/events.js ADDED
@@ -0,0 +1,37 @@
1
+ /** Extract image `File`s from a DataTransfer (clipboard paste or drag-drop). */
2
+ export function imageFilesFromDataTransfer(dt) {
3
+ if (!dt)
4
+ return [];
5
+ const out = [];
6
+ const seen = new Set();
7
+ const push = (f) => {
8
+ if (!f || !f.type.startsWith("image/"))
9
+ return;
10
+ const key = `${f.name}:${f.size}:${f.lastModified}`;
11
+ if (seen.has(key))
12
+ return;
13
+ seen.add(key);
14
+ out.push(f);
15
+ };
16
+ // Prefer items — a pasted screenshot shows up here (and sometimes not in
17
+ // .files on older browsers).
18
+ if (dt.items) {
19
+ for (const item of Array.from(dt.items)) {
20
+ if (item.kind === "file")
21
+ push(item.getAsFile());
22
+ }
23
+ }
24
+ if (dt.files) {
25
+ for (const f of Array.from(dt.files))
26
+ push(f);
27
+ }
28
+ return out;
29
+ }
30
+ /** Image files from a paste event (empty if none). */
31
+ export function imageFilesFromClipboard(event) {
32
+ return imageFilesFromDataTransfer(event.clipboardData);
33
+ }
34
+ /** Image files from a drop event (empty if none). */
35
+ export function imageFilesFromDrop(event) {
36
+ return imageFilesFromDataTransfer(event.dataTransfer);
37
+ }
@@ -0,0 +1,8 @@
1
+ export { attachPaste } from "./attach.js";
2
+ export type { PasteOptions, PasteTarget } from "./attach.js";
3
+ export { uploadImage, DEFAULT_ENDPOINT } from "./upload.js";
4
+ export type { UploadResult, UploadOptions } from "./upload.js";
5
+ export { PixelVaultPasteError } from "./errors.js";
6
+ export { buildImageMarkdown, buildPlaceholder, insertText, replaceFirst, } from "./text.js";
7
+ export type { TextFieldLike } from "./text.js";
8
+ export { imageFilesFromDataTransfer, imageFilesFromClipboard, imageFilesFromDrop, } from "./events.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { attachPaste } from "./attach.js";
2
+ export { uploadImage, DEFAULT_ENDPOINT } from "./upload.js";
3
+ export { PixelVaultPasteError } from "./errors.js";
4
+ export { buildImageMarkdown, buildPlaceholder, insertText, replaceFirst, } from "./text.js";
5
+ export { imageFilesFromDataTransfer, imageFilesFromClipboard, imageFilesFromDrop, } from "./events.js";
package/dist/text.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Minimal shape of a text field we insert into. Both HTMLTextAreaElement and
3
+ * HTMLInputElement satisfy it, and tests can pass a plain object.
4
+ */
5
+ export interface TextFieldLike {
6
+ value: string;
7
+ selectionStart: number | null;
8
+ selectionEnd: number | null;
9
+ }
10
+ /** Build the markdown image tag inserted on successful upload. */
11
+ export declare function buildImageMarkdown(url: string, alt: string): string;
12
+ /**
13
+ * Build the placeholder inserted while an upload is in flight. The token makes
14
+ * each placeholder unique so concurrent uploads replace the right one.
15
+ */
16
+ export declare function buildPlaceholder(fileName: string, token: string): string;
17
+ /** Replace the first occurrence of `needle` in `haystack`. Returns the
18
+ * original string unchanged if `needle` isn't present. */
19
+ export declare function replaceFirst(haystack: string, needle: string, replacement: string): string;
20
+ /**
21
+ * Compute the field value + caret after inserting `text` at the current caret,
22
+ * replacing any current selection. Pure — the caller applies the result to the
23
+ * real element. Falls back to appending when the selection is unknown.
24
+ */
25
+ export declare function insertText(field: TextFieldLike, text: string): {
26
+ value: string;
27
+ caret: number;
28
+ };
package/dist/text.js ADDED
@@ -0,0 +1,38 @@
1
+ /** Escape the parts of a filename that would break markdown alt text / links. */
2
+ function sanitizeAlt(name) {
3
+ // Drop the extension for a cleaner alt, and neutralize brackets/newlines.
4
+ const base = name.replace(/\.[^.]+$/, "") || name;
5
+ return base.replace(/[\r\n]+/g, " ").replace(/[[\]]/g, "").trim() || "image";
6
+ }
7
+ /** Build the markdown image tag inserted on successful upload. */
8
+ export function buildImageMarkdown(url, alt) {
9
+ return `![${sanitizeAlt(alt)}](${url})`;
10
+ }
11
+ /**
12
+ * Build the placeholder inserted while an upload is in flight. The token makes
13
+ * each placeholder unique so concurrent uploads replace the right one.
14
+ */
15
+ export function buildPlaceholder(fileName, token) {
16
+ return `![Uploading ${sanitizeAlt(fileName)}…](uploading#${token})`;
17
+ }
18
+ /** Replace the first occurrence of `needle` in `haystack`. Returns the
19
+ * original string unchanged if `needle` isn't present. */
20
+ export function replaceFirst(haystack, needle, replacement) {
21
+ const i = haystack.indexOf(needle);
22
+ if (i === -1)
23
+ return haystack;
24
+ return haystack.slice(0, i) + replacement + haystack.slice(i + needle.length);
25
+ }
26
+ /**
27
+ * Compute the field value + caret after inserting `text` at the current caret,
28
+ * replacing any current selection. Pure — the caller applies the result to the
29
+ * real element. Falls back to appending when the selection is unknown.
30
+ */
31
+ export function insertText(field, text) {
32
+ const value = field.value;
33
+ const hasSelection = field.selectionStart !== null && field.selectionEnd !== null;
34
+ const start = hasSelection ? field.selectionStart : value.length;
35
+ const end = hasSelection ? field.selectionEnd : value.length;
36
+ const nextValue = value.slice(0, start) + text + value.slice(end);
37
+ return { value: nextValue, caret: start + text.length };
38
+ }
@@ -0,0 +1,28 @@
1
+ /** The default PixelVault upload endpoint. */
2
+ export declare const DEFAULT_ENDPOINT = "https://api.pixelvault.dev/v1/images";
3
+ /** A successfully uploaded image. */
4
+ export interface UploadResult {
5
+ /** Image id (e.g. "img_a1b2c3d4e5f6"). */
6
+ id: string;
7
+ /** Public CDN URL. */
8
+ url: string;
9
+ /** Detected MIME type. */
10
+ mimeType: string;
11
+ /** Size in bytes. */
12
+ size: number;
13
+ }
14
+ export interface UploadOptions {
15
+ /** Publishable key (pv_pub_…). Required. */
16
+ publishableKey: string;
17
+ /** Upload endpoint. Defaults to {@link DEFAULT_ENDPOINT}. */
18
+ endpoint?: string;
19
+ /** Optional folder to store the image under. */
20
+ folder?: string;
21
+ /** Abort signal to cancel the request. */
22
+ signal?: AbortSignal;
23
+ }
24
+ /**
25
+ * Upload a single image file to PixelVault and resolve with its CDN URL.
26
+ * Throws {@link PixelVaultPasteError} on any HTTP or network failure.
27
+ */
28
+ export declare function uploadImage(file: File, options: UploadOptions): Promise<UploadResult>;
package/dist/upload.js ADDED
@@ -0,0 +1,49 @@
1
+ import { PixelVaultPasteError } from "./errors.js";
2
+ /** The default PixelVault upload endpoint. */
3
+ export const DEFAULT_ENDPOINT = "https://api.pixelvault.dev/v1/images";
4
+ /**
5
+ * Upload a single image file to PixelVault and resolve with its CDN URL.
6
+ * Throws {@link PixelVaultPasteError} on any HTTP or network failure.
7
+ */
8
+ export async function uploadImage(file, options) {
9
+ const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
10
+ const form = new FormData();
11
+ form.append("file", file);
12
+ if (options.folder)
13
+ form.append("folder", options.folder);
14
+ let res;
15
+ try {
16
+ res = await fetch(endpoint, {
17
+ method: "POST",
18
+ headers: { Authorization: `Bearer ${options.publishableKey}` },
19
+ body: form,
20
+ signal: options.signal,
21
+ });
22
+ }
23
+ catch (err) {
24
+ // Network error, abort, CORS rejection, etc.
25
+ const message = err instanceof Error ? err.message : "Upload request failed.";
26
+ throw new PixelVaultPasteError(message);
27
+ }
28
+ let body;
29
+ try {
30
+ body = await res.json();
31
+ }
32
+ catch {
33
+ body = undefined;
34
+ }
35
+ if (!res.ok) {
36
+ const apiErr = body;
37
+ throw new PixelVaultPasteError(apiErr?.error?.message ?? `Upload failed with status ${res.status}.`, apiErr?.error?.code, res.status);
38
+ }
39
+ const data = body?.data;
40
+ if (!data || typeof data.url !== "string") {
41
+ throw new PixelVaultPasteError("Upload succeeded but the response was malformed.");
42
+ }
43
+ return {
44
+ id: data.id,
45
+ url: data.url,
46
+ mimeType: data.mime_type,
47
+ size: data.size,
48
+ };
49
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@pixelvault-dev/paste",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Paste-and-host: drop, paste, or select an image in any textarea and get a hosted CDN URL back. Backed by PixelVault publishable keys.",
7
+ "keywords": [
8
+ "image",
9
+ "upload",
10
+ "paste",
11
+ "clipboard",
12
+ "drag-and-drop",
13
+ "cdn",
14
+ "textarea",
15
+ "pixelvault"
16
+ ],
17
+ "homepage": "https://pixelvault.dev/docs#paste",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/pixelvault-dev/pixelvault.git",
21
+ "directory": "packages/paste"
22
+ },
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
43
+ "build": "npm run clean && tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run",
46
+ "prepare": "npm run build"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5.7.3",
50
+ "vitest": "^3.2.4"
51
+ }
52
+ }