mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(wallpaper-generator): add device wallpaper generation package
Add new @manacore/wallpaper-generator package for creating device wallpapers from QR codes and images. Features: - 30 device presets (phones, tablets, desktops) - 3 layout types (center, corner, pattern) - 5 gradient presets + solid color backgrounds - Browser (Canvas) and Node.js (Sharp) renderers - Svelte WallpaperModal UI component Integrations: - @manacore/qr-export: toWallpaper() function - @manacore/spiral-db: toWallpaper() function - QRExportModal: "Als Wallpaper" button Also includes the full @manacore/qr-export package which was previously untracked. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c480231128
commit
e5109da732
37 changed files with 5393 additions and 676 deletions
|
|
@ -43,6 +43,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/qr-export": "workspace:*",
|
||||
"@manacore/wallpaper-generator": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { toDataURL, toSVG } from '@manacore/qr-export/generate';
|
||||
import { qrExportService, type QRExportResult } from '$lib/api/services/qr-export';
|
||||
import type { UserDataSummary } from '$lib/api/services/my-data';
|
||||
import { WallpaperModal } from '@manacore/wallpaper-generator/svelte';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
|
|
@ -15,6 +16,8 @@
|
|||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let exportResult = $state<QRExportResult | null>(null);
|
||||
let showWallpaperModal = $state(false);
|
||||
let qrDataUrl = $state<string | null>(null);
|
||||
|
||||
// Load export data when modal opens
|
||||
$effect(() => {
|
||||
|
|
@ -87,6 +90,18 @@
|
|||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
async function openWallpaperModal() {
|
||||
if (!exportResult) return;
|
||||
|
||||
try {
|
||||
// Generate QR code as data URL for wallpaper generation
|
||||
qrDataUrl = await toDataURL(exportResult.encodeResult, { size: 600 });
|
||||
showWallpaperModal = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to generate QR data URL:', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
|
|
@ -236,6 +251,22 @@
|
|||
SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallpaper Button -->
|
||||
<button
|
||||
onclick={openWallpaperModal}
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Als Wallpaper
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -250,3 +281,12 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Wallpaper Modal -->
|
||||
{#if qrDataUrl}
|
||||
<WallpaperModal
|
||||
show={showWallpaperModal}
|
||||
imageDataUrl={qrDataUrl}
|
||||
onClose={() => (showWallpaperModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
48
packages/qr-export/package.json
Normal file
48
packages/qr-export/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "@manacore/qr-export",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "QR code export/import for personal data (contacts, events, todos, user context)",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./svelte": "./src/svelte/index.ts",
|
||||
"./generate": "./src/generate.ts",
|
||||
"./wallpaper": "./src/wallpaper.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "^2.1.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"@manacore/wallpaper-generator": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"jsqr": {
|
||||
"optional": true
|
||||
},
|
||||
"@manacore/wallpaper-generator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
247
packages/qr-export/src/builder.ts
Normal file
247
packages/qr-export/src/builder.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* ManaQR Export Builder
|
||||
*
|
||||
* Fluent API for building QR exports easily.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ManaQRExport,
|
||||
ManaQRUserContext,
|
||||
ManaQRContact,
|
||||
ManaQREvent,
|
||||
ManaQRTodo,
|
||||
EncodeResult,
|
||||
ContactRelation,
|
||||
TodoPriority,
|
||||
} from './types';
|
||||
import { MANA_QR_LIMITS } from './types';
|
||||
import { encode, estimateSize } from './encoder';
|
||||
import {
|
||||
selectTopContacts,
|
||||
selectUpcomingEvents,
|
||||
selectPriorityTodos,
|
||||
type ContactInput,
|
||||
type EventInput,
|
||||
type TodoInput,
|
||||
} from './selectors';
|
||||
|
||||
/**
|
||||
* Builder for creating ManaQR exports
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = createManaQRExport()
|
||||
* .user({ n: 'Till', z: 'Europe/Berlin', l: 'de' })
|
||||
* .addContact({ n: 'Mama', p: '+491701234567', r: 1 })
|
||||
* .addContact({ n: 'Papa', p: '+491707654321', r: 1 })
|
||||
* .addEvent({ t: 'Zahnarzt', s: Date.now() + 86400000, d: 60 })
|
||||
* .addTodo({ t: 'Steuererklärung', p: 1, d: 14 })
|
||||
* .encode();
|
||||
* ```
|
||||
*/
|
||||
export class ManaQRExportBuilder {
|
||||
private _user: ManaQRUserContext = { n: '' };
|
||||
private _contacts: ManaQRContact[] = [];
|
||||
private _events: ManaQREvent[] = [];
|
||||
private _todos: ManaQRTodo[] = [];
|
||||
private _timestamp: number = Math.floor(Date.now() / 1000);
|
||||
|
||||
/**
|
||||
* Set user context
|
||||
*/
|
||||
user(context: ManaQRUserContext): this {
|
||||
this._user = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user name (shorthand)
|
||||
*/
|
||||
userName(name: string): this {
|
||||
this._user.n = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user timezone
|
||||
*/
|
||||
timezone(tz: string): this {
|
||||
this._user.z = tz;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user language
|
||||
*/
|
||||
language(lang: string): this {
|
||||
this._user.l = lang;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user location
|
||||
*/
|
||||
location(loc: string): this {
|
||||
this._user.w = loc;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single contact
|
||||
*/
|
||||
addContact(contact: ManaQRContact): this {
|
||||
this._contacts.push(contact);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple contacts
|
||||
*/
|
||||
contacts(contacts: ManaQRContact[]): this {
|
||||
this._contacts.push(...contacts);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add contacts from input format (auto-selects top contacts)
|
||||
*/
|
||||
contactsFrom(inputs: ContactInput[], limit: number = MANA_QR_LIMITS.MAX_CONTACTS): this {
|
||||
this._contacts = selectTopContacts(inputs, limit);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single event
|
||||
*/
|
||||
addEvent(event: ManaQREvent): this {
|
||||
this._events.push(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple events
|
||||
*/
|
||||
events(events: ManaQREvent[]): this {
|
||||
this._events.push(...events);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add events from input format (auto-selects upcoming)
|
||||
*/
|
||||
eventsFrom(inputs: EventInput[], limit: number = MANA_QR_LIMITS.MAX_EVENTS): this {
|
||||
this._events = selectUpcomingEvents(inputs, limit);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single todo
|
||||
*/
|
||||
addTodo(todo: ManaQRTodo): this {
|
||||
this._todos.push(todo);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple todos
|
||||
*/
|
||||
todos(todos: ManaQRTodo[]): this {
|
||||
this._todos.push(...todos);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add todos from input format (auto-selects by priority)
|
||||
*/
|
||||
todosFrom(inputs: TodoInput[], limit: number = MANA_QR_LIMITS.MAX_TODOS): this {
|
||||
this._todos = selectPriorityTodos(inputs, limit);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom timestamp (Unix seconds)
|
||||
*/
|
||||
timestamp(ts: number): this {
|
||||
this._timestamp = ts;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the export data (without encoding)
|
||||
*/
|
||||
build(): ManaQRExport {
|
||||
return {
|
||||
v: 1,
|
||||
ts: this._timestamp,
|
||||
u: this._user,
|
||||
c: this._contacts,
|
||||
e: this._events,
|
||||
t: this._todos,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate encoded size in bytes
|
||||
*/
|
||||
estimateSize(): number {
|
||||
return estimateSize(this.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current data will fit in a QR code
|
||||
*/
|
||||
willFit(): boolean {
|
||||
return this.estimateSize() <= MANA_QR_LIMITS.MAX_QR_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode to QR-ready string
|
||||
*/
|
||||
encode(): EncodeResult {
|
||||
return encode(this.build());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ManaQR export builder
|
||||
*/
|
||||
export function createManaQRExport(): ManaQRExportBuilder {
|
||||
return new ManaQRExportBuilder();
|
||||
}
|
||||
|
||||
// --- Quick helpers for common formats ---
|
||||
|
||||
/**
|
||||
* Create a contact in compact format
|
||||
*/
|
||||
export function contact(
|
||||
name: string,
|
||||
phone?: string,
|
||||
relation: ContactRelation = 3
|
||||
): ManaQRContact {
|
||||
return { n: name, p: phone, r: relation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event in compact format
|
||||
*/
|
||||
export function event(
|
||||
title: string,
|
||||
startDate: Date,
|
||||
durationMinutes = 60,
|
||||
location?: string
|
||||
): ManaQREvent {
|
||||
return {
|
||||
t: title,
|
||||
s: Math.floor(startDate.getTime() / 1000),
|
||||
d: durationMinutes,
|
||||
l: location,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a todo in compact format
|
||||
*/
|
||||
export function todo(title: string, priority: TodoPriority = 2, dueDays?: number): ManaQRTodo {
|
||||
return { t: title, p: priority, d: dueDays };
|
||||
}
|
||||
153
packages/qr-export/src/encoder.test.ts
Normal file
153
packages/qr-export/src/encoder.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { encode, decode, estimateSize, willFitInQR, MANA_QR_PREFIX } from './encoder';
|
||||
import { createManaQRExport, contact, event, todo } from './builder';
|
||||
import type { ManaQRExport } from './types';
|
||||
|
||||
describe('encoder', () => {
|
||||
const sampleExport: ManaQRExport = {
|
||||
v: 1,
|
||||
ts: 1708185600,
|
||||
u: {
|
||||
n: 'Till',
|
||||
z: 'Europe/Berlin',
|
||||
l: 'de',
|
||||
w: 'Berlin',
|
||||
},
|
||||
c: [
|
||||
{ n: 'Mama', p: '+491701234567', r: 1 },
|
||||
{ n: 'Papa', p: '+491707654321', r: 1 },
|
||||
],
|
||||
e: [
|
||||
{ t: 'Zahnarzt', s: 1708272000, d: 60, l: 'Praxis Dr. Weber' },
|
||||
{ t: 'Team Meeting', s: 1708358400, d: 30 },
|
||||
],
|
||||
t: [
|
||||
{ t: 'Steuererklärung abgeben', p: 1, d: 14 },
|
||||
{ t: 'Backup machen', p: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('encode', () => {
|
||||
it('should encode data with correct prefix', () => {
|
||||
const result = encode(sampleExport);
|
||||
|
||||
expect(result.data.startsWith(MANA_QR_PREFIX)).toBe(true);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should report fitsInQR correctly for small data', () => {
|
||||
const result = encode(sampleExport);
|
||||
|
||||
expect(result.fitsInQR).toBe(true);
|
||||
expect(result.size).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('should compress data significantly', () => {
|
||||
const jsonSize = JSON.stringify(sampleExport).length;
|
||||
const result = encode(sampleExport);
|
||||
|
||||
// Encoded should be smaller than raw JSON
|
||||
expect(result.size).toBeLessThan(jsonSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode', () => {
|
||||
it('should decode encoded data correctly', () => {
|
||||
const encoded = encode(sampleExport);
|
||||
const decoded = decode(encoded.data);
|
||||
|
||||
expect(decoded.success).toBe(true);
|
||||
if (decoded.success) {
|
||||
expect(decoded.data.u.n).toBe('Till');
|
||||
expect(decoded.data.c).toHaveLength(2);
|
||||
expect(decoded.data.e).toHaveLength(2);
|
||||
expect(decoded.data.t).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail for invalid prefix', () => {
|
||||
const result = decode('INVALID:xyz');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBe('INVALID_PREFIX');
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail for invalid base64', () => {
|
||||
const result = decode(MANA_QR_PREFIX + '!!!invalid!!!');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBe('INVALID_BASE64');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip', () => {
|
||||
it('should preserve all data through encode/decode', () => {
|
||||
const encoded = encode(sampleExport);
|
||||
const decoded = decode(encoded.data);
|
||||
|
||||
expect(decoded.success).toBe(true);
|
||||
if (decoded.success) {
|
||||
expect(decoded.data).toEqual(sampleExport);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateSize', () => {
|
||||
it('should estimate size within reasonable range', () => {
|
||||
const estimated = estimateSize(sampleExport);
|
||||
const actual = encode(sampleExport).size;
|
||||
|
||||
// Estimate should be within 50% of actual
|
||||
expect(estimated).toBeGreaterThan(actual * 0.5);
|
||||
expect(estimated).toBeLessThan(actual * 1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('willFitInQR', () => {
|
||||
it('should return true for small data', () => {
|
||||
expect(willFitInQR(sampleExport)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
it('should build export with fluent API', () => {
|
||||
const result = createManaQRExport()
|
||||
.user({ n: 'Test', z: 'UTC', l: 'en' })
|
||||
.addContact(contact('Alice', '+1234567890', 1))
|
||||
.addEvent(event('Meeting', new Date('2024-02-20T10:00:00Z'), 60))
|
||||
.addTodo(todo('Task 1', 1, 7))
|
||||
.build();
|
||||
|
||||
expect(result.v).toBe(1);
|
||||
expect(result.u.n).toBe('Test');
|
||||
expect(result.c).toHaveLength(1);
|
||||
expect(result.e).toHaveLength(1);
|
||||
expect(result.t).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should encode to valid QR string', () => {
|
||||
const result = createManaQRExport()
|
||||
.userName('Test')
|
||||
.timezone('UTC')
|
||||
.addContact({ n: 'Bob', r: 3 })
|
||||
.encode();
|
||||
|
||||
expect(result.data.startsWith(MANA_QR_PREFIX)).toBe(true);
|
||||
expect(result.fitsInQR).toBe(true);
|
||||
});
|
||||
|
||||
it('should estimate size correctly', () => {
|
||||
const builder = createManaQRExport().userName('Test').addContact({ n: 'Alice', r: 1 });
|
||||
|
||||
const estimated = builder.estimateSize();
|
||||
const _actual = builder.encode().size;
|
||||
|
||||
expect(estimated).toBeGreaterThan(0);
|
||||
expect(builder.willFit()).toBe(true);
|
||||
});
|
||||
});
|
||||
231
packages/qr-export/src/encoder.ts
Normal file
231
packages/qr-export/src/encoder.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* ManaQR Encoder/Decoder
|
||||
*
|
||||
* Encodes/decodes ManaQRExport data for QR codes using gzip compression.
|
||||
*/
|
||||
|
||||
import pako from 'pako';
|
||||
import type { ManaQRExport, EncodeResult, DecodeResult, DecodeError } from './types';
|
||||
import { MANA_QR_LIMITS } from './types';
|
||||
|
||||
/** Prefix for ManaQR data (helps identify valid codes) */
|
||||
export const MANA_QR_PREFIX = 'MANA1:';
|
||||
|
||||
/** Current format version */
|
||||
export const MANA_QR_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Encode ManaQRExport data to a compressed string for QR codes
|
||||
*/
|
||||
export function encode(data: ManaQRExport): EncodeResult {
|
||||
// Ensure version is set
|
||||
const exportData: ManaQRExport = {
|
||||
...data,
|
||||
v: MANA_QR_VERSION,
|
||||
ts: data.ts || Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Convert to JSON
|
||||
const json = JSON.stringify(exportData);
|
||||
|
||||
// Compress with gzip
|
||||
const compressed = pako.deflate(json);
|
||||
|
||||
// Convert to base64
|
||||
const base64 = uint8ArrayToBase64(compressed);
|
||||
|
||||
// Add prefix
|
||||
const result = MANA_QR_PREFIX + base64;
|
||||
|
||||
return {
|
||||
data: result,
|
||||
size: result.length,
|
||||
fitsInQR: result.length <= MANA_QR_LIMITS.MAX_QR_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a ManaQR string back to ManaQRExport data
|
||||
*/
|
||||
export function decode(qrString: string): DecodeResult {
|
||||
// Check prefix
|
||||
if (!qrString.startsWith(MANA_QR_PREFIX)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_PREFIX',
|
||||
message: `String must start with "${MANA_QR_PREFIX}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract base64 part
|
||||
const base64 = qrString.slice(MANA_QR_PREFIX.length);
|
||||
|
||||
// Decode base64
|
||||
let compressed: Uint8Array;
|
||||
try {
|
||||
compressed = base64ToUint8Array(base64);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_BASE64',
|
||||
message: 'Invalid base64 encoding',
|
||||
};
|
||||
}
|
||||
|
||||
// Decompress
|
||||
let json: string;
|
||||
try {
|
||||
json = pako.inflate(compressed, { to: 'string' });
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'DECOMPRESSION_FAILED',
|
||||
message: 'Failed to decompress data',
|
||||
};
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(json);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_JSON',
|
||||
message: 'Invalid JSON data',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const validation = validateExport(data);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error,
|
||||
message: validation.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as ManaQRExport,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the encoded size without actually encoding
|
||||
* Useful for checking if data will fit before encoding
|
||||
*/
|
||||
export function estimateSize(data: ManaQRExport): number {
|
||||
const json = JSON.stringify(data);
|
||||
// Rough estimate: gzip typically achieves 60-70% compression on JSON
|
||||
// Base64 adds ~33% overhead
|
||||
// Add prefix length
|
||||
const estimatedCompressed = json.length * 0.35;
|
||||
const estimatedBase64 = estimatedCompressed * 1.33;
|
||||
return Math.ceil(estimatedBase64 + MANA_QR_PREFIX.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data will likely fit in a single QR code
|
||||
*/
|
||||
export function willFitInQR(data: ManaQRExport): boolean {
|
||||
return estimateSize(data) <= MANA_QR_LIMITS.MAX_QR_BYTES;
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error: DecodeError;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function validateExport(data: unknown): ValidationResult {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Data must be an object',
|
||||
};
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
// Check version
|
||||
if (obj.v !== MANA_QR_VERSION) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_VERSION',
|
||||
message: `Unsupported version: ${obj.v}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (typeof obj.ts !== 'number') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Missing or invalid timestamp',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof obj.u !== 'object' || obj.u === null) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Missing user context',
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(obj.c)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Contacts must be an array',
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(obj.e)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Events must be an array',
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(obj.t)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'INVALID_STRUCTURE',
|
||||
message: 'Todos must be an array',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, error: 'INVALID_STRUCTURE', message: '' };
|
||||
}
|
||||
|
||||
// --- Base64 utilities (browser & Node compatible) ---
|
||||
|
||||
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === 'function') {
|
||||
// Browser
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
// Node.js
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
if (typeof atob === 'function') {
|
||||
// Browser
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
// Node.js
|
||||
return new Uint8Array(Buffer.from(base64, 'base64'));
|
||||
}
|
||||
140
packages/qr-export/src/generate.ts
Normal file
140
packages/qr-export/src/generate.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* QR Code Generation utilities
|
||||
*
|
||||
* Framework-agnostic functions for generating QR codes.
|
||||
*/
|
||||
|
||||
import QRCode from 'qrcode';
|
||||
import type { ManaQRExport, EncodeResult } from './types';
|
||||
import { encode } from './encoder';
|
||||
|
||||
/** QR Code generation options */
|
||||
export interface QRGenerateOptions {
|
||||
/** Width/height in pixels (default: 300) */
|
||||
size?: number;
|
||||
/** Margin in modules (default: 2) */
|
||||
margin?: number;
|
||||
/** Error correction level (default: 'M') */
|
||||
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
|
||||
/** Dark color (default: '#000000') */
|
||||
darkColor?: string;
|
||||
/** Light color (default: '#ffffff') */
|
||||
lightColor?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<QRGenerateOptions> = {
|
||||
size: 300,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
darkColor: '#000000',
|
||||
lightColor: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate QR code as Data URL (for <img src="...">)
|
||||
*/
|
||||
export async function toDataURL(
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options?: QRGenerateOptions
|
||||
): Promise<string> {
|
||||
const qrData = resolveData(data);
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return QRCode.toDataURL(qrData, {
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
margin: opts.margin,
|
||||
width: opts.size,
|
||||
color: {
|
||||
dark: opts.darkColor,
|
||||
light: opts.lightColor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as SVG string
|
||||
*/
|
||||
export async function toSVG(
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options?: QRGenerateOptions
|
||||
): Promise<string> {
|
||||
const qrData = resolveData(data);
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return QRCode.toString(qrData, {
|
||||
type: 'svg',
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
margin: opts.margin,
|
||||
width: opts.size,
|
||||
color: {
|
||||
dark: opts.darkColor,
|
||||
light: opts.lightColor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for terminal output
|
||||
*/
|
||||
export async function toTerminal(data: string | ManaQRExport | EncodeResult): Promise<string> {
|
||||
const qrData = resolveData(data);
|
||||
return QRCode.toString(qrData, { type: 'terminal', small: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw QR code to canvas element
|
||||
*/
|
||||
export async function toCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options?: QRGenerateOptions
|
||||
): Promise<void> {
|
||||
const qrData = resolveData(data);
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
await QRCode.toCanvas(canvas, qrData, {
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
margin: opts.margin,
|
||||
width: opts.size,
|
||||
color: {
|
||||
dark: opts.darkColor,
|
||||
light: opts.lightColor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save QR code to file (Node.js only)
|
||||
*/
|
||||
export async function toFile(
|
||||
path: string,
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options?: QRGenerateOptions
|
||||
): Promise<void> {
|
||||
const qrData = resolveData(data);
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
await QRCode.toFile(path, qrData, {
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
margin: opts.margin,
|
||||
width: opts.size,
|
||||
color: {
|
||||
dark: opts.darkColor,
|
||||
light: opts.lightColor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function resolveData(data: string | ManaQRExport | EncodeResult): string {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
if ('data' in data && typeof data.data === 'string') {
|
||||
// EncodeResult
|
||||
return data.data;
|
||||
}
|
||||
// ManaQRExport - encode it
|
||||
return encode(data as ManaQRExport).data;
|
||||
}
|
||||
66
packages/qr-export/src/index.ts
Normal file
66
packages/qr-export/src/index.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* @manacore/qr-export
|
||||
*
|
||||
* QR code export/import for personal data (contacts, events, todos, user context).
|
||||
* Compresses data to fit within a single QR code (~2,500 bytes).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createManaQRExport, decode } from '@manacore/qr-export';
|
||||
*
|
||||
* // Create export
|
||||
* const result = createManaQRExport()
|
||||
* .user({ n: 'Till', z: 'Europe/Berlin', l: 'de' })
|
||||
* .addContact({ n: 'Mama', p: '+491701234567', r: 1 })
|
||||
* .addEvent({ t: 'Meeting', s: Date.now() / 1000, d: 60 })
|
||||
* .addTodo({ t: 'Backup machen', p: 1, d: 7 })
|
||||
* .encode();
|
||||
*
|
||||
* console.log(result.data); // "MANA1:eJy..." (use for QR code)
|
||||
* console.log(result.fitsInQR); // true
|
||||
*
|
||||
* // Decode
|
||||
* const decoded = decode(result.data);
|
||||
* if (decoded.success) {
|
||||
* console.log(decoded.data.u.n); // "Till"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ManaQRExport,
|
||||
ManaQRUserContext,
|
||||
ManaQRContact,
|
||||
ManaQREvent,
|
||||
ManaQRTodo,
|
||||
ContactRelation,
|
||||
TodoPriority,
|
||||
EncodeResult,
|
||||
DecodeResult,
|
||||
DecodeError,
|
||||
} from './types';
|
||||
|
||||
export { MANA_QR_LIMITS, RELATION_LABELS, PRIORITY_LABELS } from './types';
|
||||
|
||||
// Encoder/Decoder
|
||||
export {
|
||||
encode,
|
||||
decode,
|
||||
estimateSize,
|
||||
willFitInQR,
|
||||
MANA_QR_PREFIX,
|
||||
MANA_QR_VERSION,
|
||||
} from './encoder';
|
||||
|
||||
// Selectors
|
||||
export type { ContactInput, EventInput, TodoInput } from './selectors';
|
||||
|
||||
export { selectTopContacts, selectUpcomingEvents, selectPriorityTodos } from './selectors';
|
||||
|
||||
// Builder
|
||||
export { ManaQRExportBuilder, createManaQRExport, contact, event, todo } from './builder';
|
||||
|
||||
// QR Code Generation
|
||||
export type { QRGenerateOptions } from './generate';
|
||||
export { toDataURL, toSVG, toTerminal, toCanvas, toFile } from './generate';
|
||||
232
packages/qr-export/src/selectors.ts
Normal file
232
packages/qr-export/src/selectors.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Selectors for choosing the most important data for QR export
|
||||
*
|
||||
* These helpers select and prioritize data to fit within QR code limits.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ManaQRContact,
|
||||
ManaQREvent,
|
||||
ManaQRTodo,
|
||||
ContactRelation,
|
||||
TodoPriority,
|
||||
} from './types';
|
||||
import { MANA_QR_LIMITS } from './types';
|
||||
|
||||
// --- Contact Selectors ---
|
||||
|
||||
/** Input format for contact selection */
|
||||
export interface ContactInput {
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
relation?: ContactRelation;
|
||||
/** Higher = more important */
|
||||
importance?: number;
|
||||
/** Is emergency contact */
|
||||
isEmergency?: boolean;
|
||||
/** Is family member */
|
||||
isFamily?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most important contacts for QR export
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Emergency contacts (relation = 5)
|
||||
* 2. Family (relation = 1)
|
||||
* 3. Partner (relation = 2)
|
||||
* 4. By importance score
|
||||
* 5. By provided order
|
||||
*/
|
||||
export function selectTopContacts(
|
||||
contacts: ContactInput[],
|
||||
limit: number = MANA_QR_LIMITS.MAX_CONTACTS
|
||||
): ManaQRContact[] {
|
||||
const scored = contacts.map((c, index) => {
|
||||
let score = 0;
|
||||
|
||||
// Emergency contacts highest priority
|
||||
if (c.isEmergency || c.relation === 5) score += 1000;
|
||||
// Family second
|
||||
if (c.isFamily || c.relation === 1) score += 500;
|
||||
// Partner third
|
||||
if (c.relation === 2) score += 400;
|
||||
// Work contacts
|
||||
if (c.relation === 4) score += 100;
|
||||
// Custom importance
|
||||
if (c.importance) score += c.importance;
|
||||
// Prefer contacts with phone numbers
|
||||
if (c.phone) score += 50;
|
||||
// Original order as tiebreaker
|
||||
score -= index * 0.01;
|
||||
|
||||
return { contact: c, score };
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Take top N and convert to compact format
|
||||
return scored.slice(0, limit).map(({ contact }) => ({
|
||||
n: contact.name,
|
||||
p: contact.phone,
|
||||
e: contact.email,
|
||||
r: contact.relation || 3, // Default to "Freund"
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Event Selectors ---
|
||||
|
||||
/** Input format for event selection */
|
||||
export interface EventInput {
|
||||
title: string;
|
||||
/** Start time as Date or Unix timestamp (ms) */
|
||||
start: Date | number;
|
||||
/** End time as Date or Unix timestamp (ms) */
|
||||
end?: Date | number;
|
||||
/** Duration in minutes (alternative to end) */
|
||||
durationMinutes?: number;
|
||||
location?: string;
|
||||
/** Is all-day event */
|
||||
allDay?: boolean;
|
||||
/** Higher = more important */
|
||||
importance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select upcoming events for QR export
|
||||
*
|
||||
* Only includes future events, sorted by start time.
|
||||
* Truncates titles to fit size limits.
|
||||
*/
|
||||
export function selectUpcomingEvents(
|
||||
events: EventInput[],
|
||||
limit: number = MANA_QR_LIMITS.MAX_EVENTS,
|
||||
fromDate: Date = new Date()
|
||||
): ManaQREvent[] {
|
||||
const fromTimestamp = fromDate.getTime();
|
||||
|
||||
// Filter and sort future events
|
||||
const futureEvents = events
|
||||
.map((e) => ({
|
||||
event: e,
|
||||
startMs: e.start instanceof Date ? e.start.getTime() : e.start,
|
||||
}))
|
||||
.filter(({ startMs }) => startMs >= fromTimestamp)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
// Take top N and convert to compact format
|
||||
return futureEvents.slice(0, limit).map(({ event, startMs }) => {
|
||||
// Calculate duration
|
||||
let durationMinutes = event.durationMinutes || 60;
|
||||
if (event.end) {
|
||||
const endMs = event.end instanceof Date ? event.end.getTime() : event.end;
|
||||
durationMinutes = Math.round((endMs - startMs) / 60000);
|
||||
}
|
||||
if (event.allDay) {
|
||||
durationMinutes = 1440; // 24 hours
|
||||
}
|
||||
|
||||
return {
|
||||
t: truncate(event.title, MANA_QR_LIMITS.MAX_EVENT_TITLE),
|
||||
s: Math.floor(startMs / 1000), // Unix seconds
|
||||
d: durationMinutes,
|
||||
l: event.location ? truncate(event.location, 20) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Todo Selectors ---
|
||||
|
||||
/** Input format for todo selection */
|
||||
export interface TodoInput {
|
||||
title: string;
|
||||
priority?: TodoPriority;
|
||||
/** Due date as Date or Unix timestamp (ms) */
|
||||
dueDate?: Date | number;
|
||||
/** Is completed */
|
||||
completed?: boolean;
|
||||
/** Higher = more important */
|
||||
importance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most important todos for QR export
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Priority 1 (high)
|
||||
* 2. Priority 2 (medium)
|
||||
* 3. Priority 3 (low)
|
||||
* 4. By due date (sooner = higher)
|
||||
* 5. By importance score
|
||||
*
|
||||
* Excludes completed todos.
|
||||
*/
|
||||
export function selectPriorityTodos(
|
||||
todos: TodoInput[],
|
||||
limit: number = MANA_QR_LIMITS.MAX_TODOS,
|
||||
fromDate: Date = new Date()
|
||||
): ManaQRTodo[] {
|
||||
const fromTimestamp = fromDate.getTime();
|
||||
const fromDayStart = new Date(fromDate);
|
||||
fromDayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// Filter out completed todos and score the rest
|
||||
const scored = todos
|
||||
.filter((t) => !t.completed)
|
||||
.map((t, index) => {
|
||||
let score = 0;
|
||||
|
||||
// Priority is most important
|
||||
const priority = t.priority || 3;
|
||||
score += (4 - priority) * 1000; // P1=3000, P2=2000, P3=1000
|
||||
|
||||
// Due date matters
|
||||
if (t.dueDate) {
|
||||
const dueMs = t.dueDate instanceof Date ? t.dueDate.getTime() : t.dueDate;
|
||||
const daysUntilDue = Math.floor((dueMs - fromTimestamp) / 86400000);
|
||||
// Overdue items get highest boost
|
||||
if (daysUntilDue < 0) {
|
||||
score += 500;
|
||||
} else if (daysUntilDue <= 7) {
|
||||
score += 300 - daysUntilDue * 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom importance
|
||||
if (t.importance) score += t.importance;
|
||||
|
||||
// Original order as tiebreaker
|
||||
score -= index * 0.01;
|
||||
|
||||
return { todo: t, score };
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Take top N and convert to compact format
|
||||
return scored.slice(0, limit).map(({ todo }) => {
|
||||
let dueDays: number | undefined;
|
||||
if (todo.dueDate) {
|
||||
const dueMs = todo.dueDate instanceof Date ? todo.dueDate.getTime() : todo.dueDate;
|
||||
const daysFromNow = Math.floor((dueMs - fromDayStart.getTime()) / 86400000);
|
||||
// Clamp to 0-255 range
|
||||
dueDays = Math.max(0, Math.min(255, daysFromNow));
|
||||
}
|
||||
|
||||
return {
|
||||
t: truncate(todo.title, MANA_QR_LIMITS.MAX_TODO_TITLE),
|
||||
p: todo.priority || 3,
|
||||
d: dueDays,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
144
packages/qr-export/src/svelte/ManaQRCode.svelte
Normal file
144
packages/qr-export/src/svelte/ManaQRCode.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<script lang="ts">
|
||||
import { toDataURL, toSVG } from '../generate';
|
||||
import type { ManaQRExport, EncodeResult } from '../types';
|
||||
|
||||
interface Props {
|
||||
/** Encoded QR data string, EncodeResult, or ManaQRExport object */
|
||||
data: string | ManaQRExport | EncodeResult;
|
||||
/** Width/height in pixels */
|
||||
size?: number;
|
||||
/** Margin in QR modules */
|
||||
margin?: number;
|
||||
/** Error correction level */
|
||||
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
|
||||
/** Dark color (QR modules) */
|
||||
darkColor?: string;
|
||||
/** Light color (background) */
|
||||
lightColor?: string;
|
||||
/** Render as SVG instead of PNG (sharper, smaller file) */
|
||||
svg?: boolean;
|
||||
/** Alt text for accessibility */
|
||||
alt?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
size = 300,
|
||||
margin = 2,
|
||||
errorCorrectionLevel = 'M',
|
||||
darkColor = '#000000',
|
||||
lightColor = '#ffffff',
|
||||
svg = false,
|
||||
alt = 'ManaQR Code',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let qrOutput = $state<string>('');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const options = $derived({
|
||||
size,
|
||||
margin,
|
||||
errorCorrectionLevel,
|
||||
darkColor,
|
||||
lightColor,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
generateQR();
|
||||
});
|
||||
|
||||
async function generateQR() {
|
||||
try {
|
||||
error = null;
|
||||
if (svg) {
|
||||
qrOutput = await toSVG(data, options);
|
||||
} else {
|
||||
qrOutput = await toDataURL(data, options);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to generate QR code';
|
||||
qrOutput = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="mana-qr-error {className}" role="alert">
|
||||
<span>QR Error: {error}</span>
|
||||
</div>
|
||||
{:else if qrOutput}
|
||||
{#if svg}
|
||||
<div
|
||||
class="mana-qr-svg {className}"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
>
|
||||
{@html qrOutput}
|
||||
</div>
|
||||
{:else}
|
||||
<img src={qrOutput} {alt} class="mana-qr-img {className}" width={size} height={size} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="mana-qr-loading {className}"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
role="status"
|
||||
aria-label="Loading QR code"
|
||||
>
|
||||
<span class="mana-qr-spinner"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.mana-qr-img {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mana-qr-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mana-qr-svg :global(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mana-qr-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mana-qr-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: #333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.mana-qr-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
338
packages/qr-export/src/svelte/ManaQRScanner.svelte
Normal file
338
packages/qr-export/src/svelte/ManaQRScanner.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script lang="ts">
|
||||
import { decode } from '../encoder';
|
||||
import type { ManaQRExport, DecodeResult } from '../types';
|
||||
|
||||
interface Props {
|
||||
/** Called when a valid ManaQR code is scanned */
|
||||
onScan?: (data: ManaQRExport) => void;
|
||||
/** Called on any scan error */
|
||||
onError?: (error: string) => void;
|
||||
/** Width of the scanner view */
|
||||
width?: number;
|
||||
/** Height of the scanner view */
|
||||
height?: number;
|
||||
/** Show scan overlay guide */
|
||||
showOverlay?: boolean;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onScan,
|
||||
onError,
|
||||
width = 300,
|
||||
height = 300,
|
||||
showOverlay = true,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let videoElement = $state<HTMLVideoElement | null>(null);
|
||||
let canvasElement = $state<HTMLCanvasElement | null>(null);
|
||||
let isScanning = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let stream = $state<MediaStream | null>(null);
|
||||
|
||||
// Dynamically import jsQR only when needed
|
||||
let jsQR: typeof import('jsqr').default | null = null;
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopScanning();
|
||||
};
|
||||
});
|
||||
|
||||
export async function startScanning() {
|
||||
if (isScanning) return;
|
||||
|
||||
try {
|
||||
// Lazy load jsQR
|
||||
if (!jsQR) {
|
||||
const module = await import('jsqr');
|
||||
jsQR = module.default;
|
||||
}
|
||||
|
||||
error = null;
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width, height },
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
isScanning = true;
|
||||
requestAnimationFrame(scanFrame);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Camera access denied';
|
||||
error = msg;
|
||||
onError?.(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopScanning() {
|
||||
isScanning = false;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scanFrame() {
|
||||
if (!isScanning || !videoElement || !canvasElement || !jsQR) return;
|
||||
|
||||
const ctx = canvasElement.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (videoElement.readyState === videoElement.HAVE_ENOUGH_DATA) {
|
||||
canvasElement.width = videoElement.videoWidth;
|
||||
canvasElement.height = videoElement.videoHeight;
|
||||
ctx.drawImage(videoElement, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvasElement.width, canvasElement.height);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
|
||||
if (code?.data) {
|
||||
handleScan(code.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (isScanning) {
|
||||
requestAnimationFrame(scanFrame);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScan(qrData: string) {
|
||||
const result = decode(qrData);
|
||||
|
||||
if (result.success) {
|
||||
stopScanning();
|
||||
onScan?.(result.data);
|
||||
} else {
|
||||
// Not a valid ManaQR - might be scanning something else
|
||||
// Don't stop, keep scanning
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// Lazy load jsQR
|
||||
if (!jsQR) {
|
||||
const module = await import('jsqr');
|
||||
jsQR = module.default;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
await new Promise((resolve) => (img.onload = resolve));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
|
||||
if (code?.data) {
|
||||
handleScan(code.data);
|
||||
} else {
|
||||
const msg = 'No QR code found in image';
|
||||
error = msg;
|
||||
onError?.(msg);
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(img.src);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to read image';
|
||||
error = msg;
|
||||
onError?.(msg);
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mana-qr-scanner {className}" style="width: {width}px;">
|
||||
{#if error}
|
||||
<div class="scanner-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button onclick={() => (error = null)}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scanner-view" style="width: {width}px; height: {height}px;">
|
||||
<video bind:this={videoElement} playsinline muted class:hidden={!isScanning}></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
|
||||
{#if showOverlay && isScanning}
|
||||
<div class="scanner-overlay">
|
||||
<div class="scanner-frame"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isScanning}
|
||||
<div class="scanner-placeholder">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
<span>Ready to scan</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="scanner-controls">
|
||||
{#if isScanning}
|
||||
<button class="btn-stop" onclick={stopScanning}>Stop Scanning</button>
|
||||
{:else}
|
||||
<button class="btn-start" onclick={startScanning}>Start Camera</button>
|
||||
<label class="btn-upload">
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" onchange={handleFileUpload} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mana-qr-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scanner-view {
|
||||
position: relative;
|
||||
background: #111;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanner-view video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scanner-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scanner-frame {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 0 0 9999px rgba(0, 0, 0, 0.4),
|
||||
inset 0 0 20px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.scanner-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.scanner-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scanner-controls button,
|
||||
.scanner-controls label {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-start:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: #c00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-stop:hover {
|
||||
background: #a00;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-upload input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scanner-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scanner-error button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
36
packages/qr-export/src/svelte/index.ts
Normal file
36
packages/qr-export/src/svelte/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Svelte components for ManaQR
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { ManaQRCode, ManaQRScanner } from '@manacore/qr-export/svelte';
|
||||
* import { createManaQRExport } from '@manacore/qr-export';
|
||||
*
|
||||
* const exportData = createManaQRExport()
|
||||
* .user({ n: 'Till' })
|
||||
* .encode();
|
||||
*
|
||||
* function handleScan(data) {
|
||||
* console.log('Scanned:', data);
|
||||
* }
|
||||
* </script>
|
||||
*
|
||||
* <ManaQRCode data={exportData} size={250} />
|
||||
* <ManaQRScanner onScan={handleScan} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { default as ManaQRCode } from './ManaQRCode.svelte';
|
||||
export { default as ManaQRScanner } from './ManaQRScanner.svelte';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
ManaQRExport,
|
||||
ManaQRUserContext,
|
||||
ManaQRContact,
|
||||
ManaQREvent,
|
||||
ManaQRTodo,
|
||||
EncodeResult,
|
||||
DecodeResult,
|
||||
} from '../types';
|
||||
142
packages/qr-export/src/types.ts
Normal file
142
packages/qr-export/src/types.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* ManaQR Export Format Types
|
||||
*
|
||||
* Compact format for exporting personal data to QR codes.
|
||||
* Target size: ~2,500 bytes (fits in single QR code)
|
||||
*/
|
||||
|
||||
/** Relation type for contacts */
|
||||
export type ContactRelation =
|
||||
| 1 // Familie
|
||||
| 2 // Partner
|
||||
| 3 // Freund
|
||||
| 4 // Arbeit
|
||||
| 5; // Notfall
|
||||
|
||||
/** Priority level for todos */
|
||||
export type TodoPriority =
|
||||
| 1 // Hoch
|
||||
| 2 // Mittel
|
||||
| 3; // Niedrig
|
||||
|
||||
/** Minimal contact data */
|
||||
export interface ManaQRContact {
|
||||
/** Name (required) */
|
||||
n: string;
|
||||
/** Phone number */
|
||||
p?: string;
|
||||
/** Email */
|
||||
e?: string;
|
||||
/** Relation type */
|
||||
r: ContactRelation;
|
||||
}
|
||||
|
||||
/** Minimal calendar event data */
|
||||
export interface ManaQREvent {
|
||||
/** Title (max 30 chars recommended) */
|
||||
t: string;
|
||||
/** Start time as Unix timestamp (seconds) */
|
||||
s: number;
|
||||
/** Duration in minutes */
|
||||
d: number;
|
||||
/** Location (short) */
|
||||
l?: string;
|
||||
}
|
||||
|
||||
/** Minimal todo data */
|
||||
export interface ManaQRTodo {
|
||||
/** Title (max 40 chars recommended) */
|
||||
t: string;
|
||||
/** Priority 1-3 */
|
||||
p: TodoPriority;
|
||||
/** Due date as days from export date (0-255) */
|
||||
d?: number;
|
||||
}
|
||||
|
||||
/** User context data */
|
||||
export interface ManaQRUserContext {
|
||||
/** Name */
|
||||
n: string;
|
||||
/** Timezone (e.g., "Europe/Berlin") */
|
||||
z?: string;
|
||||
/** Language code (e.g., "de") */
|
||||
l?: string;
|
||||
/** Location/City */
|
||||
w?: string;
|
||||
/** Profession/Job */
|
||||
b?: string;
|
||||
/** Status/Motto */
|
||||
m?: string;
|
||||
}
|
||||
|
||||
/** Main export format */
|
||||
export interface ManaQRExport {
|
||||
/** Format version */
|
||||
v: 1;
|
||||
/** Export timestamp (Unix seconds) */
|
||||
ts: number;
|
||||
/** User context */
|
||||
u: ManaQRUserContext;
|
||||
/** Top contacts (recommended: 5) */
|
||||
c: ManaQRContact[];
|
||||
/** Upcoming events (recommended: 10) */
|
||||
e: ManaQREvent[];
|
||||
/** Priority todos (recommended: 15) */
|
||||
t: ManaQRTodo[];
|
||||
}
|
||||
|
||||
/** Result of encoding */
|
||||
export interface EncodeResult {
|
||||
/** Encoded string for QR code */
|
||||
data: string;
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
/** Whether it fits in a single QR code */
|
||||
fitsInQR: boolean;
|
||||
}
|
||||
|
||||
/** Decode error types */
|
||||
export type DecodeError =
|
||||
| 'INVALID_PREFIX'
|
||||
| 'INVALID_BASE64'
|
||||
| 'DECOMPRESSION_FAILED'
|
||||
| 'INVALID_JSON'
|
||||
| 'INVALID_VERSION'
|
||||
| 'INVALID_STRUCTURE';
|
||||
|
||||
/** Result of decoding */
|
||||
export type DecodeResult =
|
||||
| { success: true; data: ManaQRExport }
|
||||
| { success: false; error: DecodeError; message: string };
|
||||
|
||||
/** Limits for QR export */
|
||||
export const MANA_QR_LIMITS = {
|
||||
/** Max bytes for QR code (with some buffer) */
|
||||
MAX_QR_BYTES: 2500,
|
||||
/** Recommended max contacts */
|
||||
MAX_CONTACTS: 5,
|
||||
/** Recommended max events */
|
||||
MAX_EVENTS: 10,
|
||||
/** Recommended max todos */
|
||||
MAX_TODOS: 15,
|
||||
/** Max title length for events */
|
||||
MAX_EVENT_TITLE: 30,
|
||||
/** Max title length for todos */
|
||||
MAX_TODO_TITLE: 40,
|
||||
} as const;
|
||||
|
||||
/** Relation labels (for UI) */
|
||||
export const RELATION_LABELS: Record<ContactRelation, string> = {
|
||||
1: 'Familie',
|
||||
2: 'Partner',
|
||||
3: 'Freund',
|
||||
4: 'Arbeit',
|
||||
5: 'Notfall',
|
||||
};
|
||||
|
||||
/** Priority labels (for UI) */
|
||||
export const PRIORITY_LABELS: Record<TodoPriority, string> = {
|
||||
1: 'Hoch',
|
||||
2: 'Mittel',
|
||||
3: 'Niedrig',
|
||||
};
|
||||
124
packages/qr-export/src/wallpaper.ts
Normal file
124
packages/qr-export/src/wallpaper.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* QR Code Wallpaper Generation
|
||||
*
|
||||
* Creates device wallpapers from QR codes using @manacore/wallpaper-generator.
|
||||
*/
|
||||
|
||||
import type { ManaQRExport, EncodeResult } from './types';
|
||||
import type {
|
||||
WallpaperOptions,
|
||||
WallpaperResult,
|
||||
DeviceOption,
|
||||
Layout,
|
||||
Background,
|
||||
} from '@manacore/wallpaper-generator';
|
||||
import {
|
||||
createWallpaperGenerator,
|
||||
DEFAULT_CENTER_LAYOUT,
|
||||
DEFAULT_BACKGROUND,
|
||||
} from '@manacore/wallpaper-generator';
|
||||
import { toDataURL } from './generate';
|
||||
|
||||
/** Options for QR wallpaper generation */
|
||||
export interface QRWallpaperOptions {
|
||||
/** Target device (preset ID like 'iphone-15-pro-max' or custom dimensions) */
|
||||
device: DeviceOption;
|
||||
/** Layout configuration (default: center) */
|
||||
layout?: Layout;
|
||||
/** Background configuration (default: dark gradient) */
|
||||
background?: Background;
|
||||
/** QR code size before placing on wallpaper (default: 600) */
|
||||
qrSize?: number;
|
||||
/** Output format (default: 'png') */
|
||||
format?: 'png' | 'jpeg';
|
||||
/** JPEG quality 0-100 (default: 90) */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_QR_WALLPAPER_OPTIONS: Partial<QRWallpaperOptions> = {
|
||||
layout: DEFAULT_CENTER_LAYOUT,
|
||||
background: DEFAULT_BACKGROUND,
|
||||
qrSize: 600,
|
||||
format: 'png',
|
||||
quality: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a device wallpaper from QR code data.
|
||||
*
|
||||
* @param data - QR code data (string, ManaQRExport, or EncodeResult)
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with wallpaper result
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { toWallpaper } from '@manacore/qr-export/wallpaper';
|
||||
*
|
||||
* const result = await toWallpaper(encodeResult, {
|
||||
* device: 'iphone-15-pro-max',
|
||||
* layout: { type: 'center', scale: 0.7 },
|
||||
* background: { type: 'gradient', colors: ['#1a1a2e', '#16213e'] },
|
||||
* });
|
||||
*
|
||||
* // result.dataUrl contains the wallpaper as data URL
|
||||
* ```
|
||||
*/
|
||||
export async function toWallpaper(
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options: QRWallpaperOptions
|
||||
): Promise<WallpaperResult> {
|
||||
const opts = { ...DEFAULT_QR_WALLPAPER_OPTIONS, ...options };
|
||||
|
||||
// Generate QR code as data URL
|
||||
const qrDataUrl = await toDataURL(data, {
|
||||
size: opts.qrSize,
|
||||
errorCorrectionLevel: 'M',
|
||||
darkColor: '#000000',
|
||||
lightColor: '#ffffff',
|
||||
});
|
||||
|
||||
// Create wallpaper generator
|
||||
const generator = createWallpaperGenerator();
|
||||
|
||||
// Generate wallpaper
|
||||
const wallpaperOptions: WallpaperOptions = {
|
||||
device: opts.device,
|
||||
layout: opts.layout ?? DEFAULT_CENTER_LAYOUT,
|
||||
background: opts.background ?? DEFAULT_BACKGROUND,
|
||||
format: opts.format,
|
||||
quality: opts.quality,
|
||||
};
|
||||
|
||||
return generator.generate({ type: 'dataUrl', data: qrDataUrl }, wallpaperOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview of the QR wallpaper (smaller, faster).
|
||||
*
|
||||
* @param data - QR code data
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with preview data URL
|
||||
*/
|
||||
export async function toWallpaperPreview(
|
||||
data: string | ManaQRExport | EncodeResult,
|
||||
options: QRWallpaperOptions
|
||||
): Promise<string> {
|
||||
const opts = { ...DEFAULT_QR_WALLPAPER_OPTIONS, ...options };
|
||||
|
||||
// Generate QR code at smaller size for preview
|
||||
const qrSize = opts.qrSize ?? 600;
|
||||
const qrDataUrl = await toDataURL(data, {
|
||||
size: Math.round(qrSize / 2),
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
|
||||
const generator = createWallpaperGenerator();
|
||||
|
||||
const wallpaperOptions: WallpaperOptions = {
|
||||
device: opts.device,
|
||||
layout: opts.layout ?? DEFAULT_CENTER_LAYOUT,
|
||||
background: opts.background ?? DEFAULT_BACKGROUND,
|
||||
};
|
||||
|
||||
return generator.preview({ type: 'dataUrl', data: qrDataUrl }, wallpaperOptions);
|
||||
}
|
||||
18
packages/qr-export/tsconfig.json
Normal file
18
packages/qr-export/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "src/**/*.svelte"]
|
||||
}
|
||||
9
packages/qr-export/vitest.config.ts
Normal file
9
packages/qr-export/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
|
@ -10,11 +10,15 @@
|
|||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./wallpaper": {
|
||||
"types": "./dist/wallpaper.d.ts",
|
||||
"import": "./dist/wallpaper.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"build": "tsup src/index.ts src/wallpaper.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts src/wallpaper.ts --format esm --dts --watch",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
|
|
@ -31,11 +35,15 @@
|
|||
"vitest": "^1.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"sharp": "^0.33.0"
|
||||
"sharp": "^0.33.0",
|
||||
"@manacore/wallpaper-generator": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"sharp": {
|
||||
"optional": true
|
||||
},
|
||||
"@manacore/wallpaper-generator": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
157
packages/spiral-db/src/wallpaper.ts
Normal file
157
packages/spiral-db/src/wallpaper.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Spiral-DB Wallpaper Generation
|
||||
*
|
||||
* Creates device wallpapers from SpiralDB images using @manacore/wallpaper-generator.
|
||||
*/
|
||||
|
||||
import type { SpiralImage } from './types.js';
|
||||
import type {
|
||||
WallpaperOptions,
|
||||
WallpaperResult,
|
||||
DeviceOption,
|
||||
Layout,
|
||||
Background,
|
||||
} from '@manacore/wallpaper-generator';
|
||||
import {
|
||||
createWallpaperGenerator,
|
||||
DEFAULT_CENTER_LAYOUT,
|
||||
DEFAULT_BACKGROUND,
|
||||
} from '@manacore/wallpaper-generator';
|
||||
import { exportToDataUrl } from './png.js';
|
||||
|
||||
/** Options for Spiral wallpaper generation */
|
||||
export interface SpiralWallpaperOptions {
|
||||
/** Target device (preset ID like 'iphone-15-pro-max' or custom dimensions) */
|
||||
device: DeviceOption;
|
||||
/** Layout configuration (default: center) */
|
||||
layout?: Layout;
|
||||
/** Background configuration (default: dark gradient) */
|
||||
background?: Background;
|
||||
/** Scale factor for the spiral image (default: 10 for crisp pixel art) */
|
||||
scale?: number;
|
||||
/** Output format (default: 'png') */
|
||||
format?: 'png' | 'jpeg';
|
||||
/** JPEG quality 0-100 (default: 90) */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SPIRAL_WALLPAPER_OPTIONS: Partial<SpiralWallpaperOptions> = {
|
||||
layout: DEFAULT_CENTER_LAYOUT,
|
||||
background: DEFAULT_BACKGROUND,
|
||||
scale: 10,
|
||||
format: 'png',
|
||||
quality: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a device wallpaper from a SpiralDB image.
|
||||
*
|
||||
* @param image - SpiralDB image
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with wallpaper result
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { SpiralDB, createTodoSchema } from '@manacore/spiral-db';
|
||||
* import { toWallpaper } from '@manacore/spiral-db/wallpaper';
|
||||
*
|
||||
* const db = new SpiralDB({ schema: createTodoSchema() });
|
||||
* db.insert({ title: 'My Todo', ... });
|
||||
*
|
||||
* const image = db.getImage();
|
||||
* const result = await toWallpaper(image, {
|
||||
* device: 'iphone-15-pro-max',
|
||||
* layout: { type: 'corner', position: 'bottom-right', scale: 0.3 },
|
||||
* background: { type: 'gradient', colors: ['#0f0f23', '#1a1a2e'] },
|
||||
* });
|
||||
*
|
||||
* // result.dataUrl contains the wallpaper as data URL
|
||||
* ```
|
||||
*/
|
||||
export async function toWallpaper(
|
||||
image: SpiralImage,
|
||||
options: SpiralWallpaperOptions
|
||||
): Promise<WallpaperResult> {
|
||||
const opts = { ...DEFAULT_SPIRAL_WALLPAPER_OPTIONS, ...options };
|
||||
|
||||
// Convert spiral image to data URL
|
||||
const dataUrl = exportToDataUrl(image);
|
||||
|
||||
// Create wallpaper generator
|
||||
const generator = createWallpaperGenerator();
|
||||
|
||||
// Apply scale to layout
|
||||
const layoutConfig = opts.layout ?? DEFAULT_CENTER_LAYOUT;
|
||||
const scaleValue = opts.scale ?? 10;
|
||||
const layout = applyScaleToLayout(layoutConfig, scaleValue);
|
||||
|
||||
// Generate wallpaper
|
||||
const wallpaperOptions: WallpaperOptions = {
|
||||
device: opts.device,
|
||||
layout,
|
||||
background: opts.background ?? DEFAULT_BACKGROUND,
|
||||
format: opts.format,
|
||||
quality: opts.quality,
|
||||
};
|
||||
|
||||
return generator.generate({ type: 'dataUrl', data: dataUrl }, wallpaperOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview of the Spiral wallpaper (smaller, faster).
|
||||
*
|
||||
* @param image - SpiralDB image
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with preview data URL
|
||||
*/
|
||||
export async function toWallpaperPreview(
|
||||
image: SpiralImage,
|
||||
options: SpiralWallpaperOptions
|
||||
): Promise<string> {
|
||||
const opts = { ...DEFAULT_SPIRAL_WALLPAPER_OPTIONS, ...options };
|
||||
|
||||
// Convert spiral image to data URL
|
||||
const dataUrl = exportToDataUrl(image);
|
||||
|
||||
const generator = createWallpaperGenerator();
|
||||
|
||||
// Apply scale to layout (reduced for preview)
|
||||
const layoutConfig = opts.layout ?? DEFAULT_CENTER_LAYOUT;
|
||||
const scaleValue = opts.scale ?? 10;
|
||||
const layout = applyScaleToLayout(layoutConfig, scaleValue * 0.5);
|
||||
|
||||
const wallpaperOptions: WallpaperOptions = {
|
||||
device: opts.device,
|
||||
layout,
|
||||
background: opts.background ?? DEFAULT_BACKGROUND,
|
||||
};
|
||||
|
||||
return generator.preview({ type: 'dataUrl', data: dataUrl }, wallpaperOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scale factor to layout
|
||||
* For spiral images which are typically small (e.g., 11x11 pixels),
|
||||
* we multiply the scale to make them visible on wallpapers.
|
||||
*/
|
||||
function applyScaleToLayout(layout: Layout, scale: number): Layout {
|
||||
switch (layout.type) {
|
||||
case 'center':
|
||||
return {
|
||||
...layout,
|
||||
scale: (layout.scale ?? 1.0) * scale,
|
||||
};
|
||||
case 'corner':
|
||||
return {
|
||||
...layout,
|
||||
scale: (layout.scale ?? 0.3) * scale,
|
||||
};
|
||||
case 'pattern':
|
||||
return {
|
||||
...layout,
|
||||
scale: (layout.scale ?? 0.5) * scale,
|
||||
};
|
||||
default:
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
38
packages/wallpaper-generator/package.json
Normal file
38
packages/wallpaper-generator/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@manacore/wallpaper-generator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Device wallpaper generator from QR codes and images",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./svelte": "./src/svelte/index.ts",
|
||||
"./presets": "./src/presets/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"sharp": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/wallpaper-generator/src/backgrounds/gradient.ts
Normal file
160
packages/wallpaper-generator/src/backgrounds/gradient.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Gradient Background Renderer
|
||||
*
|
||||
* Creates linear gradients on canvas.
|
||||
*/
|
||||
|
||||
import { parseHexColor } from './solid.js';
|
||||
|
||||
/**
|
||||
* Calculate gradient end points from angle
|
||||
* Angle: 0 = bottom to top, 90 = left to right, 180 = top to bottom
|
||||
*/
|
||||
function getGradientCoordinates(
|
||||
width: number,
|
||||
height: number,
|
||||
angleDegrees: number
|
||||
): { x0: number; y0: number; x1: number; y1: number } {
|
||||
// Convert angle to radians (CSS gradient angles: 0deg = to top, 180deg = to bottom)
|
||||
const angleRad = ((angleDegrees - 90) * Math.PI) / 180;
|
||||
|
||||
// Calculate the diagonal length for proper coverage
|
||||
const diagonal = Math.sqrt(width * width + height * height);
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
const dx = Math.cos(angleRad) * diagonal;
|
||||
const dy = Math.sin(angleRad) * diagonal;
|
||||
|
||||
return {
|
||||
x0: centerX - dx / 2,
|
||||
y0: centerY - dy / 2,
|
||||
x1: centerX + dx / 2,
|
||||
y1: centerY + dy / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill canvas with linear gradient (browser)
|
||||
*/
|
||||
export function fillGradient(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
colors: string[],
|
||||
angle = 180
|
||||
): void {
|
||||
if (colors.length === 0) {
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (colors.length === 1) {
|
||||
ctx.fillStyle = colors[0];
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
const { x0, y0, x1, y1 } = getGradientCoordinates(width, height, angle);
|
||||
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
||||
|
||||
// Distribute color stops evenly
|
||||
colors.forEach((color, index) => {
|
||||
const stop = index / (colors.length - 1);
|
||||
gradient.addColorStop(stop, color);
|
||||
});
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate between two colors
|
||||
*/
|
||||
function interpolateColor(
|
||||
color1: { r: number; g: number; b: number },
|
||||
color2: { r: number; g: number; b: number },
|
||||
t: number
|
||||
): { r: number; g: number; b: number } {
|
||||
return {
|
||||
r: Math.round(color1.r + (color2.r - color1.r) * t),
|
||||
g: Math.round(color1.g + (color2.g - color1.g) * t),
|
||||
b: Math.round(color1.b + (color2.b - color1.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color at position in gradient
|
||||
*/
|
||||
function getGradientColorAt(
|
||||
colors: { r: number; g: number; b: number }[],
|
||||
position: number
|
||||
): { r: number; g: number; b: number } {
|
||||
if (colors.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
if (colors.length === 1) return colors[0];
|
||||
if (position <= 0) return colors[0];
|
||||
if (position >= 1) return colors[colors.length - 1];
|
||||
|
||||
const scaledPosition = position * (colors.length - 1);
|
||||
const index = Math.floor(scaledPosition);
|
||||
const t = scaledPosition - index;
|
||||
|
||||
return interpolateColor(colors[index], colors[Math.min(index + 1, colors.length - 1)], t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gradient buffer for Node.js/Sharp
|
||||
*/
|
||||
export function createGradientBuffer(
|
||||
width: number,
|
||||
height: number,
|
||||
colors: string[],
|
||||
angle = 180
|
||||
): Uint8Array {
|
||||
const buffer = new Uint8Array(width * height * 3);
|
||||
|
||||
if (colors.length === 0) {
|
||||
return buffer; // All zeros (black)
|
||||
}
|
||||
|
||||
const parsedColors = colors.map(parseHexColor);
|
||||
|
||||
if (colors.length === 1) {
|
||||
const color = parsedColors[0];
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
buffer[i * 3] = color.r;
|
||||
buffer[i * 3 + 1] = color.g;
|
||||
buffer[i * 3 + 2] = color.b;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Convert angle to radians for calculation
|
||||
const angleRad = ((angle - 90) * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
// Calculate the projection for each pixel
|
||||
const diagonal = Math.sqrt(width * width + height * height);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Calculate position along gradient axis
|
||||
const dx = x - centerX;
|
||||
const dy = y - centerY;
|
||||
const projection = (dx * cos + dy * sin) / diagonal + 0.5;
|
||||
|
||||
const color = getGradientColorAt(parsedColors, Math.max(0, Math.min(1, projection)));
|
||||
const i = (y * width + x) * 3;
|
||||
buffer[i] = color.r;
|
||||
buffer[i + 1] = color.g;
|
||||
buffer[i + 2] = color.b;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
6
packages/wallpaper-generator/src/backgrounds/index.ts
Normal file
6
packages/wallpaper-generator/src/backgrounds/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Background Exports
|
||||
*/
|
||||
|
||||
export { fillSolid, createSolidBuffer, parseHexColor } from './solid.js';
|
||||
export { fillGradient, createGradientBuffer } from './gradient.js';
|
||||
57
packages/wallpaper-generator/src/backgrounds/solid.ts
Normal file
57
packages/wallpaper-generator/src/backgrounds/solid.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Solid Background Renderer
|
||||
*
|
||||
* Fills canvas with a solid color.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse hex color to RGB values
|
||||
*/
|
||||
export function parseHexColor(hex: string): { r: number; g: number; b: number } {
|
||||
// Remove # if present
|
||||
const cleanHex = hex.replace('#', '');
|
||||
|
||||
// Handle shorthand hex (e.g., #fff)
|
||||
const fullHex =
|
||||
cleanHex.length === 3
|
||||
? cleanHex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: cleanHex;
|
||||
|
||||
const r = parseInt(fullHex.substring(0, 2), 16);
|
||||
const g = parseInt(fullHex.substring(2, 4), 16);
|
||||
const b = parseInt(fullHex.substring(4, 6), 16);
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill canvas with solid color (browser)
|
||||
*/
|
||||
export function fillSolid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string
|
||||
): void {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create solid color buffer for Node.js/Sharp
|
||||
*/
|
||||
export function createSolidBuffer(width: number, height: number, color: string): Uint8Array {
|
||||
const { r, g, b } = parseHexColor(color);
|
||||
const buffer = new Uint8Array(width * height * 3);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
buffer[i * 3] = r;
|
||||
buffer[i * 3 + 1] = g;
|
||||
buffer[i * 3 + 2] = b;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
140
packages/wallpaper-generator/src/index.ts
Normal file
140
packages/wallpaper-generator/src/index.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* @manacore/wallpaper-generator
|
||||
*
|
||||
* Device wallpaper generator from QR codes, Spiral-DB images, and other sources.
|
||||
* Supports both browser (Canvas) and Node.js (Sharp) environments.
|
||||
*
|
||||
* @example Browser usage:
|
||||
* ```ts
|
||||
* import { createWallpaperGenerator } from '@manacore/wallpaper-generator';
|
||||
*
|
||||
* const generator = createWallpaperGenerator();
|
||||
*
|
||||
* const result = await generator.generate(
|
||||
* { type: 'dataUrl', data: qrCodeDataUrl },
|
||||
* {
|
||||
* device: 'iphone-15-pro-max',
|
||||
* layout: { type: 'center', scale: 0.8 },
|
||||
* background: { type: 'gradient', colors: ['#1a1a2e', '#16213e'] },
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* // Download the wallpaper
|
||||
* downloadWallpaper(result, 'my-wallpaper.png');
|
||||
* ```
|
||||
*
|
||||
* @example Node.js usage:
|
||||
* ```ts
|
||||
* import { createWallpaperGenerator, saveWallpaperToFile } from '@manacore/wallpaper-generator';
|
||||
*
|
||||
* const generator = createWallpaperGenerator();
|
||||
*
|
||||
* const result = await generator.generate(
|
||||
* { type: 'dataUrl', data: imageDataUrl },
|
||||
* {
|
||||
* device: 'desktop-4k',
|
||||
* layout: { type: 'corner', position: 'bottom-right', scale: 0.2 },
|
||||
* background: { type: 'solid', color: '#0f0f23' },
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* await saveWallpaperToFile(result, './wallpaper.png');
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
// Image Sources
|
||||
ImageSource,
|
||||
DataUrlSource,
|
||||
CanvasSource,
|
||||
BufferSource,
|
||||
// Device Presets
|
||||
DevicePreset,
|
||||
DeviceCategory,
|
||||
CustomDevice,
|
||||
DeviceOption,
|
||||
// Layouts
|
||||
Layout,
|
||||
CenterLayout,
|
||||
CornerLayout,
|
||||
PatternLayout,
|
||||
CornerPosition,
|
||||
// Backgrounds
|
||||
Background,
|
||||
SolidBackground,
|
||||
GradientBackground,
|
||||
// Options & Results
|
||||
WallpaperOptions,
|
||||
WallpaperResult,
|
||||
OutputFormat,
|
||||
// Generator Interface
|
||||
WallpaperGenerator,
|
||||
// Svelte Props
|
||||
WallpaperModalProps,
|
||||
} from './types.js';
|
||||
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_CENTER_LAYOUT,
|
||||
DEFAULT_CORNER_LAYOUT,
|
||||
DEFAULT_PATTERN_LAYOUT,
|
||||
DEFAULT_BACKGROUND,
|
||||
GRADIENT_PRESETS,
|
||||
} from './types.js';
|
||||
|
||||
// Device Presets
|
||||
export {
|
||||
PHONE_PRESETS,
|
||||
TABLET_PRESETS,
|
||||
DESKTOP_PRESETS,
|
||||
ALL_DEVICE_PRESETS,
|
||||
getDevicePreset,
|
||||
getPresetsByCategory,
|
||||
getRecommendedPresets,
|
||||
} from './presets/index.js';
|
||||
|
||||
// Renderers
|
||||
export {
|
||||
createBrowserGenerator,
|
||||
downloadWallpaper,
|
||||
copyWallpaperToClipboard,
|
||||
} from './renderers/browser.js';
|
||||
export { createNodeGenerator, saveWallpaperToFile } from './renderers/node.js';
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY FUNCTION
|
||||
// =============================================================================
|
||||
|
||||
import type { WallpaperGenerator } from './types.js';
|
||||
import { createBrowserGenerator } from './renderers/browser.js';
|
||||
import { createNodeGenerator } from './renderers/node.js';
|
||||
|
||||
/**
|
||||
* Detect if running in browser environment
|
||||
*/
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wallpaper generator appropriate for the current environment.
|
||||
*
|
||||
* - In browser: Uses Canvas API
|
||||
* - In Node.js: Uses Sharp
|
||||
*
|
||||
* @param options - Optional configuration
|
||||
* @param options.preferNode - Force Node.js renderer even in browser (requires Sharp)
|
||||
* @returns WallpaperGenerator instance
|
||||
*/
|
||||
export function createWallpaperGenerator(options?: { preferNode?: boolean }): WallpaperGenerator {
|
||||
if (options?.preferNode) {
|
||||
return createNodeGenerator();
|
||||
}
|
||||
|
||||
if (isBrowser()) {
|
||||
return createBrowserGenerator();
|
||||
}
|
||||
|
||||
return createNodeGenerator();
|
||||
}
|
||||
59
packages/wallpaper-generator/src/layouts/center.ts
Normal file
59
packages/wallpaper-generator/src/layouts/center.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Center Layout
|
||||
*
|
||||
* Places image centered on the wallpaper.
|
||||
*/
|
||||
|
||||
import type { CenterLayout } from '../types.js';
|
||||
|
||||
export interface CenterPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate center position for image
|
||||
*/
|
||||
export function calculateCenterPosition(
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
layout: CenterLayout
|
||||
): CenterPosition {
|
||||
const scale = layout.scale ?? 1.0;
|
||||
const offset = layout.offset ?? [0, 0];
|
||||
|
||||
const scaledWidth = imageWidth * scale;
|
||||
const scaledHeight = imageHeight * scale;
|
||||
|
||||
// Center the image
|
||||
const x = (canvasWidth - scaledWidth) / 2 + offset[0];
|
||||
const y = (canvasHeight - scaledHeight) / 2 + offset[1];
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width: Math.round(scaledWidth),
|
||||
height: Math.round(scaledHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image centered on canvas (browser)
|
||||
*/
|
||||
export function drawCentered(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
layout: CenterLayout
|
||||
): void {
|
||||
const pos = calculateCenterPosition(canvasWidth, canvasHeight, image.width, image.height, layout);
|
||||
|
||||
// Use crisp rendering for pixel art / QR codes
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, pos.x, pos.y, pos.width, pos.height);
|
||||
}
|
||||
79
packages/wallpaper-generator/src/layouts/corner.ts
Normal file
79
packages/wallpaper-generator/src/layouts/corner.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Corner Layout
|
||||
*
|
||||
* Places image in one of the four corners.
|
||||
*/
|
||||
|
||||
import type { CornerLayout } from '../types.js';
|
||||
|
||||
export interface CornerPositionResult {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate corner position for image
|
||||
*/
|
||||
export function calculateCornerPosition(
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
layout: CornerLayout
|
||||
): CornerPositionResult {
|
||||
const scale = layout.scale ?? 0.3;
|
||||
const padding = layout.padding ?? 40;
|
||||
const position = layout.position ?? 'bottom-right';
|
||||
|
||||
const scaledWidth = imageWidth * scale;
|
||||
const scaledHeight = imageHeight * scale;
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = canvasWidth - scaledWidth - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = padding;
|
||||
y = canvasHeight - scaledHeight - padding;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
default:
|
||||
x = canvasWidth - scaledWidth - padding;
|
||||
y = canvasHeight - scaledHeight - padding;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width: Math.round(scaledWidth),
|
||||
height: Math.round(scaledHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image in corner on canvas (browser)
|
||||
*/
|
||||
export function drawCorner(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
layout: CornerLayout
|
||||
): void {
|
||||
const pos = calculateCornerPosition(canvasWidth, canvasHeight, image.width, image.height, layout);
|
||||
|
||||
// Use crisp rendering for pixel art / QR codes
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, pos.x, pos.y, pos.width, pos.height);
|
||||
}
|
||||
11
packages/wallpaper-generator/src/layouts/index.ts
Normal file
11
packages/wallpaper-generator/src/layouts/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Layout Exports
|
||||
*/
|
||||
|
||||
export { calculateCenterPosition, drawCentered } from './center.js';
|
||||
export { calculateCornerPosition, drawCorner } from './corner.js';
|
||||
export { calculatePatternTiles, drawPattern } from './pattern.js';
|
||||
|
||||
export type { CenterPosition } from './center.js';
|
||||
export type { CornerPositionResult } from './corner.js';
|
||||
export type { PatternTile } from './pattern.js';
|
||||
88
packages/wallpaper-generator/src/layouts/pattern.ts
Normal file
88
packages/wallpaper-generator/src/layouts/pattern.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Pattern Layout
|
||||
*
|
||||
* Tiles image across the wallpaper as a repeating pattern.
|
||||
*/
|
||||
|
||||
import type { PatternLayout } from '../types.js';
|
||||
|
||||
export interface PatternTile {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tile positions for pattern
|
||||
*/
|
||||
export function calculatePatternTiles(
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
layout: PatternLayout
|
||||
): PatternTile[] {
|
||||
const scale = layout.scale ?? 0.5;
|
||||
const gap = layout.gap ?? 20;
|
||||
|
||||
const tileWidth = imageWidth * scale;
|
||||
const tileHeight = imageHeight * scale;
|
||||
|
||||
const tiles: PatternTile[] = [];
|
||||
|
||||
// Calculate how many tiles fit (with overlap to cover edges)
|
||||
const cols = Math.ceil(canvasWidth / (tileWidth + gap)) + 1;
|
||||
const rows = Math.ceil(canvasHeight / (tileHeight + gap)) + 1;
|
||||
|
||||
// Center the pattern grid
|
||||
const totalPatternWidth = cols * tileWidth + (cols - 1) * gap;
|
||||
const totalPatternHeight = rows * tileHeight + (rows - 1) * gap;
|
||||
|
||||
const startX = (canvasWidth - totalPatternWidth) / 2;
|
||||
const startY = (canvasHeight - totalPatternHeight) / 2;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
tiles.push({
|
||||
x: Math.round(startX + col * (tileWidth + gap)),
|
||||
y: Math.round(startY + row * (tileHeight + gap)),
|
||||
width: Math.round(tileWidth),
|
||||
height: Math.round(tileHeight),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw pattern on canvas (browser)
|
||||
*/
|
||||
export function drawPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
layout: PatternLayout
|
||||
): void {
|
||||
const opacity = layout.opacity ?? 0.15;
|
||||
const tiles = calculatePatternTiles(canvasWidth, canvasHeight, image.width, image.height, layout);
|
||||
|
||||
// Save current state
|
||||
ctx.save();
|
||||
|
||||
// Set opacity for pattern
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
// Use crisp rendering for pixel art / QR codes
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw all tiles
|
||||
for (const tile of tiles) {
|
||||
ctx.drawImage(image, tile.x, tile.y, tile.width, tile.height);
|
||||
}
|
||||
|
||||
// Restore state
|
||||
ctx.restore();
|
||||
}
|
||||
336
packages/wallpaper-generator/src/presets/devices.ts
Normal file
336
packages/wallpaper-generator/src/presets/devices.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* Device Presets
|
||||
*
|
||||
* Standard device screen dimensions for wallpaper generation.
|
||||
* Dimensions are in portrait orientation (height > width).
|
||||
*/
|
||||
|
||||
import type { DevicePreset, DeviceCategory } from '../types.js';
|
||||
|
||||
// =============================================================================
|
||||
// PHONE PRESETS
|
||||
// =============================================================================
|
||||
|
||||
export const PHONE_PRESETS: DevicePreset[] = [
|
||||
// Apple iPhones
|
||||
{
|
||||
id: 'iphone-15-pro-max',
|
||||
name: 'iPhone 15 Pro Max',
|
||||
category: 'phone',
|
||||
width: 1290,
|
||||
height: 2796,
|
||||
pixelRatio: 3,
|
||||
},
|
||||
{
|
||||
id: 'iphone-15-pro',
|
||||
name: 'iPhone 15 Pro',
|
||||
category: 'phone',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
pixelRatio: 3,
|
||||
},
|
||||
{
|
||||
id: 'iphone-15',
|
||||
name: 'iPhone 15',
|
||||
category: 'phone',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
pixelRatio: 3,
|
||||
},
|
||||
{
|
||||
id: 'iphone-14',
|
||||
name: 'iPhone 14',
|
||||
category: 'phone',
|
||||
width: 1170,
|
||||
height: 2532,
|
||||
pixelRatio: 3,
|
||||
},
|
||||
{
|
||||
id: 'iphone-se',
|
||||
name: 'iPhone SE',
|
||||
category: 'phone',
|
||||
width: 750,
|
||||
height: 1334,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
|
||||
// Google Pixels
|
||||
{
|
||||
id: 'pixel-8-pro',
|
||||
name: 'Pixel 8 Pro',
|
||||
category: 'phone',
|
||||
width: 1344,
|
||||
height: 2992,
|
||||
pixelRatio: 3,
|
||||
},
|
||||
{
|
||||
id: 'pixel-8',
|
||||
name: 'Pixel 8',
|
||||
category: 'phone',
|
||||
width: 1080,
|
||||
height: 2400,
|
||||
pixelRatio: 2.625,
|
||||
},
|
||||
{
|
||||
id: 'pixel-7a',
|
||||
name: 'Pixel 7a',
|
||||
category: 'phone',
|
||||
width: 1080,
|
||||
height: 2400,
|
||||
pixelRatio: 2.5,
|
||||
},
|
||||
|
||||
// Samsung Galaxy
|
||||
{
|
||||
id: 'samsung-s24-ultra',
|
||||
name: 'Samsung S24 Ultra',
|
||||
category: 'phone',
|
||||
width: 1440,
|
||||
height: 3120,
|
||||
pixelRatio: 3.75,
|
||||
},
|
||||
{
|
||||
id: 'samsung-s24',
|
||||
name: 'Samsung S24',
|
||||
category: 'phone',
|
||||
width: 1080,
|
||||
height: 2340,
|
||||
pixelRatio: 2.625,
|
||||
},
|
||||
{
|
||||
id: 'samsung-a54',
|
||||
name: 'Samsung A54',
|
||||
category: 'phone',
|
||||
width: 1080,
|
||||
height: 2340,
|
||||
pixelRatio: 2.625,
|
||||
},
|
||||
|
||||
// Generic Android
|
||||
{
|
||||
id: 'android-fhd',
|
||||
name: 'Android FHD+',
|
||||
category: 'phone',
|
||||
width: 1080,
|
||||
height: 2400,
|
||||
pixelRatio: 2.5,
|
||||
},
|
||||
{
|
||||
id: 'android-hd',
|
||||
name: 'Android HD+',
|
||||
category: 'phone',
|
||||
width: 720,
|
||||
height: 1600,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// TABLET PRESETS
|
||||
// =============================================================================
|
||||
|
||||
export const TABLET_PRESETS: DevicePreset[] = [
|
||||
// Apple iPads
|
||||
{
|
||||
id: 'ipad-pro-12.9',
|
||||
name: 'iPad Pro 12.9"',
|
||||
category: 'tablet',
|
||||
width: 2048,
|
||||
height: 2732,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'ipad-pro-11',
|
||||
name: 'iPad Pro 11"',
|
||||
category: 'tablet',
|
||||
width: 1668,
|
||||
height: 2388,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'ipad-air',
|
||||
name: 'iPad Air',
|
||||
category: 'tablet',
|
||||
width: 1640,
|
||||
height: 2360,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'ipad-mini',
|
||||
name: 'iPad mini',
|
||||
category: 'tablet',
|
||||
width: 1488,
|
||||
height: 2266,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'ipad-10th',
|
||||
name: 'iPad 10th Gen',
|
||||
category: 'tablet',
|
||||
width: 1640,
|
||||
height: 2360,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
|
||||
// Android Tablets
|
||||
{
|
||||
id: 'samsung-tab-s9-ultra',
|
||||
name: 'Samsung Tab S9 Ultra',
|
||||
category: 'tablet',
|
||||
width: 1848,
|
||||
height: 2960,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'samsung-tab-s9',
|
||||
name: 'Samsung Tab S9',
|
||||
category: 'tablet',
|
||||
width: 1600,
|
||||
height: 2560,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'android-tablet-generic',
|
||||
name: 'Android Tablet',
|
||||
category: 'tablet',
|
||||
width: 1600,
|
||||
height: 2560,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// DESKTOP PRESETS
|
||||
// =============================================================================
|
||||
|
||||
export const DESKTOP_PRESETS: DevicePreset[] = [
|
||||
// Standard Resolutions
|
||||
{
|
||||
id: 'desktop-4k',
|
||||
name: '4K UHD',
|
||||
category: 'desktop',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
pixelRatio: 1,
|
||||
},
|
||||
{
|
||||
id: 'desktop-2k',
|
||||
name: '2K QHD',
|
||||
category: 'desktop',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
pixelRatio: 1,
|
||||
},
|
||||
{
|
||||
id: 'desktop-fhd',
|
||||
name: 'Full HD',
|
||||
category: 'desktop',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
pixelRatio: 1,
|
||||
},
|
||||
|
||||
// Apple MacBooks
|
||||
{
|
||||
id: 'macbook-pro-16',
|
||||
name: 'MacBook Pro 16"',
|
||||
category: 'desktop',
|
||||
width: 3456,
|
||||
height: 2234,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'macbook-pro-14',
|
||||
name: 'MacBook Pro 14"',
|
||||
category: 'desktop',
|
||||
width: 3024,
|
||||
height: 1964,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'macbook-air-15',
|
||||
name: 'MacBook Air 15"',
|
||||
category: 'desktop',
|
||||
width: 2880,
|
||||
height: 1864,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
{
|
||||
id: 'macbook-air-13',
|
||||
name: 'MacBook Air 13"',
|
||||
category: 'desktop',
|
||||
width: 2560,
|
||||
height: 1664,
|
||||
pixelRatio: 2,
|
||||
},
|
||||
|
||||
// Ultrawide
|
||||
{
|
||||
id: 'ultrawide-34',
|
||||
name: 'Ultrawide 34"',
|
||||
category: 'desktop',
|
||||
width: 3440,
|
||||
height: 1440,
|
||||
pixelRatio: 1,
|
||||
},
|
||||
{
|
||||
id: 'ultrawide-49',
|
||||
name: 'Super Ultrawide 49"',
|
||||
category: 'desktop',
|
||||
width: 5120,
|
||||
height: 1440,
|
||||
pixelRatio: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// ALL PRESETS
|
||||
// =============================================================================
|
||||
|
||||
/** All device presets combined */
|
||||
export const ALL_DEVICE_PRESETS: DevicePreset[] = [
|
||||
...PHONE_PRESETS,
|
||||
...TABLET_PRESETS,
|
||||
...DESKTOP_PRESETS,
|
||||
];
|
||||
|
||||
/** Map of preset ID to preset for quick lookup */
|
||||
export const DEVICE_PRESET_MAP: Map<string, DevicePreset> = new Map(
|
||||
ALL_DEVICE_PRESETS.map((preset) => [preset.id, preset])
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get device preset by ID
|
||||
*/
|
||||
export function getDevicePreset(id: string): DevicePreset | undefined {
|
||||
return DEVICE_PRESET_MAP.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all presets for a category
|
||||
*/
|
||||
export function getPresetsByCategory(category: DeviceCategory): DevicePreset[] {
|
||||
return ALL_DEVICE_PRESETS.filter((preset) => preset.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended presets (most common devices)
|
||||
*/
|
||||
export function getRecommendedPresets(): DevicePreset[] {
|
||||
const recommended = [
|
||||
'iphone-15-pro-max',
|
||||
'iphone-15',
|
||||
'samsung-s24-ultra',
|
||||
'pixel-8-pro',
|
||||
'ipad-pro-12.9',
|
||||
'macbook-pro-16',
|
||||
'desktop-4k',
|
||||
];
|
||||
return recommended
|
||||
.map((id) => DEVICE_PRESET_MAP.get(id))
|
||||
.filter((preset): preset is DevicePreset => preset !== undefined);
|
||||
}
|
||||
14
packages/wallpaper-generator/src/presets/index.ts
Normal file
14
packages/wallpaper-generator/src/presets/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Device Presets Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
PHONE_PRESETS,
|
||||
TABLET_PRESETS,
|
||||
DESKTOP_PRESETS,
|
||||
ALL_DEVICE_PRESETS,
|
||||
DEVICE_PRESET_MAP,
|
||||
getDevicePreset,
|
||||
getPresetsByCategory,
|
||||
getRecommendedPresets,
|
||||
} from './devices.js';
|
||||
316
packages/wallpaper-generator/src/renderers/browser.ts
Normal file
316
packages/wallpaper-generator/src/renderers/browser.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Browser Renderer
|
||||
*
|
||||
* Canvas-based wallpaper generation for browser environments.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImageSource,
|
||||
WallpaperOptions,
|
||||
WallpaperResult,
|
||||
WallpaperGenerator,
|
||||
DevicePreset,
|
||||
DeviceCategory,
|
||||
Layout,
|
||||
Background,
|
||||
} from '../types.js';
|
||||
import { ALL_DEVICE_PRESETS, getDevicePreset, getPresetsByCategory } from '../presets/index.js';
|
||||
import { fillSolid } from '../backgrounds/solid.js';
|
||||
import { fillGradient } from '../backgrounds/gradient.js';
|
||||
import { drawCentered } from '../layouts/center.js';
|
||||
import { drawCorner } from '../layouts/corner.js';
|
||||
import { drawPattern } from '../layouts/pattern.js';
|
||||
|
||||
// =============================================================================
|
||||
// IMAGE SOURCE LOADING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Load image from data URL
|
||||
*/
|
||||
async function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error(`Failed to load image: ${e}`));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from canvas
|
||||
*/
|
||||
function loadImageFromCanvas(canvas: HTMLCanvasElement): HTMLCanvasElement {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from buffer (convert to canvas)
|
||||
*/
|
||||
function loadImageFromBuffer(
|
||||
buffer: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
channels: 3 | 4 = 4
|
||||
): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get canvas context');
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
|
||||
if (channels === 4) {
|
||||
// RGBA - direct copy
|
||||
imageData.data.set(buffer);
|
||||
} else {
|
||||
// RGB - need to add alpha channel
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
imageData.data[i * 4] = buffer[i * 3];
|
||||
imageData.data[i * 4 + 1] = buffer[i * 3 + 1];
|
||||
imageData.data[i * 4 + 2] = buffer[i * 3 + 2];
|
||||
imageData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ImageSource to drawable element
|
||||
*/
|
||||
async function loadSourceImage(source: ImageSource): Promise<HTMLImageElement | HTMLCanvasElement> {
|
||||
switch (source.type) {
|
||||
case 'dataUrl':
|
||||
return loadImageFromDataUrl(source.data);
|
||||
case 'canvas':
|
||||
return loadImageFromCanvas(source.canvas);
|
||||
case 'buffer':
|
||||
return loadImageFromBuffer(source.buffer, source.width, source.height, source.channels ?? 4);
|
||||
default:
|
||||
throw new Error('Unknown image source type');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACKGROUND RENDERING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Draw background on canvas
|
||||
*/
|
||||
function drawBackground(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
background: Background
|
||||
): void {
|
||||
if (background.type === 'solid') {
|
||||
fillSolid(ctx, width, height, background.color);
|
||||
} else if (background.type === 'gradient') {
|
||||
fillGradient(ctx, width, height, background.colors, background.angle ?? 180);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT RENDERING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Draw image with layout
|
||||
*/
|
||||
function drawWithLayout(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
layout: Layout
|
||||
): void {
|
||||
switch (layout.type) {
|
||||
case 'center':
|
||||
drawCentered(ctx, image, canvasWidth, canvasHeight, layout);
|
||||
break;
|
||||
case 'corner':
|
||||
drawCorner(ctx, image, canvasWidth, canvasHeight, layout);
|
||||
break;
|
||||
case 'pattern':
|
||||
drawPattern(ctx, image, canvasWidth, canvasHeight, layout);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown layout type');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DIMENSION RESOLUTION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resolve device option to dimensions
|
||||
*/
|
||||
function resolveDimensions(device: string | { width: number; height: number }): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (typeof device === 'string') {
|
||||
const preset = getDevicePreset(device);
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown device preset: ${device}`);
|
||||
}
|
||||
return { width: preset.width, height: preset.height };
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BROWSER GENERATOR
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create browser-based wallpaper generator
|
||||
*/
|
||||
export function createBrowserGenerator(): WallpaperGenerator {
|
||||
return {
|
||||
async generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult> {
|
||||
const { width, height } = resolveDimensions(options.device);
|
||||
const format = options.format ?? 'png';
|
||||
const quality = options.quality ?? 90;
|
||||
|
||||
// Create canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get canvas context');
|
||||
|
||||
// Draw background
|
||||
drawBackground(ctx, width, height, options.background);
|
||||
|
||||
// Load and draw source image
|
||||
const sourceImage = await loadSourceImage(source);
|
||||
drawWithLayout(ctx, sourceImage, width, height, options.layout);
|
||||
|
||||
// Export to data URL
|
||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||
const dataUrl = canvas.toDataURL(mimeType, quality / 100);
|
||||
|
||||
// Estimate size from base64
|
||||
const base64Length = dataUrl.split(',')[1]?.length ?? 0;
|
||||
const size = Math.ceil((base64Length * 3) / 4);
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
size,
|
||||
};
|
||||
},
|
||||
|
||||
async preview(source: ImageSource, options: WallpaperOptions): Promise<string> {
|
||||
// Generate at 1/4 resolution for preview
|
||||
const { width, height } = resolveDimensions(options.device);
|
||||
const previewWidth = Math.round(width / 4);
|
||||
const previewHeight = Math.round(height / 4);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = previewWidth;
|
||||
canvas.height = previewHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to get canvas context');
|
||||
|
||||
// Draw background
|
||||
drawBackground(ctx, previewWidth, previewHeight, options.background);
|
||||
|
||||
// Load and draw source image (with scaled layout)
|
||||
const sourceImage = await loadSourceImage(source);
|
||||
|
||||
// Scale down the layout parameters
|
||||
const scaledLayout = scaleLayout(options.layout, 0.25);
|
||||
drawWithLayout(ctx, sourceImage, previewWidth, previewHeight, scaledLayout);
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.7);
|
||||
},
|
||||
|
||||
getSupportedDevices(): DevicePreset[] {
|
||||
return ALL_DEVICE_PRESETS;
|
||||
},
|
||||
|
||||
getDevicesByCategory(category: DeviceCategory): DevicePreset[] {
|
||||
return getPresetsByCategory(category);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale layout parameters for preview
|
||||
*/
|
||||
function scaleLayout(layout: Layout, scaleFactor: number): Layout {
|
||||
switch (layout.type) {
|
||||
case 'center':
|
||||
return {
|
||||
...layout,
|
||||
offset: layout.offset
|
||||
? [layout.offset[0] * scaleFactor, layout.offset[1] * scaleFactor]
|
||||
: undefined,
|
||||
};
|
||||
case 'corner':
|
||||
return {
|
||||
...layout,
|
||||
padding: (layout.padding ?? 40) * scaleFactor,
|
||||
};
|
||||
case 'pattern':
|
||||
return {
|
||||
...layout,
|
||||
gap: (layout.gap ?? 20) * scaleFactor,
|
||||
};
|
||||
default:
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BROWSER UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Download wallpaper to user's device
|
||||
*/
|
||||
export function downloadWallpaper(result: WallpaperResult, filename?: string): void {
|
||||
const defaultFilename = `wallpaper-${result.width}x${result.height}.${result.format}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = result.dataUrl;
|
||||
link.download = filename ?? defaultFilename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy wallpaper to clipboard (if supported)
|
||||
*/
|
||||
export async function copyWallpaperToClipboard(result: WallpaperResult): Promise<boolean> {
|
||||
if (!navigator.clipboard?.write) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(result.dataUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
packages/wallpaper-generator/src/renderers/index.ts
Normal file
6
packages/wallpaper-generator/src/renderers/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Renderer Exports
|
||||
*/
|
||||
|
||||
export { createBrowserGenerator, downloadWallpaper, copyWallpaperToClipboard } from './browser.js';
|
||||
export { createNodeGenerator, saveWallpaperToFile } from './node.js';
|
||||
397
packages/wallpaper-generator/src/renderers/node.ts
Normal file
397
packages/wallpaper-generator/src/renderers/node.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* Node.js Renderer
|
||||
*
|
||||
* Sharp-based wallpaper generation for Node.js environments.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ImageSource,
|
||||
WallpaperOptions,
|
||||
WallpaperResult,
|
||||
WallpaperGenerator,
|
||||
DevicePreset,
|
||||
DeviceCategory,
|
||||
Layout,
|
||||
Background,
|
||||
CenterLayout,
|
||||
CornerLayout,
|
||||
PatternLayout,
|
||||
} from '../types.js';
|
||||
import { ALL_DEVICE_PRESETS, getDevicePreset, getPresetsByCategory } from '../presets/index.js';
|
||||
import { createSolidBuffer } from '../backgrounds/solid.js';
|
||||
import { createGradientBuffer } from '../backgrounds/gradient.js';
|
||||
import { calculateCenterPosition } from '../layouts/center.js';
|
||||
import { calculateCornerPosition } from '../layouts/corner.js';
|
||||
import { calculatePatternTiles } from '../layouts/pattern.js';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface SharpInstance {
|
||||
metadata(): Promise<{ width?: number; height?: number }>;
|
||||
resize(width: number, height: number, options?: object): SharpInstance;
|
||||
composite(inputs: CompositeInput[]): SharpInstance;
|
||||
png(options?: { compressionLevel?: number }): SharpInstance;
|
||||
jpeg(options?: { quality?: number }): SharpInstance;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
}
|
||||
|
||||
interface CompositeInput {
|
||||
input:
|
||||
| Buffer
|
||||
| {
|
||||
create: {
|
||||
width: number;
|
||||
height: number;
|
||||
channels: number;
|
||||
background: { r: number; g: number; b: number; alpha: number };
|
||||
};
|
||||
};
|
||||
top?: number;
|
||||
left?: number;
|
||||
blend?: string;
|
||||
}
|
||||
|
||||
type SharpConstructor = {
|
||||
(
|
||||
input?:
|
||||
| Buffer
|
||||
| string
|
||||
| {
|
||||
create: {
|
||||
width: number;
|
||||
height: number;
|
||||
channels: 3 | 4;
|
||||
background: { r: number; g: number; b: number; alpha?: number };
|
||||
};
|
||||
}
|
||||
): SharpInstance;
|
||||
(
|
||||
buffer: Buffer,
|
||||
options: { raw: { width: number; height: number; channels: 3 | 4 } }
|
||||
): SharpInstance;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SHARP LOADING
|
||||
// =============================================================================
|
||||
|
||||
let sharpModule: SharpConstructor | null = null;
|
||||
|
||||
async function getSharp(): Promise<SharpConstructor> {
|
||||
if (sharpModule) return sharpModule;
|
||||
|
||||
try {
|
||||
const module = await import('sharp');
|
||||
sharpModule = module.default as SharpConstructor;
|
||||
return sharpModule;
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Sharp is required for Node.js wallpaper generation. Install it with: npm install sharp'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMAGE SOURCE LOADING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Convert data URL to Buffer
|
||||
*/
|
||||
function dataUrlToBuffer(dataUrl: string): Buffer {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
if (!base64) throw new Error('Invalid data URL');
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load source image and get dimensions
|
||||
*/
|
||||
async function loadSourceBuffer(
|
||||
source: ImageSource
|
||||
): Promise<{ buffer: Buffer; width: number; height: number; isRaw?: boolean; channels?: 3 | 4 }> {
|
||||
const sharp = await getSharp();
|
||||
|
||||
switch (source.type) {
|
||||
case 'dataUrl': {
|
||||
const buffer = dataUrlToBuffer(source.data);
|
||||
const img = sharp(buffer);
|
||||
const metadata = await img.metadata();
|
||||
return {
|
||||
buffer,
|
||||
width: metadata.width ?? 0,
|
||||
height: metadata.height ?? 0,
|
||||
isRaw: false,
|
||||
};
|
||||
}
|
||||
case 'canvas': {
|
||||
throw new Error('Canvas source is not supported in Node.js environment');
|
||||
}
|
||||
case 'buffer': {
|
||||
return {
|
||||
buffer: Buffer.from(source.buffer),
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
isRaw: true,
|
||||
channels: source.channels ?? 3,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown image source type');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACKGROUND CREATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create background buffer
|
||||
*/
|
||||
function createBackgroundBuffer(width: number, height: number, background: Background): Buffer {
|
||||
let buffer: Uint8Array;
|
||||
|
||||
if (background.type === 'solid') {
|
||||
buffer = createSolidBuffer(width, height, background.color);
|
||||
} else if (background.type === 'gradient') {
|
||||
buffer = createGradientBuffer(width, height, background.colors, background.angle ?? 180);
|
||||
} else {
|
||||
buffer = createSolidBuffer(width, height, '#000000');
|
||||
}
|
||||
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DIMENSION RESOLUTION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resolve device option to dimensions
|
||||
*/
|
||||
function resolveDimensions(device: string | { width: number; height: number }): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (typeof device === 'string') {
|
||||
const preset = getDevicePreset(device);
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown device preset: ${device}`);
|
||||
}
|
||||
return { width: preset.width, height: preset.height };
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NODE.JS GENERATOR
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create Node.js-based wallpaper generator using Sharp
|
||||
*/
|
||||
export function createNodeGenerator(): WallpaperGenerator {
|
||||
return {
|
||||
async generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult> {
|
||||
const sharp = await getSharp();
|
||||
const { width, height } = resolveDimensions(options.device);
|
||||
const format = options.format ?? 'png';
|
||||
const quality = options.quality ?? 90;
|
||||
|
||||
// Create background
|
||||
const bgBuffer = createBackgroundBuffer(width, height, options.background);
|
||||
let canvas = sharp(bgBuffer, { raw: { width, height, channels: 3 } });
|
||||
|
||||
// Load source image
|
||||
const sourceData = await loadSourceBuffer(source);
|
||||
|
||||
// Calculate composite operations based on layout
|
||||
const composites: CompositeInput[] = [];
|
||||
const { layout } = options;
|
||||
|
||||
// Create sharp instance for source image
|
||||
const createSourceSharp = () => {
|
||||
if (sourceData.isRaw) {
|
||||
return sharp(sourceData.buffer, {
|
||||
raw: {
|
||||
width: sourceData.width,
|
||||
height: sourceData.height,
|
||||
channels: sourceData.channels ?? 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sharp(sourceData.buffer);
|
||||
};
|
||||
|
||||
if (layout.type === 'center') {
|
||||
const pos = calculateCenterPosition(
|
||||
width,
|
||||
height,
|
||||
sourceData.width,
|
||||
sourceData.height,
|
||||
layout as CenterLayout
|
||||
);
|
||||
|
||||
// Resize source image
|
||||
const resizedSource = await createSourceSharp()
|
||||
.resize(pos.width, pos.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
composites.push({
|
||||
input: resizedSource,
|
||||
top: Math.max(0, pos.y),
|
||||
left: Math.max(0, pos.x),
|
||||
});
|
||||
} else if (layout.type === 'corner') {
|
||||
const pos = calculateCornerPosition(
|
||||
width,
|
||||
height,
|
||||
sourceData.width,
|
||||
sourceData.height,
|
||||
layout as CornerLayout
|
||||
);
|
||||
|
||||
const resizedSource = await createSourceSharp()
|
||||
.resize(pos.width, pos.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
composites.push({
|
||||
input: resizedSource,
|
||||
top: Math.max(0, pos.y),
|
||||
left: Math.max(0, pos.x),
|
||||
});
|
||||
} else if (layout.type === 'pattern') {
|
||||
const tiles = calculatePatternTiles(
|
||||
width,
|
||||
height,
|
||||
sourceData.width,
|
||||
sourceData.height,
|
||||
layout as PatternLayout
|
||||
);
|
||||
|
||||
// Resize source once for all tiles
|
||||
const tileSize = tiles[0];
|
||||
if (tileSize) {
|
||||
const resizedTile = await createSourceSharp()
|
||||
.resize(tileSize.width, tileSize.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Add each tile (limited opacity in Sharp requires different approach)
|
||||
for (const tile of tiles) {
|
||||
if (tile.x >= 0 && tile.y >= 0 && tile.x < width && tile.y < height) {
|
||||
composites.push({
|
||||
input: resizedTile,
|
||||
top: tile.y,
|
||||
left: tile.x,
|
||||
blend: 'over',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply composites
|
||||
if (composites.length > 0) {
|
||||
canvas = canvas.composite(composites);
|
||||
}
|
||||
|
||||
// Export
|
||||
let outputBuffer: Buffer;
|
||||
if (format === 'jpeg') {
|
||||
outputBuffer = await canvas.jpeg({ quality }).toBuffer();
|
||||
} else {
|
||||
outputBuffer = await canvas.png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
|
||||
// Convert to data URL
|
||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||
const dataUrl = `data:${mimeType};base64,${outputBuffer.toString('base64')}`;
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
},
|
||||
|
||||
async preview(source: ImageSource, options: WallpaperOptions): Promise<string> {
|
||||
// Generate at 1/4 resolution for preview
|
||||
const { width, height } = resolveDimensions(options.device);
|
||||
const previewWidth = Math.round(width / 4);
|
||||
const previewHeight = Math.round(height / 4);
|
||||
|
||||
const previewOptions: WallpaperOptions = {
|
||||
...options,
|
||||
device: { width: previewWidth, height: previewHeight },
|
||||
format: 'jpeg',
|
||||
quality: 70,
|
||||
};
|
||||
|
||||
// Scale layout parameters
|
||||
previewOptions.layout = scaleLayout(options.layout, 0.25);
|
||||
|
||||
const result = await this.generate(source, previewOptions);
|
||||
return result.dataUrl;
|
||||
},
|
||||
|
||||
getSupportedDevices(): DevicePreset[] {
|
||||
return ALL_DEVICE_PRESETS;
|
||||
},
|
||||
|
||||
getDevicesByCategory(category: DeviceCategory): DevicePreset[] {
|
||||
return getPresetsByCategory(category);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale layout parameters for preview
|
||||
*/
|
||||
function scaleLayout(layout: Layout, scaleFactor: number): Layout {
|
||||
switch (layout.type) {
|
||||
case 'center':
|
||||
return {
|
||||
...layout,
|
||||
offset: layout.offset
|
||||
? [layout.offset[0] * scaleFactor, layout.offset[1] * scaleFactor]
|
||||
: undefined,
|
||||
} as CenterLayout;
|
||||
case 'corner':
|
||||
return {
|
||||
...layout,
|
||||
padding: ((layout as CornerLayout).padding ?? 40) * scaleFactor,
|
||||
} as CornerLayout;
|
||||
case 'pattern':
|
||||
return {
|
||||
...layout,
|
||||
gap: ((layout as PatternLayout).gap ?? 20) * scaleFactor,
|
||||
} as PatternLayout;
|
||||
default:
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NODE.JS UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Save wallpaper to file
|
||||
*/
|
||||
export async function saveWallpaperToFile(
|
||||
result: WallpaperResult,
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
const fs = await import('fs/promises');
|
||||
const base64 = result.dataUrl.split(',')[1];
|
||||
if (!base64) throw new Error('Invalid data URL');
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
await fs.writeFile(filePath, buffer);
|
||||
}
|
||||
453
packages/wallpaper-generator/src/svelte/WallpaperModal.svelte
Normal file
453
packages/wallpaper-generator/src/svelte/WallpaperModal.svelte
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
WallpaperResult,
|
||||
DevicePreset,
|
||||
DeviceCategory,
|
||||
Layout,
|
||||
Background,
|
||||
CornerPosition,
|
||||
} from '../types.js';
|
||||
import { createBrowserGenerator, downloadWallpaper } from '../renderers/browser.js';
|
||||
import {
|
||||
PHONE_PRESETS,
|
||||
TABLET_PRESETS,
|
||||
DESKTOP_PRESETS,
|
||||
getRecommendedPresets,
|
||||
} from '../presets/index.js';
|
||||
import { GRADIENT_PRESETS } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
imageDataUrl: string;
|
||||
imageSize?: { width: number; height: number };
|
||||
onClose: () => void;
|
||||
onGenerate?: (result: WallpaperResult) => void;
|
||||
}
|
||||
|
||||
let { show, imageDataUrl, imageSize, onClose, onGenerate }: Props = $props();
|
||||
|
||||
// State
|
||||
let selectedCategory = $state<DeviceCategory>('phone');
|
||||
let selectedDeviceId = $state<string>('iphone-15-pro-max');
|
||||
let layoutType = $state<'center' | 'corner' | 'pattern'>('center');
|
||||
let cornerPosition = $state<CornerPosition>('bottom-right');
|
||||
let layoutScale = $state(0.6);
|
||||
let backgroundType = $state<'solid' | 'gradient'>('gradient');
|
||||
let solidColor = $state('#1a1a2e');
|
||||
let selectedGradient = $state('dark');
|
||||
let generating = $state(false);
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let result = $state<WallpaperResult | null>(null);
|
||||
|
||||
// Computed
|
||||
const devicesByCategory = $derived<Record<DeviceCategory, DevicePreset[]>>({
|
||||
phone: PHONE_PRESETS,
|
||||
tablet: TABLET_PRESETS,
|
||||
desktop: DESKTOP_PRESETS,
|
||||
});
|
||||
|
||||
const currentDevices = $derived(devicesByCategory[selectedCategory] || []);
|
||||
|
||||
const currentLayout = $derived<Layout>(() => {
|
||||
if (layoutType === 'center') {
|
||||
return { type: 'center', scale: layoutScale };
|
||||
} else if (layoutType === 'corner') {
|
||||
return { type: 'corner', position: cornerPosition, scale: layoutScale * 0.5, padding: 60 };
|
||||
} else {
|
||||
return { type: 'pattern', scale: layoutScale * 0.3, gap: 40, opacity: 0.15 };
|
||||
}
|
||||
});
|
||||
|
||||
const currentBackground = $derived<Background>(() => {
|
||||
if (backgroundType === 'solid') {
|
||||
return { type: 'solid', color: solidColor };
|
||||
} else {
|
||||
const preset = GRADIENT_PRESETS[selectedGradient] || GRADIENT_PRESETS['dark'];
|
||||
return preset;
|
||||
}
|
||||
});
|
||||
|
||||
// Generator
|
||||
const generator = createBrowserGenerator();
|
||||
|
||||
// Generate preview when settings change
|
||||
$effect(() => {
|
||||
if (show && imageDataUrl) {
|
||||
generatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
async function generatePreview() {
|
||||
try {
|
||||
const url = await generator.preview(
|
||||
{ type: 'dataUrl', data: imageDataUrl },
|
||||
{
|
||||
device: selectedDeviceId,
|
||||
layout: currentLayout(),
|
||||
background: currentBackground(),
|
||||
}
|
||||
);
|
||||
previewUrl = url;
|
||||
} catch (e) {
|
||||
console.error('Preview generation failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateWallpaper() {
|
||||
generating = true;
|
||||
result = null;
|
||||
|
||||
try {
|
||||
result = await generator.generate(
|
||||
{ type: 'dataUrl', data: imageDataUrl },
|
||||
{
|
||||
device: selectedDeviceId,
|
||||
layout: currentLayout(),
|
||||
background: currentBackground(),
|
||||
format: 'png',
|
||||
}
|
||||
);
|
||||
|
||||
onGenerate?.(result);
|
||||
} catch (e) {
|
||||
console.error('Wallpaper generation failed:', e);
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (result) {
|
||||
const device = currentDevices.find((d) => d.id === selectedDeviceId);
|
||||
const deviceName = device?.name.replace(/\s+/g, '-').toLowerCase() || 'custom';
|
||||
downloadWallpaper(result, `wallpaper-${deviceName}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Reset state when modal closes
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
result = null;
|
||||
previewUrl = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="bg-card rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Wallpaper erstellen</h3>
|
||||
<p class="text-sm text-muted-foreground">Erstelle ein Wallpaper fur dein Gerat</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-2 hover:bg-muted rounded-lg transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Preview -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-sm">Vorschau</h4>
|
||||
<div
|
||||
class="aspect-[9/16] bg-muted rounded-lg overflow-hidden flex items-center justify-center border"
|
||||
>
|
||||
{#if previewUrl}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Wallpaper Preview"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">Wird generiert...</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="space-y-6">
|
||||
<!-- Device Selection -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-sm">Gerat</h4>
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="flex gap-1 p-1 bg-muted rounded-lg">
|
||||
{#each ['phone', 'tablet', 'desktop'] as category}
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedCategory = category as DeviceCategory;
|
||||
selectedDeviceId = devicesByCategory[category as DeviceCategory][0]?.id || '';
|
||||
}}
|
||||
class="flex-1 px-3 py-1.5 text-sm rounded-md transition-colors {selectedCategory ===
|
||||
category
|
||||
? 'bg-card shadow-sm font-medium'
|
||||
: 'hover:bg-card/50'}"
|
||||
>
|
||||
{category === 'phone' ? 'Handy' : category === 'tablet' ? 'Tablet' : 'Desktop'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Device Dropdown -->
|
||||
<select
|
||||
bind:value={selectedDeviceId}
|
||||
class="w-full px-3 py-2 bg-muted border rounded-lg text-sm"
|
||||
>
|
||||
{#each currentDevices as device}
|
||||
<option value={device.id}>
|
||||
{device.name} ({device.width}x{device.height})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-sm">Layout</h4>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#each ['center', 'corner', 'pattern'] as type}
|
||||
<button
|
||||
onclick={() => (layoutType = type as 'center' | 'corner' | 'pattern')}
|
||||
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {layoutType ===
|
||||
type
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'hover:bg-muted'}"
|
||||
>
|
||||
{type === 'center' ? 'Zentriert' : type === 'corner' ? 'Ecke' : 'Muster'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if layoutType === 'corner'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as pos}
|
||||
<button
|
||||
onclick={() => (cornerPosition = pos as CornerPosition)}
|
||||
class="px-3 py-2 text-xs border rounded-lg transition-colors {cornerPosition ===
|
||||
pos
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'hover:bg-muted'}"
|
||||
>
|
||||
{pos === 'top-left'
|
||||
? 'Oben Links'
|
||||
: pos === 'top-right'
|
||||
? 'Oben Rechts'
|
||||
: pos === 'bottom-left'
|
||||
? 'Unten Links'
|
||||
: 'Unten Rechts'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scale Slider -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Grosse</span>
|
||||
<span>{Math.round(layoutScale * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
bind:value={layoutScale}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-sm">Hintergrund</h4>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (backgroundType = 'gradient')}
|
||||
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {backgroundType ===
|
||||
'gradient'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'hover:bg-muted'}"
|
||||
>
|
||||
Verlauf
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (backgroundType = 'solid')}
|
||||
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {backgroundType ===
|
||||
'solid'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'hover:bg-muted'}"
|
||||
>
|
||||
Einfarbig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backgroundType === 'gradient'}
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each Object.entries(GRADIENT_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => (selectedGradient = key)}
|
||||
class="aspect-square rounded-lg border-2 transition-all {selectedGradient ===
|
||||
key
|
||||
? 'border-primary scale-105'
|
||||
: 'border-transparent hover:border-muted-foreground/30'}"
|
||||
style="background: linear-gradient({preset.angle ??
|
||||
180}deg, {preset.colors.join(', ')})"
|
||||
aria-label={key}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={solidColor}
|
||||
class="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={solidColor}
|
||||
class="flex-1 px-3 py-2 bg-muted border rounded-lg text-sm font-mono"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Info -->
|
||||
{#if result}
|
||||
<div
|
||||
class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-5 w-5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-green-800 dark:text-green-200"
|
||||
>Wallpaper erstellt!</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">
|
||||
{result.width}x{result.height} · {formatSize(result.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="flex-1 px-4 py-2.5 border rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{#if result}
|
||||
<button
|
||||
onclick={handleDownload}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Herunterladen
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={generateWallpaper}
|
||||
disabled={generating}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if generating}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
></div>
|
||||
Generiert...
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Wallpaper erstellen
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
5
packages/wallpaper-generator/src/svelte/index.ts
Normal file
5
packages/wallpaper-generator/src/svelte/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Svelte Exports
|
||||
*/
|
||||
|
||||
export { default as WallpaperModal } from './WallpaperModal.svelte';
|
||||
282
packages/wallpaper-generator/src/types.ts
Normal file
282
packages/wallpaper-generator/src/types.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* Wallpaper Generator Types
|
||||
*
|
||||
* Type definitions for the wallpaper generator package.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// IMAGE SOURCE TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** Data URL image source (e.g., from QR code or canvas export) */
|
||||
export interface DataUrlSource {
|
||||
type: 'dataUrl';
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** HTML Canvas element source */
|
||||
export interface CanvasSource {
|
||||
type: 'canvas';
|
||||
canvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
/** Raw pixel buffer source */
|
||||
export interface BufferSource {
|
||||
type: 'buffer';
|
||||
buffer: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Number of channels (3 for RGB, 4 for RGBA) */
|
||||
channels?: 3 | 4;
|
||||
}
|
||||
|
||||
/** All supported image source types */
|
||||
export type ImageSource = DataUrlSource | CanvasSource | BufferSource;
|
||||
|
||||
// =============================================================================
|
||||
// DEVICE PRESETS
|
||||
// =============================================================================
|
||||
|
||||
/** Device category */
|
||||
export type DeviceCategory = 'phone' | 'tablet' | 'desktop';
|
||||
|
||||
/** Device preset definition */
|
||||
export interface DevicePreset {
|
||||
/** Unique identifier (e.g., 'iphone-15-pro-max') */
|
||||
id: string;
|
||||
/** Display name (e.g., 'iPhone 15 Pro Max') */
|
||||
name: string;
|
||||
/** Device category */
|
||||
category: DeviceCategory;
|
||||
/** Width in pixels */
|
||||
width: number;
|
||||
/** Height in pixels */
|
||||
height: number;
|
||||
/** Pixel density ratio (optional) */
|
||||
pixelRatio?: number;
|
||||
}
|
||||
|
||||
/** Custom device dimensions */
|
||||
export interface CustomDevice {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Device option - either a preset ID or custom dimensions */
|
||||
export type DeviceOption = string | CustomDevice;
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT OPTIONS
|
||||
// =============================================================================
|
||||
|
||||
/** Corner position for corner layout */
|
||||
export type CornerPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
/** Center layout - image centered on wallpaper */
|
||||
export interface CenterLayout {
|
||||
type: 'center';
|
||||
/** Scale factor (default: 1.0) */
|
||||
scale?: number;
|
||||
/** Offset from center in pixels [x, y] */
|
||||
offset?: [number, number];
|
||||
}
|
||||
|
||||
/** Corner layout - image in one corner */
|
||||
export interface CornerLayout {
|
||||
type: 'corner';
|
||||
/** Which corner to place the image */
|
||||
position: CornerPosition;
|
||||
/** Scale factor (default: 1.0) */
|
||||
scale?: number;
|
||||
/** Padding from edges in pixels (default: 40) */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
/** Pattern layout - tiled image */
|
||||
export interface PatternLayout {
|
||||
type: 'pattern';
|
||||
/** Scale factor for each tile (default: 0.5) */
|
||||
scale?: number;
|
||||
/** Gap between tiles in pixels (default: 20) */
|
||||
gap?: number;
|
||||
/** Opacity of pattern (default: 0.15) */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
/** All layout types */
|
||||
export type Layout = CenterLayout | CornerLayout | PatternLayout;
|
||||
|
||||
// =============================================================================
|
||||
// BACKGROUND OPTIONS
|
||||
// =============================================================================
|
||||
|
||||
/** Solid color background */
|
||||
export interface SolidBackground {
|
||||
type: 'solid';
|
||||
/** Color in hex format (e.g., '#1a1a2e') */
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Gradient background */
|
||||
export interface GradientBackground {
|
||||
type: 'gradient';
|
||||
/** Array of color stops in hex format */
|
||||
colors: string[];
|
||||
/** Gradient angle in degrees (default: 180 = top to bottom) */
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
/** All background types */
|
||||
export type Background = SolidBackground | GradientBackground;
|
||||
|
||||
// =============================================================================
|
||||
// WALLPAPER OPTIONS
|
||||
// =============================================================================
|
||||
|
||||
/** Output image format */
|
||||
export type OutputFormat = 'png' | 'jpeg';
|
||||
|
||||
/** Complete wallpaper generation options */
|
||||
export interface WallpaperOptions {
|
||||
/** Target device (preset ID or custom dimensions) */
|
||||
device: DeviceOption;
|
||||
/** Image layout configuration */
|
||||
layout: Layout;
|
||||
/** Background configuration */
|
||||
background: Background;
|
||||
/** Output format (default: 'png') */
|
||||
format?: OutputFormat;
|
||||
/** JPEG quality 0-100 (only for jpeg format, default: 90) */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RESULT TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** Wallpaper generation result */
|
||||
export interface WallpaperResult {
|
||||
/** Data URL of the generated wallpaper */
|
||||
dataUrl: string;
|
||||
/** Width of the generated image */
|
||||
width: number;
|
||||
/** Height of the generated image */
|
||||
height: number;
|
||||
/** Output format */
|
||||
format: OutputFormat;
|
||||
/** Size in bytes (approximate) */
|
||||
size: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR INTERFACE
|
||||
// =============================================================================
|
||||
|
||||
/** Wallpaper generator interface */
|
||||
export interface WallpaperGenerator {
|
||||
/**
|
||||
* Generate a wallpaper from an image source
|
||||
* @param source - The source image (data URL, canvas, or buffer)
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with the generated wallpaper result
|
||||
*/
|
||||
generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult>;
|
||||
|
||||
/**
|
||||
* Generate a preview (smaller, faster) of the wallpaper
|
||||
* @param source - The source image
|
||||
* @param options - Wallpaper generation options
|
||||
* @returns Promise with data URL of preview image
|
||||
*/
|
||||
preview(source: ImageSource, options: WallpaperOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Get list of all supported device presets
|
||||
* @returns Array of device presets
|
||||
*/
|
||||
getSupportedDevices(): DevicePreset[];
|
||||
|
||||
/**
|
||||
* Get device presets by category
|
||||
* @param category - Device category to filter by
|
||||
* @returns Array of device presets in that category
|
||||
*/
|
||||
getDevicesByCategory(category: DeviceCategory): DevicePreset[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SVELTE COMPONENT PROPS
|
||||
// =============================================================================
|
||||
|
||||
/** Props for WallpaperModal Svelte component */
|
||||
export interface WallpaperModalProps {
|
||||
/** Whether the modal is visible */
|
||||
show: boolean;
|
||||
/** Source image as data URL */
|
||||
imageDataUrl: string;
|
||||
/** Optional source image dimensions (for better scaling) */
|
||||
imageSize?: { width: number; height: number };
|
||||
/** Callback when modal is closed */
|
||||
onClose: () => void;
|
||||
/** Optional callback when wallpaper is generated */
|
||||
onGenerate?: (result: WallpaperResult) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRESET CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
/** Default layout options */
|
||||
export const DEFAULT_CENTER_LAYOUT: CenterLayout = {
|
||||
type: 'center',
|
||||
scale: 1.0,
|
||||
};
|
||||
|
||||
export const DEFAULT_CORNER_LAYOUT: CornerLayout = {
|
||||
type: 'corner',
|
||||
position: 'bottom-right',
|
||||
scale: 0.3,
|
||||
padding: 40,
|
||||
};
|
||||
|
||||
export const DEFAULT_PATTERN_LAYOUT: PatternLayout = {
|
||||
type: 'pattern',
|
||||
scale: 0.5,
|
||||
gap: 20,
|
||||
opacity: 0.15,
|
||||
};
|
||||
|
||||
/** Default background */
|
||||
export const DEFAULT_BACKGROUND: SolidBackground = {
|
||||
type: 'solid',
|
||||
color: '#1a1a2e',
|
||||
};
|
||||
|
||||
/** Default gradient backgrounds */
|
||||
export const GRADIENT_PRESETS: Record<string, GradientBackground> = {
|
||||
dark: {
|
||||
type: 'gradient',
|
||||
colors: ['#1a1a2e', '#16213e', '#0f3460'],
|
||||
angle: 180,
|
||||
},
|
||||
sunset: {
|
||||
type: 'gradient',
|
||||
colors: ['#ff6b6b', '#feca57', '#48dbfb'],
|
||||
angle: 135,
|
||||
},
|
||||
ocean: {
|
||||
type: 'gradient',
|
||||
colors: ['#0f0c29', '#302b63', '#24243e'],
|
||||
angle: 180,
|
||||
},
|
||||
forest: {
|
||||
type: 'gradient',
|
||||
colors: ['#134e5e', '#71b280'],
|
||||
angle: 180,
|
||||
},
|
||||
purple: {
|
||||
type: 'gradient',
|
||||
colors: ['#667eea', '#764ba2'],
|
||||
angle: 135,
|
||||
},
|
||||
};
|
||||
19
packages/wallpaper-generator/tsconfig.json
Normal file
19
packages/wallpaper-generator/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
1463
pnpm-lock.yaml
generated
1463
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue