kritzel-stencil 0.3.31 → 0.3.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/kritzel-active-users_45.cjs.entry.js +213 -107
- package/dist/collection/classes/managers/browser-permission.manager.js +4 -4
- package/dist/collection/classes/managers/clipboard.manager.js +36 -104
- package/dist/collection/classes/managers/clipboard.types.js +1 -0
- package/dist/collection/classes/providers/paste-intent/event-paste-intent.provider.js +48 -0
- package/dist/collection/classes/providers/paste-intent/ios-paste-intent.provider.js +16 -0
- package/dist/collection/classes/providers/paste-intent/navigator-paste-intent.provider.js +86 -0
- package/dist/collection/classes/registries/paste-intent-provider.registry.js +26 -0
- package/dist/collection/constants/version.js +1 -1
- package/dist/collection/interfaces/paste-intent-provider.interface.js +1 -0
- package/dist/components/index.js +1 -1
- package/dist/components/kritzel-editor.js +1 -1
- package/dist/components/kritzel-engine.js +1 -1
- package/dist/components/kritzel-settings.js +1 -1
- package/dist/components/{p-dxwV_Hq6.js → p-DK2ZByiU.js} +2 -2
- package/dist/components/{p-BohZHzi_.js → p-FjazwgrY.js} +1 -1
- package/dist/esm/kritzel-active-users_45.entry.js +213 -107
- package/dist/stencil/{p-16a19285.entry.js → p-be38d770.entry.js} +2 -2
- package/dist/stencil/stencil.esm.js +1 -1
- package/dist/types/classes/managers/clipboard.manager.d.ts +21 -37
- package/dist/types/classes/managers/clipboard.types.d.ts +23 -0
- package/dist/types/classes/providers/paste-intent/event-paste-intent.provider.d.ts +8 -0
- package/dist/types/classes/providers/paste-intent/ios-paste-intent.provider.d.ts +6 -0
- package/dist/types/classes/providers/paste-intent/navigator-paste-intent.provider.d.ts +9 -0
- package/dist/types/classes/registries/paste-intent-provider.registry.d.ts +10 -0
- package/dist/types/constants/version.d.ts +1 -1
- package/dist/types/interfaces/paste-intent-provider.interface.d.ts +10 -0
- package/package.json +1 -1
|
@@ -24055,17 +24055,17 @@ class KritzelBrowserPermissionManager {
|
|
|
24055
24055
|
*/
|
|
24056
24056
|
constructor(_core) {
|
|
24057
24057
|
this.registerRequestPermissionHandler('clipboard-read', async () => {
|
|
24058
|
-
// Firefox and Safari open native paste menus when
|
|
24058
|
+
// Firefox and Safari open native paste menus when the clipboard is read
|
|
24059
24059
|
// proactively, so rely on the permission state instead of an eager read.
|
|
24060
24060
|
if (this._isFirefox || this._isSafari) {
|
|
24061
24061
|
return 'granted';
|
|
24062
24062
|
}
|
|
24063
24063
|
try {
|
|
24064
|
-
await navigator.
|
|
24065
|
-
return 'granted';
|
|
24064
|
+
const status = await navigator.permissions.query({ name: 'clipboard-read' });
|
|
24065
|
+
return status.state === 'denied' ? 'denied' : 'granted';
|
|
24066
24066
|
}
|
|
24067
24067
|
catch {
|
|
24068
|
-
return '
|
|
24068
|
+
return 'granted';
|
|
24069
24069
|
}
|
|
24070
24070
|
});
|
|
24071
24071
|
}
|
|
@@ -24104,37 +24104,55 @@ class KritzelBrowserPermissionManager {
|
|
|
24104
24104
|
}
|
|
24105
24105
|
}
|
|
24106
24106
|
|
|
24107
|
-
|
|
24108
|
-
/**
|
|
24109
|
-
* Manages clipboard operations (copy/cut/paste) for a core instance.
|
|
24110
|
-
*
|
|
24111
|
-
* The system clipboard is the single source of truth: copy serializes the current
|
|
24112
|
-
* selection into a Kritzel payload, paste reads + revives it. No copied objects are
|
|
24113
|
-
* cached on the engine state.
|
|
24114
|
-
*/
|
|
24115
|
-
class KritzelClipboardManager {
|
|
24107
|
+
class KritzelPasteIntentProviderRegistry {
|
|
24116
24108
|
_core;
|
|
24117
|
-
|
|
24118
|
-
|
|
24119
|
-
|
|
24120
|
-
*/
|
|
24121
|
-
_pasteOffset = 0;
|
|
24122
|
-
constructor(core) {
|
|
24123
|
-
this._core = core;
|
|
24109
|
+
_providers = [];
|
|
24110
|
+
constructor(_core) {
|
|
24111
|
+
this._core = _core;
|
|
24124
24112
|
}
|
|
24125
|
-
|
|
24126
|
-
|
|
24127
|
-
|
|
24128
|
-
|
|
24129
|
-
|
|
24130
|
-
|
|
24131
|
-
|
|
24132
|
-
|
|
24113
|
+
register(provider) {
|
|
24114
|
+
this._providers.push(provider);
|
|
24115
|
+
}
|
|
24116
|
+
async resolve(event) {
|
|
24117
|
+
const context = {
|
|
24118
|
+
core: this._core,
|
|
24119
|
+
event,
|
|
24120
|
+
};
|
|
24121
|
+
for (const provider of this._providers) {
|
|
24122
|
+
if (!provider.canHandle(context)) {
|
|
24123
|
+
continue;
|
|
24124
|
+
}
|
|
24125
|
+
const intent = await provider.getPasteIntent(context);
|
|
24126
|
+
if (intent.type !== 'unknown') {
|
|
24127
|
+
return intent;
|
|
24128
|
+
}
|
|
24133
24129
|
}
|
|
24134
|
-
return
|
|
24130
|
+
return { type: 'unknown' };
|
|
24135
24131
|
}
|
|
24136
|
-
|
|
24137
|
-
|
|
24132
|
+
}
|
|
24133
|
+
|
|
24134
|
+
const CLIPBOARD_PAYLOAD_PREFIX = 'kritzel-clipboard/v1:';
|
|
24135
|
+
|
|
24136
|
+
class KritzelEventPasteIntentProvider {
|
|
24137
|
+
canHandle(context) {
|
|
24138
|
+
return !!context.event?.clipboardData;
|
|
24139
|
+
}
|
|
24140
|
+
async getPasteIntent(context) {
|
|
24141
|
+
const data = context.event?.clipboardData;
|
|
24142
|
+
if (!data) {
|
|
24143
|
+
return { type: 'unknown' };
|
|
24144
|
+
}
|
|
24145
|
+
const imageFiles = Array.from(data.files ?? []).filter(file => file.type.startsWith('image/'));
|
|
24146
|
+
if (imageFiles.length > 0) {
|
|
24147
|
+
return { type: 'image_files', files: imageFiles };
|
|
24148
|
+
}
|
|
24149
|
+
if (data.types.includes('text/plain')) {
|
|
24150
|
+
const textIntent = this.determineTextPasteIntent(data.getData('text/plain'));
|
|
24151
|
+
if (textIntent) {
|
|
24152
|
+
return textIntent;
|
|
24153
|
+
}
|
|
24154
|
+
}
|
|
24155
|
+
return { type: 'unknown' };
|
|
24138
24156
|
}
|
|
24139
24157
|
parseClipboardPayload(text) {
|
|
24140
24158
|
if (!text || !text.startsWith(CLIPBOARD_PAYLOAD_PREFIX)) {
|
|
@@ -24161,6 +24179,165 @@ class KritzelClipboardManager {
|
|
|
24161
24179
|
}
|
|
24162
24180
|
return null;
|
|
24163
24181
|
}
|
|
24182
|
+
}
|
|
24183
|
+
|
|
24184
|
+
class KritzelNavigatorPasteIntentProvider {
|
|
24185
|
+
canHandle(context) {
|
|
24186
|
+
return !context.event?.clipboardData && typeof navigator !== 'undefined' && !!navigator.clipboard;
|
|
24187
|
+
}
|
|
24188
|
+
async getPasteIntent(context) {
|
|
24189
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
|
24190
|
+
return { type: 'unknown' };
|
|
24191
|
+
}
|
|
24192
|
+
const { clipboard } = navigator;
|
|
24193
|
+
const permissionState = await context.core.permissionManager.checkPermission('clipboard-read');
|
|
24194
|
+
if (permissionState !== 'granted') {
|
|
24195
|
+
const requestedState = await context.core.permissionManager.requestPermission('clipboard-read');
|
|
24196
|
+
if (requestedState !== 'granted') {
|
|
24197
|
+
return { type: 'unknown' };
|
|
24198
|
+
}
|
|
24199
|
+
}
|
|
24200
|
+
if (clipboard.read) {
|
|
24201
|
+
try {
|
|
24202
|
+
const items = await clipboard.read();
|
|
24203
|
+
const { imageFiles, text } = await this.processClipboardItems(items);
|
|
24204
|
+
if (imageFiles.length > 0) {
|
|
24205
|
+
return { type: 'image_files', files: imageFiles };
|
|
24206
|
+
}
|
|
24207
|
+
if (text !== null) {
|
|
24208
|
+
const textIntent = this.determineTextPasteIntent(text);
|
|
24209
|
+
if (textIntent) {
|
|
24210
|
+
return textIntent;
|
|
24211
|
+
}
|
|
24212
|
+
}
|
|
24213
|
+
}
|
|
24214
|
+
catch {
|
|
24215
|
+
// Fall back to readText below when read() is denied or unavailable at runtime.
|
|
24216
|
+
}
|
|
24217
|
+
}
|
|
24218
|
+
if (clipboard.readText) {
|
|
24219
|
+
const textIntent = this.determineTextPasteIntent(await clipboard.readText());
|
|
24220
|
+
if (textIntent) {
|
|
24221
|
+
return textIntent;
|
|
24222
|
+
}
|
|
24223
|
+
}
|
|
24224
|
+
return { type: 'unknown' };
|
|
24225
|
+
}
|
|
24226
|
+
async processClipboardItems(items) {
|
|
24227
|
+
const imageFiles = [];
|
|
24228
|
+
let text = null;
|
|
24229
|
+
await Promise.all(items.map(async (item) => {
|
|
24230
|
+
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
24231
|
+
if (imageType) {
|
|
24232
|
+
const blob = await item.getType(imageType);
|
|
24233
|
+
imageFiles.push(new File([blob], 'pasted-image', { type: imageType }));
|
|
24234
|
+
return;
|
|
24235
|
+
}
|
|
24236
|
+
if (text === null && item.types.includes('text/plain')) {
|
|
24237
|
+
const blob = await item.getType('text/plain');
|
|
24238
|
+
text = await blob.text();
|
|
24239
|
+
}
|
|
24240
|
+
}));
|
|
24241
|
+
return { imageFiles, text };
|
|
24242
|
+
}
|
|
24243
|
+
parseClipboardPayload(text) {
|
|
24244
|
+
if (!text || !text.startsWith(CLIPBOARD_PAYLOAD_PREFIX)) {
|
|
24245
|
+
return null;
|
|
24246
|
+
}
|
|
24247
|
+
try {
|
|
24248
|
+
const parsed = JSON.parse(text.slice(CLIPBOARD_PAYLOAD_PREFIX.length));
|
|
24249
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.objects)) {
|
|
24250
|
+
return null;
|
|
24251
|
+
}
|
|
24252
|
+
return parsed;
|
|
24253
|
+
}
|
|
24254
|
+
catch {
|
|
24255
|
+
return null;
|
|
24256
|
+
}
|
|
24257
|
+
}
|
|
24258
|
+
determineTextPasteIntent(text) {
|
|
24259
|
+
const payload = this.parseClipboardPayload(text);
|
|
24260
|
+
if (payload) {
|
|
24261
|
+
return { type: 'kritzel_objects', payload };
|
|
24262
|
+
}
|
|
24263
|
+
if (text && text.trim().length > 0) {
|
|
24264
|
+
return { type: 'plain_text', text };
|
|
24265
|
+
}
|
|
24266
|
+
return null;
|
|
24267
|
+
}
|
|
24268
|
+
}
|
|
24269
|
+
|
|
24270
|
+
class KritzelIosPasteIntentProvider {
|
|
24271
|
+
canHandle(context) {
|
|
24272
|
+
return !context.event?.clipboardData && schema_constants.KritzelDevicesHelper.isIOS();
|
|
24273
|
+
}
|
|
24274
|
+
async getPasteIntent(context) {
|
|
24275
|
+
// On iOS Safari, eventless programmatic clipboard reads are commonly unavailable
|
|
24276
|
+
// (e.g. the context-menu paste calls engine.paste() without a native ClipboardEvent).
|
|
24277
|
+
// Fall back to the in-memory session buffer so internal object copy/paste still works.
|
|
24278
|
+
const payload = context.core.clipboardManager.sessionClipboardPayload;
|
|
24279
|
+
if (payload) {
|
|
24280
|
+
return { type: 'kritzel_objects', payload };
|
|
24281
|
+
}
|
|
24282
|
+
return { type: 'unknown' };
|
|
24283
|
+
}
|
|
24284
|
+
}
|
|
24285
|
+
|
|
24286
|
+
/**
|
|
24287
|
+
* Manages clipboard operations (copy/cut/paste) for a core instance.
|
|
24288
|
+
*
|
|
24289
|
+
* The system clipboard is the preferred source of truth: copy serializes the current
|
|
24290
|
+
* selection into a Kritzel payload, paste reads + revives it.
|
|
24291
|
+
*
|
|
24292
|
+
* A copy also mirrors the payload into an in-memory session buffer. On platforms where
|
|
24293
|
+
* eventless programmatic clipboard access is unavailable (notably iOS Safari, where the
|
|
24294
|
+
* context menu triggers copy/paste without a native ClipboardEvent), the session buffer
|
|
24295
|
+
* is used as the fallback source so internal object copy/paste keeps working.
|
|
24296
|
+
*/
|
|
24297
|
+
class KritzelClipboardManager {
|
|
24298
|
+
_core;
|
|
24299
|
+
_pasteIntentRegistry;
|
|
24300
|
+
/**
|
|
24301
|
+
* In-memory mirror of the most recent copy payload. Acts as the fallback clipboard
|
|
24302
|
+
* source when the system clipboard cannot be read programmatically (e.g. iOS Safari
|
|
24303
|
+
* context-menu paste). Reset is not required; a new copy overwrites it.
|
|
24304
|
+
*/
|
|
24305
|
+
_sessionClipboardPayload = null;
|
|
24306
|
+
/**
|
|
24307
|
+
* In-memory cascade offset (world units) applied to successive pastes of the same
|
|
24308
|
+
* clipboard content so they don't stack exactly on top of each other. Reset on copy/cut.
|
|
24309
|
+
*/
|
|
24310
|
+
_pasteOffset = 0;
|
|
24311
|
+
constructor(core) {
|
|
24312
|
+
this._core = core;
|
|
24313
|
+
this._pasteIntentRegistry = new KritzelPasteIntentProviderRegistry(this._core);
|
|
24314
|
+
// Provider order is intentional: iOS eventless path should short-circuit,
|
|
24315
|
+
// native paste events remain preferred, and async navigator APIs are fallback.
|
|
24316
|
+
this._pasteIntentRegistry.register(new KritzelIosPasteIntentProvider());
|
|
24317
|
+
this._pasteIntentRegistry.register(new KritzelEventPasteIntentProvider());
|
|
24318
|
+
this._pasteIntentRegistry.register(new KritzelNavigatorPasteIntentProvider());
|
|
24319
|
+
}
|
|
24320
|
+
/**
|
|
24321
|
+
* The most recent copy payload mirrored in memory, used as a fallback clipboard
|
|
24322
|
+
* source when the system clipboard cannot be read programmatically.
|
|
24323
|
+
*/
|
|
24324
|
+
get sessionClipboardPayload() {
|
|
24325
|
+
return this._sessionClipboardPayload;
|
|
24326
|
+
}
|
|
24327
|
+
/**
|
|
24328
|
+
* Serializes an object for the clipboard payload. Groups additionally embed their
|
|
24329
|
+
* (recursively serialized) children under `__children__` so the payload is self-contained.
|
|
24330
|
+
*/
|
|
24331
|
+
serializeForClipboard(object) {
|
|
24332
|
+
const serialized = (typeof object.serialize === 'function' ? object.serialize() : object);
|
|
24333
|
+
if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
|
|
24334
|
+
serialized.__children__ = object.children.map(child => this.serializeForClipboard(child));
|
|
24335
|
+
}
|
|
24336
|
+
return serialized;
|
|
24337
|
+
}
|
|
24338
|
+
serializeClipboardPayload(payload) {
|
|
24339
|
+
return `${CLIPBOARD_PAYLOAD_PREFIX}${JSON.stringify(payload)}`;
|
|
24340
|
+
}
|
|
24164
24341
|
async writeBrowserClipboardPayload(payload, event) {
|
|
24165
24342
|
const serialized = this.serializeClipboardPayload(payload);
|
|
24166
24343
|
if (event?.clipboardData) {
|
|
@@ -24240,80 +24417,6 @@ class KritzelClipboardManager {
|
|
|
24240
24417
|
}
|
|
24241
24418
|
}
|
|
24242
24419
|
}
|
|
24243
|
-
/**
|
|
24244
|
-
* Evaluates the clipboard event to determine exactly what the user is trying to paste.
|
|
24245
|
-
* Returns a PasteIntent covering the 3 main cases: Kritzel objects, Images, or Plain text.
|
|
24246
|
-
*/
|
|
24247
|
-
async determinePasteIntent(event) {
|
|
24248
|
-
if (event?.clipboardData) {
|
|
24249
|
-
const data = event.clipboardData;
|
|
24250
|
-
const imageFiles = Array.from(data.files ?? []).filter(file => file.type.startsWith('image/'));
|
|
24251
|
-
if (imageFiles.length > 0) {
|
|
24252
|
-
return { type: 'image_files', files: imageFiles };
|
|
24253
|
-
}
|
|
24254
|
-
if (data.types.includes('text/plain')) {
|
|
24255
|
-
const textIntent = this.determineTextPasteIntent(data.getData('text/plain'));
|
|
24256
|
-
if (textIntent)
|
|
24257
|
-
return textIntent;
|
|
24258
|
-
}
|
|
24259
|
-
return { type: 'unknown' };
|
|
24260
|
-
}
|
|
24261
|
-
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
|
24262
|
-
return { type: 'unknown' };
|
|
24263
|
-
}
|
|
24264
|
-
const { clipboard } = navigator;
|
|
24265
|
-
const permissionState = await this._core.permissionManager.checkPermission('clipboard-read');
|
|
24266
|
-
if (permissionState !== 'granted') {
|
|
24267
|
-
const requestedState = await this._core.permissionManager.requestPermission('clipboard-read');
|
|
24268
|
-
if (requestedState !== 'granted') {
|
|
24269
|
-
return { type: 'unknown' };
|
|
24270
|
-
}
|
|
24271
|
-
}
|
|
24272
|
-
if (clipboard.read) {
|
|
24273
|
-
try {
|
|
24274
|
-
const items = await clipboard.read();
|
|
24275
|
-
const { imageFiles, text } = await this.processClipboardItems(items);
|
|
24276
|
-
if (imageFiles.length > 0) {
|
|
24277
|
-
return { type: 'image_files', files: imageFiles };
|
|
24278
|
-
}
|
|
24279
|
-
if (text !== null) {
|
|
24280
|
-
const textIntent = this.determineTextPasteIntent(text);
|
|
24281
|
-
if (textIntent)
|
|
24282
|
-
return textIntent;
|
|
24283
|
-
}
|
|
24284
|
-
}
|
|
24285
|
-
catch {
|
|
24286
|
-
// Fall back to readText below when read() is denied or unavailable at runtime.
|
|
24287
|
-
}
|
|
24288
|
-
}
|
|
24289
|
-
if (clipboard.readText) {
|
|
24290
|
-
const textIntent = this.determineTextPasteIntent(await clipboard.readText());
|
|
24291
|
-
if (textIntent)
|
|
24292
|
-
return textIntent;
|
|
24293
|
-
}
|
|
24294
|
-
return { type: 'unknown' };
|
|
24295
|
-
}
|
|
24296
|
-
/**
|
|
24297
|
-
* Helper to parse clipboard items in parallel
|
|
24298
|
-
*/
|
|
24299
|
-
async processClipboardItems(items) {
|
|
24300
|
-
const imageFiles = [];
|
|
24301
|
-
let text = null;
|
|
24302
|
-
// Process items concurrently
|
|
24303
|
-
await Promise.all(items.map(async (item) => {
|
|
24304
|
-
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
24305
|
-
if (imageType) {
|
|
24306
|
-
const blob = await item.getType(imageType);
|
|
24307
|
-
imageFiles.push(new File([blob], 'pasted-image', { type: imageType }));
|
|
24308
|
-
return;
|
|
24309
|
-
}
|
|
24310
|
-
if (text === null && item.types.includes('text/plain')) {
|
|
24311
|
-
const blob = await item.getType('text/plain');
|
|
24312
|
-
text = await blob.text();
|
|
24313
|
-
}
|
|
24314
|
-
}));
|
|
24315
|
-
return { imageFiles, text };
|
|
24316
|
-
}
|
|
24317
24420
|
/**
|
|
24318
24421
|
* Copies currently selected objects into internal clipboard state.
|
|
24319
24422
|
*/
|
|
@@ -24329,6 +24432,9 @@ class KritzelClipboardManager {
|
|
|
24329
24432
|
};
|
|
24330
24433
|
// A fresh copy resets the cascade so the first paste lands at the default offset.
|
|
24331
24434
|
this._pasteOffset = 0;
|
|
24435
|
+
// Mirror the payload in memory so eventless paste still works where the system
|
|
24436
|
+
// clipboard is not programmatically accessible (e.g. iOS Safari context menu).
|
|
24437
|
+
this._sessionClipboardPayload = payload;
|
|
24332
24438
|
void this.writeBrowserClipboardPayload(payload, event);
|
|
24333
24439
|
}
|
|
24334
24440
|
/**
|
|
@@ -24387,7 +24493,7 @@ class KritzelClipboardManager {
|
|
|
24387
24493
|
* Pastes objects from clipboard with optional explicit world coordinates.
|
|
24388
24494
|
*/
|
|
24389
24495
|
async paste(x, y, event) {
|
|
24390
|
-
const pasteIntent = await this.
|
|
24496
|
+
const pasteIntent = await this._pasteIntentRegistry.resolve(event);
|
|
24391
24497
|
let objects = null;
|
|
24392
24498
|
if (pasteIntent.type === 'kritzel_objects') {
|
|
24393
24499
|
objects = this.materializeKritzelObjects(pasteIntent);
|
|
@@ -31296,7 +31402,7 @@ const KritzelPortal = class {
|
|
|
31296
31402
|
* This file is auto-generated by the version bump scripts.
|
|
31297
31403
|
* Do not modify manually.
|
|
31298
31404
|
*/
|
|
31299
|
-
const KRITZEL_VERSION = '0.3.
|
|
31405
|
+
const KRITZEL_VERSION = '0.3.32';
|
|
31300
31406
|
|
|
31301
31407
|
const kritzelSettingsCss = () => `:host{display:contents}kritzel-dialog{--kritzel-dialog-body-padding:0;--kritzel-dialog-width-large:800px;--kritzel-dialog-height-large:500px}.footer-button{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px}.cancel-button{border:1px solid #ebebeb;background:#fff;color:inherit}.cancel-button:hover{background:#f5f5f5}.settings-content{padding:0}.settings-content h3{margin:0 0 16px 0;font-size:18px;font-weight:600;color:var(--kritzel-settings-content-heading-color, #333333)}.settings-content p{margin:0;font-size:14px;color:var(--kritzel-settings-content-text-color, #666666);line-height:1.5}.settings-group{display:flex;flex-direction:column;gap:24px}.settings-item{display:flex;flex-direction:column;gap:8px}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.settings-label{font-size:14px;font-weight:600;color:var(--kritzel-settings-label-color, #333333);margin:0 0 4px 0}.settings-description{font-size:12px;color:var(--kritzel-settings-description-color, #888888);margin:0;line-height:1.4}.shortcuts-list{display:flex;flex-direction:column;gap:24px}.shortcuts-category{display:flex;flex-direction:column;gap:8px}.shortcuts-category-title{font-size:14px;font-weight:600;color:var(--kritzel-settings-label-color, #333333);margin:0 0 4px 0}.shortcuts-group{display:flex;flex-direction:column;gap:4px}.shortcut-item{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;border-radius:4px;background:var(--kritzel-settings-shortcut-item-bg, rgba(0, 0, 0, 0.02))}.shortcut-label{font-size:14px;color:var(--kritzel-settings-content-text-color, #666666)}.shortcut-key{font-family:monospace;font-size:12px;padding:2px 8px;border-radius:4px;background:var(--kritzel-settings-shortcut-key-bg, #f0f0f0);color:var(--kritzel-settings-shortcut-key-color, #333333);border:1px solid var(--kritzel-settings-shortcut-key-border, #ddd)}`;
|
|
31302
31408
|
|
|
@@ -14,17 +14,17 @@ export class KritzelBrowserPermissionManager {
|
|
|
14
14
|
*/
|
|
15
15
|
constructor(_core) {
|
|
16
16
|
this.registerRequestPermissionHandler('clipboard-read', async () => {
|
|
17
|
-
// Firefox and Safari open native paste menus when
|
|
17
|
+
// Firefox and Safari open native paste menus when the clipboard is read
|
|
18
18
|
// proactively, so rely on the permission state instead of an eager read.
|
|
19
19
|
if (this._isFirefox || this._isSafari) {
|
|
20
20
|
return 'granted';
|
|
21
21
|
}
|
|
22
22
|
try {
|
|
23
|
-
await navigator.
|
|
24
|
-
return 'granted';
|
|
23
|
+
const status = await navigator.permissions.query({ name: 'clipboard-read' });
|
|
24
|
+
return status.state === 'denied' ? 'denied' : 'granted';
|
|
25
25
|
}
|
|
26
26
|
catch {
|
|
27
|
-
return '
|
|
27
|
+
return 'granted';
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
30
|
}
|
|
@@ -3,17 +3,32 @@ import { KritzelText } from "../objects/text.class";
|
|
|
3
3
|
import { KritzelSelectionGroup } from "../objects/selection-group.class";
|
|
4
4
|
import { KritzelClassHelper } from "../../helpers/class.helper";
|
|
5
5
|
import { KritzelReviver } from "../core/reviver.class";
|
|
6
|
+
import { KritzelPasteIntentProviderRegistry } from "../registries/paste-intent-provider.registry";
|
|
7
|
+
import { KritzelEventPasteIntentProvider } from "../providers/paste-intent/event-paste-intent.provider";
|
|
8
|
+
import { KritzelNavigatorPasteIntentProvider } from "../providers/paste-intent/navigator-paste-intent.provider";
|
|
9
|
+
import { KritzelIosPasteIntentProvider } from "../providers/paste-intent/ios-paste-intent.provider";
|
|
10
|
+
import { CLIPBOARD_PAYLOAD_PREFIX } from "./clipboard.types";
|
|
6
11
|
import imageCompression from "browser-image-compression";
|
|
7
|
-
const CLIPBOARD_PAYLOAD_PREFIX = 'kritzel-clipboard/v1:';
|
|
8
12
|
/**
|
|
9
13
|
* Manages clipboard operations (copy/cut/paste) for a core instance.
|
|
10
14
|
*
|
|
11
|
-
* The system clipboard is the
|
|
12
|
-
* selection into a Kritzel payload, paste reads + revives it.
|
|
13
|
-
*
|
|
15
|
+
* The system clipboard is the preferred source of truth: copy serializes the current
|
|
16
|
+
* selection into a Kritzel payload, paste reads + revives it.
|
|
17
|
+
*
|
|
18
|
+
* A copy also mirrors the payload into an in-memory session buffer. On platforms where
|
|
19
|
+
* eventless programmatic clipboard access is unavailable (notably iOS Safari, where the
|
|
20
|
+
* context menu triggers copy/paste without a native ClipboardEvent), the session buffer
|
|
21
|
+
* is used as the fallback source so internal object copy/paste keeps working.
|
|
14
22
|
*/
|
|
15
23
|
export class KritzelClipboardManager {
|
|
16
24
|
_core;
|
|
25
|
+
_pasteIntentRegistry;
|
|
26
|
+
/**
|
|
27
|
+
* In-memory mirror of the most recent copy payload. Acts as the fallback clipboard
|
|
28
|
+
* source when the system clipboard cannot be read programmatically (e.g. iOS Safari
|
|
29
|
+
* context-menu paste). Reset is not required; a new copy overwrites it.
|
|
30
|
+
*/
|
|
31
|
+
_sessionClipboardPayload = null;
|
|
17
32
|
/**
|
|
18
33
|
* In-memory cascade offset (world units) applied to successive pastes of the same
|
|
19
34
|
* clipboard content so they don't stack exactly on top of each other. Reset on copy/cut.
|
|
@@ -21,6 +36,19 @@ export class KritzelClipboardManager {
|
|
|
21
36
|
_pasteOffset = 0;
|
|
22
37
|
constructor(core) {
|
|
23
38
|
this._core = core;
|
|
39
|
+
this._pasteIntentRegistry = new KritzelPasteIntentProviderRegistry(this._core);
|
|
40
|
+
// Provider order is intentional: iOS eventless path should short-circuit,
|
|
41
|
+
// native paste events remain preferred, and async navigator APIs are fallback.
|
|
42
|
+
this._pasteIntentRegistry.register(new KritzelIosPasteIntentProvider());
|
|
43
|
+
this._pasteIntentRegistry.register(new KritzelEventPasteIntentProvider());
|
|
44
|
+
this._pasteIntentRegistry.register(new KritzelNavigatorPasteIntentProvider());
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The most recent copy payload mirrored in memory, used as a fallback clipboard
|
|
48
|
+
* source when the system clipboard cannot be read programmatically.
|
|
49
|
+
*/
|
|
50
|
+
get sessionClipboardPayload() {
|
|
51
|
+
return this._sessionClipboardPayload;
|
|
24
52
|
}
|
|
25
53
|
/**
|
|
26
54
|
* Serializes an object for the clipboard payload. Groups additionally embed their
|
|
@@ -36,31 +64,6 @@ export class KritzelClipboardManager {
|
|
|
36
64
|
serializeClipboardPayload(payload) {
|
|
37
65
|
return `${CLIPBOARD_PAYLOAD_PREFIX}${JSON.stringify(payload)}`;
|
|
38
66
|
}
|
|
39
|
-
parseClipboardPayload(text) {
|
|
40
|
-
if (!text || !text.startsWith(CLIPBOARD_PAYLOAD_PREFIX)) {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const parsed = JSON.parse(text.slice(CLIPBOARD_PAYLOAD_PREFIX.length));
|
|
45
|
-
if (parsed?.version !== 1 || !Array.isArray(parsed.objects)) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
return parsed;
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
determineTextPasteIntent(text) {
|
|
55
|
-
const payload = this.parseClipboardPayload(text);
|
|
56
|
-
if (payload) {
|
|
57
|
-
return { type: 'kritzel_objects', payload };
|
|
58
|
-
}
|
|
59
|
-
if (text && text.trim().length > 0) {
|
|
60
|
-
return { type: 'plain_text', text };
|
|
61
|
-
}
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
67
|
async writeBrowserClipboardPayload(payload, event) {
|
|
65
68
|
const serialized = this.serializeClipboardPayload(payload);
|
|
66
69
|
if (event?.clipboardData) {
|
|
@@ -140,80 +143,6 @@ export class KritzelClipboardManager {
|
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Evaluates the clipboard event to determine exactly what the user is trying to paste.
|
|
145
|
-
* Returns a PasteIntent covering the 3 main cases: Kritzel objects, Images, or Plain text.
|
|
146
|
-
*/
|
|
147
|
-
async determinePasteIntent(event) {
|
|
148
|
-
if (event?.clipboardData) {
|
|
149
|
-
const data = event.clipboardData;
|
|
150
|
-
const imageFiles = Array.from(data.files ?? []).filter(file => file.type.startsWith('image/'));
|
|
151
|
-
if (imageFiles.length > 0) {
|
|
152
|
-
return { type: 'image_files', files: imageFiles };
|
|
153
|
-
}
|
|
154
|
-
if (data.types.includes('text/plain')) {
|
|
155
|
-
const textIntent = this.determineTextPasteIntent(data.getData('text/plain'));
|
|
156
|
-
if (textIntent)
|
|
157
|
-
return textIntent;
|
|
158
|
-
}
|
|
159
|
-
return { type: 'unknown' };
|
|
160
|
-
}
|
|
161
|
-
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
|
162
|
-
return { type: 'unknown' };
|
|
163
|
-
}
|
|
164
|
-
const { clipboard } = navigator;
|
|
165
|
-
const permissionState = await this._core.permissionManager.checkPermission('clipboard-read');
|
|
166
|
-
if (permissionState !== 'granted') {
|
|
167
|
-
const requestedState = await this._core.permissionManager.requestPermission('clipboard-read');
|
|
168
|
-
if (requestedState !== 'granted') {
|
|
169
|
-
return { type: 'unknown' };
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (clipboard.read) {
|
|
173
|
-
try {
|
|
174
|
-
const items = await clipboard.read();
|
|
175
|
-
const { imageFiles, text } = await this.processClipboardItems(items);
|
|
176
|
-
if (imageFiles.length > 0) {
|
|
177
|
-
return { type: 'image_files', files: imageFiles };
|
|
178
|
-
}
|
|
179
|
-
if (text !== null) {
|
|
180
|
-
const textIntent = this.determineTextPasteIntent(text);
|
|
181
|
-
if (textIntent)
|
|
182
|
-
return textIntent;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
// Fall back to readText below when read() is denied or unavailable at runtime.
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (clipboard.readText) {
|
|
190
|
-
const textIntent = this.determineTextPasteIntent(await clipboard.readText());
|
|
191
|
-
if (textIntent)
|
|
192
|
-
return textIntent;
|
|
193
|
-
}
|
|
194
|
-
return { type: 'unknown' };
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Helper to parse clipboard items in parallel
|
|
198
|
-
*/
|
|
199
|
-
async processClipboardItems(items) {
|
|
200
|
-
const imageFiles = [];
|
|
201
|
-
let text = null;
|
|
202
|
-
// Process items concurrently
|
|
203
|
-
await Promise.all(items.map(async (item) => {
|
|
204
|
-
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
205
|
-
if (imageType) {
|
|
206
|
-
const blob = await item.getType(imageType);
|
|
207
|
-
imageFiles.push(new File([blob], 'pasted-image', { type: imageType }));
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (text === null && item.types.includes('text/plain')) {
|
|
211
|
-
const blob = await item.getType('text/plain');
|
|
212
|
-
text = await blob.text();
|
|
213
|
-
}
|
|
214
|
-
}));
|
|
215
|
-
return { imageFiles, text };
|
|
216
|
-
}
|
|
217
146
|
/**
|
|
218
147
|
* Copies currently selected objects into internal clipboard state.
|
|
219
148
|
*/
|
|
@@ -229,6 +158,9 @@ export class KritzelClipboardManager {
|
|
|
229
158
|
};
|
|
230
159
|
// A fresh copy resets the cascade so the first paste lands at the default offset.
|
|
231
160
|
this._pasteOffset = 0;
|
|
161
|
+
// Mirror the payload in memory so eventless paste still works where the system
|
|
162
|
+
// clipboard is not programmatically accessible (e.g. iOS Safari context menu).
|
|
163
|
+
this._sessionClipboardPayload = payload;
|
|
232
164
|
void this.writeBrowserClipboardPayload(payload, event);
|
|
233
165
|
}
|
|
234
166
|
/**
|
|
@@ -287,7 +219,7 @@ export class KritzelClipboardManager {
|
|
|
287
219
|
* Pastes objects from clipboard with optional explicit world coordinates.
|
|
288
220
|
*/
|
|
289
221
|
async paste(x, y, event) {
|
|
290
|
-
const pasteIntent = await this.
|
|
222
|
+
const pasteIntent = await this._pasteIntentRegistry.resolve(event);
|
|
291
223
|
let objects = null;
|
|
292
224
|
if (pasteIntent.type === 'kritzel_objects') {
|
|
293
225
|
objects = this.materializeKritzelObjects(pasteIntent);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLIPBOARD_PAYLOAD_PREFIX = 'kritzel-clipboard/v1:';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CLIPBOARD_PAYLOAD_PREFIX } from "../../managers/clipboard.types";
|
|
2
|
+
export class KritzelEventPasteIntentProvider {
|
|
3
|
+
canHandle(context) {
|
|
4
|
+
return !!context.event?.clipboardData;
|
|
5
|
+
}
|
|
6
|
+
async getPasteIntent(context) {
|
|
7
|
+
const data = context.event?.clipboardData;
|
|
8
|
+
if (!data) {
|
|
9
|
+
return { type: 'unknown' };
|
|
10
|
+
}
|
|
11
|
+
const imageFiles = Array.from(data.files ?? []).filter(file => file.type.startsWith('image/'));
|
|
12
|
+
if (imageFiles.length > 0) {
|
|
13
|
+
return { type: 'image_files', files: imageFiles };
|
|
14
|
+
}
|
|
15
|
+
if (data.types.includes('text/plain')) {
|
|
16
|
+
const textIntent = this.determineTextPasteIntent(data.getData('text/plain'));
|
|
17
|
+
if (textIntent) {
|
|
18
|
+
return textIntent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { type: 'unknown' };
|
|
22
|
+
}
|
|
23
|
+
parseClipboardPayload(text) {
|
|
24
|
+
if (!text || !text.startsWith(CLIPBOARD_PAYLOAD_PREFIX)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text.slice(CLIPBOARD_PAYLOAD_PREFIX.length));
|
|
29
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.objects)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
determineTextPasteIntent(text) {
|
|
39
|
+
const payload = this.parseClipboardPayload(text);
|
|
40
|
+
if (payload) {
|
|
41
|
+
return { type: 'kritzel_objects', payload };
|
|
42
|
+
}
|
|
43
|
+
if (text && text.trim().length > 0) {
|
|
44
|
+
return { type: 'plain_text', text };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { KritzelDevicesHelper } from "../../../helpers/devices.helper";
|
|
2
|
+
export class KritzelIosPasteIntentProvider {
|
|
3
|
+
canHandle(context) {
|
|
4
|
+
return !context.event?.clipboardData && KritzelDevicesHelper.isIOS();
|
|
5
|
+
}
|
|
6
|
+
async getPasteIntent(context) {
|
|
7
|
+
// On iOS Safari, eventless programmatic clipboard reads are commonly unavailable
|
|
8
|
+
// (e.g. the context-menu paste calls engine.paste() without a native ClipboardEvent).
|
|
9
|
+
// Fall back to the in-memory session buffer so internal object copy/paste still works.
|
|
10
|
+
const payload = context.core.clipboardManager.sessionClipboardPayload;
|
|
11
|
+
if (payload) {
|
|
12
|
+
return { type: 'kritzel_objects', payload };
|
|
13
|
+
}
|
|
14
|
+
return { type: 'unknown' };
|
|
15
|
+
}
|
|
16
|
+
}
|