mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
import supported from './supported.js';
|
||||
|
||||
const implementation = !supported
|
||||
? import('./legacy/directory-open.js')
|
||||
: import('./fs-access/directory-open.js');
|
||||
|
||||
/**
|
||||
* For opening directories, dynamically either loads the File System Access API
|
||||
* module or the legacy method.
|
||||
*/
|
||||
export async function directoryOpen(...args) {
|
||||
return (await implementation).default(...args);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
import supported from './supported.js';
|
||||
|
||||
const implementation = !supported
|
||||
? import('./legacy/file-open.js')
|
||||
: import('./fs-access/file-open.js');
|
||||
|
||||
/**
|
||||
* For opening files, dynamically either loads the File System Access API module
|
||||
* or the legacy method.
|
||||
*/
|
||||
export async function fileOpen(...args) {
|
||||
return (await implementation).default(...args);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
import supported from './supported.js';
|
||||
|
||||
const implementation = !supported
|
||||
? import('./legacy/file-save.js')
|
||||
: import('./fs-access/file-save.js');
|
||||
|
||||
/**
|
||||
* For saving files, dynamically either loads the File System Access API module
|
||||
* or the legacy method.
|
||||
*/
|
||||
export async function fileSave(...args) {
|
||||
return (await implementation).default(...args);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
const getFiles = async (
|
||||
dirHandle,
|
||||
recursive,
|
||||
path = dirHandle.name,
|
||||
skipDirectory
|
||||
) => {
|
||||
const dirs = [];
|
||||
const files = [];
|
||||
for (const entry of dirHandle.values()) {
|
||||
const nestedPath = `${path}/${entry.name}`;
|
||||
if (entry.kind === 'file') {
|
||||
files.push(
|
||||
await entry.getFile().then(file => {
|
||||
file.directoryHandle = dirHandle;
|
||||
return Object.defineProperty(file, 'webkitRelativePath', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => nestedPath,
|
||||
});
|
||||
})
|
||||
);
|
||||
} else if (
|
||||
entry.kind === 'directory' &&
|
||||
recursive &&
|
||||
(!skipDirectory || !skipDirectory(entry))
|
||||
) {
|
||||
dirs.push(
|
||||
await getFiles(entry, recursive, nestedPath, skipDirectory)
|
||||
);
|
||||
}
|
||||
}
|
||||
return [...(await Promise.all(dirs)).flat(), ...(await Promise.all(files))];
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a directory from disk using the File System Access API.
|
||||
* @type { typeof import("../../index").directoryOpen }
|
||||
*/
|
||||
export default async (options = {}) => {
|
||||
options.recursive = options.recursive || false;
|
||||
const handle = await window.showDirectoryPicker({
|
||||
id: options.id,
|
||||
startIn: options.startIn,
|
||||
});
|
||||
return getFiles(
|
||||
handle,
|
||||
options.recursive,
|
||||
undefined,
|
||||
options.skipDirectory
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
const getFileWithHandle = async handle => {
|
||||
const file = await handle.getFile();
|
||||
file.handle = handle;
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a file from disk using the File System Access API.
|
||||
* @type { typeof import("../../index").fileOpen }
|
||||
*/
|
||||
export default async (options = [{}]) => {
|
||||
if (!Array.isArray(options)) {
|
||||
options = [options];
|
||||
}
|
||||
const types = [];
|
||||
options.forEach((option, i) => {
|
||||
types[i] = {
|
||||
description: option.description || '',
|
||||
accept: {},
|
||||
};
|
||||
if (option.mimeTypes) {
|
||||
option.mimeTypes.map(mimeType => {
|
||||
types[i].accept[mimeType] = option.extensions || [];
|
||||
});
|
||||
} else {
|
||||
types[i].accept['*/*'] = option.extensions || [];
|
||||
}
|
||||
});
|
||||
const handleOrHandles = await window.showOpenFilePicker({
|
||||
id: options[0].id,
|
||||
startIn: options[0].startIn,
|
||||
types,
|
||||
multiple: options[0].multiple || false,
|
||||
excludeAcceptAllOption: options[0].excludeAcceptAllOption || false,
|
||||
});
|
||||
const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
|
||||
if (options[0].multiple) {
|
||||
return files;
|
||||
}
|
||||
return files[0];
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* Saves a file to disk using the File System Access API.
|
||||
* @type { typeof import("../../index").fileSave }
|
||||
*/
|
||||
export default async (
|
||||
blobOrResponse,
|
||||
options = [{}],
|
||||
existingHandle = null,
|
||||
throwIfExistingHandleNotGood = false
|
||||
) => {
|
||||
if (!Array.isArray(options)) {
|
||||
options = [options];
|
||||
}
|
||||
options[0].fileName = options[0].fileName || 'Untitled';
|
||||
const types = [];
|
||||
options.forEach((option, i) => {
|
||||
types[i] = {
|
||||
description: option.description || '',
|
||||
accept: {},
|
||||
};
|
||||
if (option.mimeTypes) {
|
||||
if (i === 0) {
|
||||
if (blobOrResponse.type) {
|
||||
option.mimeTypes.push(blobOrResponse.type);
|
||||
} else if (
|
||||
blobOrResponse.headers &&
|
||||
blobOrResponse.headers.get('content-type')
|
||||
) {
|
||||
option.mimeTypes.push(
|
||||
blobOrResponse.headers.get('content-type')
|
||||
);
|
||||
}
|
||||
}
|
||||
option.mimeTypes.map(mimeType => {
|
||||
types[i].accept[mimeType] = option.extensions || [];
|
||||
});
|
||||
} else if (blobOrResponse.type) {
|
||||
types[i].accept[blobOrResponse.type] = option.extensions || [];
|
||||
}
|
||||
});
|
||||
if (existingHandle) {
|
||||
try {
|
||||
// Check if the file still exists.
|
||||
await existingHandle.getFile();
|
||||
} catch (err) {
|
||||
existingHandle = null;
|
||||
if (throwIfExistingHandleNotGood) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle =
|
||||
existingHandle ||
|
||||
(await window.showSaveFilePicker({
|
||||
suggestedName: options[0].fileName,
|
||||
id: options[0].id,
|
||||
startIn: options[0].startIn,
|
||||
types,
|
||||
excludeAcceptAllOption: options[0].excludeAcceptAllOption || false,
|
||||
}));
|
||||
const writable = await handle.createWritable();
|
||||
// Use streaming on the `Blob` if the browser supports it.
|
||||
if ('stream' in blobOrResponse) {
|
||||
const stream = blobOrResponse.stream();
|
||||
await stream.pipeTo(writable);
|
||||
return handle;
|
||||
// Handle passed `ReadableStream`.
|
||||
} else if ('body' in blobOrResponse) {
|
||||
await blobOrResponse.body.pipeTo(writable);
|
||||
return handle;
|
||||
}
|
||||
// Default case of `Blob` passed and `Blob.stream()` not supported.
|
||||
await writable.write(blobOrResponse);
|
||||
await writable.close();
|
||||
return handle;
|
||||
};
|
||||
242
libs/components/board-state/src/data/browser-fs-access/index.d.ts
vendored
Normal file
242
libs/components/board-state/src/data/browser-fs-access/index.d.ts
vendored
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Properties shared by all `options` provided to file save and open operations
|
||||
*/
|
||||
export interface CoreFileOptions {
|
||||
/** Acceptable file extensions. Defaults to [""]. */
|
||||
extensions?: string[];
|
||||
/** Suggested file description. Defaults to "". */
|
||||
description?: string;
|
||||
/** Acceptable MIME types. [] */
|
||||
mimeTypes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties shared by the _first_ `options` object provided to file save and
|
||||
* open operations (any additional options objects provided to those operations
|
||||
* cannot have these properties)
|
||||
*/
|
||||
export interface FirstCoreFileOptions extends CoreFileOptions {
|
||||
startIn?: WellKnownDirectory | FileSystemHandle;
|
||||
/** By specifying an ID, the user agent can remember different directories for different IDs. */
|
||||
id?: string;
|
||||
excludeAcceptAllOption?: boolean | false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `options` object passed to file save operations can also specify
|
||||
* a filename
|
||||
*/
|
||||
export interface FirstFileSaveOptions extends FirstCoreFileOptions {
|
||||
/** Suggested file name. Defaults to "Untitled". */
|
||||
fileName?: string;
|
||||
/**
|
||||
* Configurable cleanup and `Promise` rejector usable with legacy API for
|
||||
* determining when (and reacting if) a user cancels the operation. The
|
||||
* method will be passed a reference to the internal `rejectionHandler` that
|
||||
* can, e.g., be attached to/removed from the window or called after a
|
||||
* timeout. The method should return a function that will be called when
|
||||
* either the user chooses to open a file or the `rejectionHandler` is
|
||||
* called. In the latter case, the returned function will also be passed a
|
||||
* reference to the `reject` callback for the `Promise` returned by
|
||||
* `fileOpen`, so that developers may reject the `Promise` when desired at
|
||||
* that time.
|
||||
* Example rejector:
|
||||
*
|
||||
* const file = await fileOpen({
|
||||
* legacySetup: (rejectionHandler) => {
|
||||
* const timeoutId = setTimeout(rejectionHandler, 10_000);
|
||||
* return (reject) => {
|
||||
* clearTimeout(timeoutId);
|
||||
* if (reject) {
|
||||
* reject('My error message here.');
|
||||
* }
|
||||
* };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* ToDo: Remove this workaround once
|
||||
* https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
*/
|
||||
legacySetup?: (
|
||||
resolve: (value: Blob) => void,
|
||||
rejectionHandler: () => void,
|
||||
anchor: HTMLAnchorElement
|
||||
) => (reject?: (reason?: any) => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `options` object passed to file open operations can specify
|
||||
* whether multiple files can be selected (the return type of the operation
|
||||
* will be updated appropriately) and a way of handling cleanup and rejection
|
||||
* for legacy open operations.
|
||||
*/
|
||||
export interface FirstFileOpenOptions<M extends boolean | undefined>
|
||||
extends FirstCoreFileOptions {
|
||||
/** Allow multiple files to be selected. Defaults to false. */
|
||||
multiple?: M;
|
||||
/**
|
||||
* Configurable cleanup and `Promise` rejector usable with legacy API for
|
||||
* determining when (and reacting if) a user cancels the operation. The
|
||||
* method will be passed a reference to the internal `rejectionHandler` that
|
||||
* can, e.g., be attached to/removed from the window or called after a
|
||||
* timeout. The method should return a function that will be called when
|
||||
* either the user chooses to open a file or the `rejectionHandler` is
|
||||
* called. In the latter case, the returned function will also be passed a
|
||||
* reference to the `reject` callback for the `Promise` returned by
|
||||
* `fileOpen`, so that developers may reject the `Promise` when desired at
|
||||
* that time.
|
||||
* Example rejector:
|
||||
*
|
||||
* const file = await fileOpen({
|
||||
* legacySetup: (rejectionHandler) => {
|
||||
* const timeoutId = setTimeout(rejectionHandler, 10_000);
|
||||
* return (reject) => {
|
||||
* clearTimeout(timeoutId);
|
||||
* if (reject) {
|
||||
* reject('My error message here.');
|
||||
* }
|
||||
* };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* ToDo: Remove this workaround once
|
||||
* https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
*/
|
||||
legacySetup?: (
|
||||
resolve: (
|
||||
value: M extends false | undefined
|
||||
? FileWithHandle
|
||||
: FileWithHandle[]
|
||||
) => void,
|
||||
rejectionHandler: () => void,
|
||||
input: HTMLInputElement
|
||||
) => (reject?: (reason?: any) => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens file(s) from disk.
|
||||
*/
|
||||
export function fileOpen<M extends boolean | undefined = false>(
|
||||
options?:
|
||||
| [FirstFileOpenOptions<M>, ...CoreFileOptions[]]
|
||||
| FirstFileOpenOptions<M>
|
||||
): M extends false | undefined
|
||||
? Promise<FileWithHandle>
|
||||
: Promise<FileWithHandle[]>;
|
||||
|
||||
export type WellKnownDirectory =
|
||||
| 'desktop'
|
||||
| 'documents'
|
||||
| 'downloads'
|
||||
| 'music'
|
||||
| 'pictures'
|
||||
| 'videos';
|
||||
|
||||
/**
|
||||
* Saves a file to disk.
|
||||
* @returns Optional file handle to save in place.
|
||||
*/
|
||||
export function fileSave(
|
||||
/** To-be-saved `Blob` or `Response` */
|
||||
blobOrResponse: Blob | Response,
|
||||
options?:
|
||||
| [FirstFileSaveOptions, ...CoreFileOptions[]]
|
||||
| FirstFileSaveOptions,
|
||||
/**
|
||||
* A potentially existing file handle for a file to save to. Defaults to
|
||||
* null.
|
||||
*/
|
||||
existingHandle?: FileSystemHandle | null,
|
||||
/**
|
||||
* Determines whether to throw (rather than open a new file save dialog)
|
||||
* when existingHandle is no longer good. Defaults to false.
|
||||
*/
|
||||
throwIfExistingHandleNotGood?: boolean | false
|
||||
): Promise<FileSystemHandle | null>;
|
||||
|
||||
/**
|
||||
* Opens a directory from disk using the File System Access API.
|
||||
* @returns Contained files.
|
||||
*/
|
||||
export function directoryOpen(options?: {
|
||||
/** Whether to recursively get subdirectories. */
|
||||
recursive: boolean;
|
||||
/** Suggested directory in which the file picker opens. */
|
||||
startIn?: WellKnownDirectory | FileSystemHandle;
|
||||
/** By specifying an ID, the user agent can remember different directories for different IDs. */
|
||||
id?: string;
|
||||
/** Callback to determine whether a directory should be entered, return `true` to skip. */
|
||||
skipDirectory?: (
|
||||
fileSystemDirectoryEntry: FileSystemDirectoryEntry
|
||||
) => boolean;
|
||||
/**
|
||||
* Configurable setup, cleanup and `Promise` rejector usable with legacy API
|
||||
* for determining when (and reacting if) a user cancels the operation. The
|
||||
* method will be passed a reference to the internal `rejectionHandler` that
|
||||
* can, e.g., be attached to/removed from the window or called after a
|
||||
* timeout. The method should return a function that will be called when
|
||||
* either the user chooses to open a file or the `rejectionHandler` is
|
||||
* called. In the latter case, the returned function will also be passed a
|
||||
* reference to the `reject` callback for the `Promise` returned by
|
||||
* `fileOpen`, so that developers may reject the `Promise` when desired at
|
||||
* that time.
|
||||
* Example rejector:
|
||||
*
|
||||
* const file = await directoryOpen({
|
||||
* legacySetup: (rejectionHandler) => {
|
||||
* const timeoutId = setTimeout(rejectionHandler, 10_000);
|
||||
* return (reject) => {
|
||||
* clearTimeout(timeoutId);
|
||||
* if (reject) {
|
||||
* reject('My error message here.');
|
||||
* }
|
||||
* };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* ToDo: Remove this workaround once
|
||||
* https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
*/
|
||||
legacySetup?: (
|
||||
resolve: (value: FileWithDirectoryHandle) => void,
|
||||
rejectionHandler: () => void,
|
||||
input: HTMLInputElement
|
||||
) => (reject?: (reason?: any) => void) => void;
|
||||
}): Promise<FileWithDirectoryHandle[]>;
|
||||
|
||||
/**
|
||||
* Whether the File System Access API is supported.
|
||||
*/
|
||||
export const supported: boolean;
|
||||
|
||||
export function imageToBlob(img: HTMLImageElement): Promise<Blob>;
|
||||
|
||||
export interface FileWithHandle extends File {
|
||||
handle?: FileSystemHandle;
|
||||
}
|
||||
|
||||
export interface FileWithDirectoryHandle extends File {
|
||||
directoryHandle?: FileSystemHandle;
|
||||
}
|
||||
|
||||
// The following typings implement the relevant parts of the File System Access
|
||||
// API. This can be removed once the specification reaches the Candidate phase
|
||||
// and is implemented as part of microsoft/TSJS-lib-generator.
|
||||
|
||||
export interface FileSystemHandlePermissionDescriptor {
|
||||
mode?: 'read' | 'readwrite';
|
||||
}
|
||||
|
||||
export interface FileSystemHandle {
|
||||
readonly kind: 'file' | 'directory';
|
||||
readonly name: string;
|
||||
|
||||
isSameEntry: (other: FileSystemHandle) => Promise<boolean>;
|
||||
|
||||
queryPermission: (
|
||||
descriptor?: FileSystemHandlePermissionDescriptor
|
||||
) => Promise<PermissionState>;
|
||||
requestPermission: (
|
||||
descriptor?: FileSystemHandlePermissionDescriptor
|
||||
) => Promise<PermissionState>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* @module browser-fs-access
|
||||
*/
|
||||
export { fileOpen } from './file-open.js';
|
||||
export { directoryOpen } from './directory-open.js';
|
||||
export { fileSave } from './file-save.js';
|
||||
export { default as supported } from './supported.js';
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* Opens a directory from disk using the legacy
|
||||
* `<input type="file" webkitdirectory>` method.
|
||||
* @type { typeof import("../../index").directoryOpen }
|
||||
*/
|
||||
export default async (options = [{}]) => {
|
||||
if (!Array.isArray(options)) {
|
||||
options = [options];
|
||||
}
|
||||
options[0].recursive = options[0].recursive || false;
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
|
||||
const _reject = () => cleanupListenersAndMaybeReject(reject);
|
||||
const _resolve = value => {
|
||||
if (typeof cleanupListenersAndMaybeReject === 'function') {
|
||||
cleanupListenersAndMaybeReject();
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
// ToDo: Remove this workaround once
|
||||
// https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
const cleanupListenersAndMaybeReject =
|
||||
options[0].legacySetup &&
|
||||
options[0].legacySetup(_resolve, _reject, input);
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
let files = Array.from(input.files);
|
||||
if (!options[0].recursive) {
|
||||
files = files.filter(file => {
|
||||
return file.webkitRelativePath.split('/').length === 2;
|
||||
});
|
||||
} else if (options[0].recursive && options[0].skipDirectory) {
|
||||
files = files.filter(file => {
|
||||
const directoriesName = file.webkitRelativePath.split('/');
|
||||
return directoriesName.every(
|
||||
directoryName =>
|
||||
!options[0].skipDirectory({
|
||||
name: directoryName,
|
||||
kind: 'directory',
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_resolve(files);
|
||||
});
|
||||
|
||||
input.click();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* Opens a file from disk using the legacy `<input type="file">` method.
|
||||
* @type { typeof import("../../index").fileOpen }
|
||||
*/
|
||||
export default async (options = [{}]) => {
|
||||
if (!Array.isArray(options)) {
|
||||
options = [options];
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
const accept = [
|
||||
...options.map(option => option.mimeTypes || []).join(),
|
||||
options.map(option => option.extensions || []).join(),
|
||||
].join();
|
||||
input.multiple = options[0].multiple || false;
|
||||
// Empty string allows everything.
|
||||
input.accept = accept || '';
|
||||
const _reject = () => cleanupListenersAndMaybeReject(reject);
|
||||
const _resolve = value => {
|
||||
if (typeof cleanupListenersAndMaybeReject === 'function') {
|
||||
cleanupListenersAndMaybeReject();
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
// ToDo: Remove this workaround once
|
||||
// https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
const cleanupListenersAndMaybeReject =
|
||||
options[0].legacySetup &&
|
||||
options[0].legacySetup(_resolve, _reject, input);
|
||||
input.addEventListener('change', () => {
|
||||
_resolve(input.multiple ? Array.from(input.files) : input.files[0]);
|
||||
});
|
||||
|
||||
input.click();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* Saves a file to disk using the legacy `<a download>` method.
|
||||
* @type { typeof import("../../index").fileSave }
|
||||
*/
|
||||
export default async (blobOrResponse, options = {}) => {
|
||||
if (Array.isArray(options)) {
|
||||
options = options[0];
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
let data = blobOrResponse;
|
||||
// Handle the case where input is a `ReadableStream`.
|
||||
if ('body' in blobOrResponse) {
|
||||
data = await streamToBlob(
|
||||
blobOrResponse.body,
|
||||
blobOrResponse.headers.get('content-type')
|
||||
);
|
||||
}
|
||||
a.download = options.fileName || 'Untitled';
|
||||
a.href = URL.createObjectURL(data);
|
||||
|
||||
const _reject = () => cleanupListenersAndMaybeReject(reject);
|
||||
const _resolve = () => {
|
||||
if (typeof cleanupListenersAndMaybeReject === 'function') {
|
||||
cleanupListenersAndMaybeReject();
|
||||
}
|
||||
};
|
||||
// ToDo: Remove this workaround once
|
||||
// https://github.com/whatwg/html/issues/6376 is specified and supported.
|
||||
const cleanupListenersAndMaybeReject =
|
||||
options.legacySetup && options.legacySetup(_resolve, _reject, a);
|
||||
|
||||
a.addEventListener('click', () => {
|
||||
// `setTimeout()` due to
|
||||
// https://github.com/LLK/scratch-gui/issues/1783#issuecomment-426286393
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
|
||||
_resolve(null);
|
||||
});
|
||||
a.click();
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a passed `ReadableStream` to a `Blob`.
|
||||
* @param {ReadableStream} stream
|
||||
* @param {string} type
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async function streamToBlob(stream, type) {
|
||||
const reader = stream.getReader();
|
||||
const pumpedStream = new ReadableStream({
|
||||
start(controller) {
|
||||
return pump();
|
||||
/**
|
||||
* Recursively pumps data chunks out of the `ReadableStream`.
|
||||
* @type { () => Promise<void> }
|
||||
*/
|
||||
async function pump() {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(value);
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const res = new Response(pumpedStream);
|
||||
reader.releaseLock();
|
||||
return new Blob([await res.blob()], { type });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Copyright 2020 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.
|
||||
|
||||
/**
|
||||
* Returns whether the File System Access API is supported and usable in the
|
||||
* current context (for example cross-origin iframes).
|
||||
* @returns {boolean} Returns `true` if the File System Access API is supported and usable, else returns `false`.
|
||||
*/
|
||||
const supported = (() => {
|
||||
// When running in an SSR environment return `false`.
|
||||
if (typeof self === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
// ToDo: Remove this check once Permissions Policy integration
|
||||
// has happened, tracked in
|
||||
// https://github.com/WICG/file-system-access/issues/245.
|
||||
if ('top' in self && self !== top) {
|
||||
try {
|
||||
// This will succeed on same-origin iframes,
|
||||
// but fail on cross-origin iframes.
|
||||
top.location + '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} else if ('showOpenFilePicker' in self) {
|
||||
return 'showOpenFilePicker';
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
export default supported;
|
||||
12
libs/components/board-state/src/data/filesystem.spec.ts
Normal file
12
libs/components/board-state/src/data/filesystem.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
describe('when saving data to the file system', () => {
|
||||
it.todo('saves a new file in the filesystem');
|
||||
it.todo('saves a new file in the filesystem');
|
||||
});
|
||||
|
||||
describe('when opening files from file system', () => {
|
||||
it.todo('opens a file and loads it into the document');
|
||||
it.todo('opens an older file, migrates it, and loads it into the document');
|
||||
it.todo(
|
||||
'opens a corrupt file, tries to fix it, and fails without crashing'
|
||||
);
|
||||
});
|
||||
169
libs/components/board-state/src/data/filesystem.ts
Normal file
169
libs/components/board-state/src/data/filesystem.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import type { TDDocument, TDFile } from '@toeverything/components/board-types';
|
||||
import type { FileSystemHandle } from './browser-fs-access';
|
||||
import { get as getFromIdb, set as setToIdb } from 'idb-keyval';
|
||||
import {
|
||||
IMAGE_EXTENSIONS,
|
||||
VIDEO_EXTENSIONS,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
const options = { mode: 'readwrite' as const };
|
||||
|
||||
const checkPermissions = async (handle: FileSystemHandle) => {
|
||||
return (
|
||||
(await handle.queryPermission(options)) === 'granted' ||
|
||||
(await handle.requestPermission(options)) === 'granted'
|
||||
);
|
||||
};
|
||||
|
||||
export async function loadFileHandle() {
|
||||
if (typeof Window === 'undefined' || !('_location' in Window)) return;
|
||||
const fileHandle = await getFromIdb(
|
||||
`Tldraw_file_handle_${window.location.origin}`
|
||||
);
|
||||
if (!fileHandle) return null;
|
||||
return fileHandle;
|
||||
}
|
||||
|
||||
export async function saveFileHandle(fileHandle: FileSystemHandle | null) {
|
||||
return setToIdb(`Tldraw_file_handle_${window.location.origin}`, fileHandle);
|
||||
}
|
||||
|
||||
export async function saveToFileSystem(
|
||||
document: TDDocument,
|
||||
fileHandle: FileSystemHandle | null
|
||||
) {
|
||||
// Create the saved file data
|
||||
const file: TDFile = {
|
||||
name: document.name || 'New Document',
|
||||
fileHandle: fileHandle ?? null,
|
||||
document,
|
||||
assets: {},
|
||||
};
|
||||
|
||||
// Serialize to JSON
|
||||
const json = JSON.stringify(file, null, 2);
|
||||
|
||||
// Create blob
|
||||
const blob = new Blob([json], {
|
||||
type: 'application/vnd.Tldraw+json',
|
||||
});
|
||||
|
||||
if (fileHandle) {
|
||||
const hasPermissions = await checkPermissions(fileHandle);
|
||||
if (!hasPermissions) return null;
|
||||
}
|
||||
|
||||
// Save to file system
|
||||
// @ts-ignore
|
||||
const browser_fs = await import('./browser-fs-access');
|
||||
const fileSave = browser_fs.fileSave;
|
||||
const newFileHandle = await fileSave(
|
||||
blob,
|
||||
{
|
||||
fileName: `${file.name}.tldr`,
|
||||
description: 'Tldraw File',
|
||||
extensions: [`.tldr`],
|
||||
},
|
||||
fileHandle
|
||||
);
|
||||
|
||||
await saveFileHandle(newFileHandle);
|
||||
|
||||
// Return true
|
||||
return newFileHandle;
|
||||
}
|
||||
|
||||
export async function openFromFileSystem(): Promise<null | {
|
||||
fileHandle: FileSystemHandle | null;
|
||||
document: TDDocument;
|
||||
}> {
|
||||
// Get the blob
|
||||
// @ts-ignore
|
||||
const browser_fs = await import('./browser-fs-access');
|
||||
const fileOpen = browser_fs.fileOpen;
|
||||
const blob = await fileOpen({
|
||||
description: 'Tldraw File',
|
||||
extensions: [`.tldr`],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!blob) return null;
|
||||
|
||||
// Get JSON from blob
|
||||
const json: string = await new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
if (reader.readyState === FileReader.DONE) {
|
||||
resolve(reader.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsText(blob, 'utf8');
|
||||
});
|
||||
|
||||
// Parse
|
||||
const file: TDFile = JSON.parse(json);
|
||||
|
||||
const fileHandle = blob.handle ?? null;
|
||||
|
||||
await saveFileHandle(fileHandle);
|
||||
|
||||
return {
|
||||
fileHandle,
|
||||
document: file.document,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openAssetFromFileSystem() {
|
||||
// @ts-ignore
|
||||
const browser_fs = await import('./browser-fs-access');
|
||||
const fileOpen = browser_fs.fileOpen;
|
||||
return fileOpen({
|
||||
description: 'Image or Video',
|
||||
extensions: [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS],
|
||||
multiple: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function fileToBase64(file: Blob): Promise<string | ArrayBuffer | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = error => reject(error);
|
||||
reader.onabort = error => reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function fileToText(file: Blob): Promise<string | ArrayBuffer | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = error => reject(error);
|
||||
reader.onabort = error => reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageSizeFromSrc(src: string): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve([img.width, img.height]);
|
||||
img.onerror = () => reject(new Error('Could not get image size'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export function getVideoSizeFromSrc(src: string): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.onloadedmetadata = () =>
|
||||
resolve([video.videoWidth, video.videoHeight]);
|
||||
video.onerror = () => reject(new Error('Could not get video size'));
|
||||
video.src = src;
|
||||
});
|
||||
}
|
||||
3
libs/components/board-state/src/data/index.ts
Normal file
3
libs/components/board-state/src/data/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './migrate';
|
||||
export * from './filesystem';
|
||||
export * from './browser-fs-access';
|
||||
19
libs/components/board-state/src/data/migrate.spec.ts
Normal file
19
libs/components/board-state/src/data/migrate.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TDDocument } from '~types';
|
||||
import { TldrawApp } from '~state';
|
||||
import oldDoc from '~test/documents/old-doc';
|
||||
import oldDoc2 from '~test/documents/old-doc-2';
|
||||
|
||||
describe('When migrating bindings', () => {
|
||||
it('migrates a document without a version', () => {
|
||||
new TldrawApp().loadDocument(oldDoc as unknown as TDDocument);
|
||||
});
|
||||
|
||||
it('migrates a document with an older version', () => {
|
||||
const app = new TldrawApp().loadDocument(
|
||||
oldDoc2 as unknown as TDDocument
|
||||
);
|
||||
expect(
|
||||
app.getShape('d7ab0a49-3cb3-43ae-3d83-f5cf2f4a510a').style.color
|
||||
).toBe('black');
|
||||
});
|
||||
});
|
||||
152
libs/components/board-state/src/data/migrate.ts
Normal file
152
libs/components/board-state/src/data/migrate.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import {
|
||||
Decoration,
|
||||
FontStyle,
|
||||
TDDocument,
|
||||
TDShapeType,
|
||||
// TextShape
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export function migrate(document: TDDocument, newVersion: number): TDDocument {
|
||||
const { version = 0 } = document;
|
||||
|
||||
if (!('assets' in document)) {
|
||||
document.assets = {};
|
||||
}
|
||||
|
||||
// Remove unused assets when loading a document
|
||||
const assetIdsInUse = new Set<string>();
|
||||
|
||||
Object.values(document.pages).forEach(page =>
|
||||
Object.values(page.shapes).forEach(shape => {
|
||||
const { parentId, children, assetId } = shape;
|
||||
|
||||
if (assetId) {
|
||||
assetIdsInUse.add(assetId);
|
||||
}
|
||||
|
||||
// Fix missing parent bug
|
||||
if (parentId !== page.id && !page.shapes[parentId]) {
|
||||
console.warn('Encountered a shape with a missing parent!');
|
||||
shape.parentId = page.id;
|
||||
}
|
||||
|
||||
if (shape.type === TDShapeType.Group && children) {
|
||||
children.forEach(childId => {
|
||||
if (!page.shapes[childId]) {
|
||||
console.warn(
|
||||
'Encountered a parent with a missing child!',
|
||||
shape.id,
|
||||
childId
|
||||
);
|
||||
children?.splice(children.indexOf(childId), 1);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Remove the shape if it has no children
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
Object.keys(document.assets).forEach(assetId => {
|
||||
if (!assetIdsInUse.has(assetId)) {
|
||||
delete document.assets[assetId];
|
||||
}
|
||||
});
|
||||
|
||||
if (version === newVersion) return document;
|
||||
|
||||
// if (version < 14) {
|
||||
// Object.values(document.pages).forEach(page => {
|
||||
// Object.values(page.shapes)
|
||||
// // .filter(shape => shape.type === TDShapeType.Text)
|
||||
// .forEach(
|
||||
// shape =>
|
||||
// (shape as TextShape).style.font === FontStyle.Script
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
|
||||
// Lowercase styles, move binding meta to binding
|
||||
if (version <= 13) {
|
||||
Object.values(document.pages).forEach(page => {
|
||||
Object.values(page.bindings).forEach(binding => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Object.assign(binding, (binding as any).meta);
|
||||
});
|
||||
|
||||
Object.values(page.shapes).forEach(shape => {
|
||||
Object.entries(shape.style).forEach(([id, style]) => {
|
||||
if (typeof style === 'string') {
|
||||
// @ts-ignore
|
||||
shape.style[id] = style.toLowerCase();
|
||||
}
|
||||
});
|
||||
|
||||
if (shape.type === TDShapeType.Arrow) {
|
||||
if (shape.decorations) {
|
||||
Object.entries(shape.decorations).forEach(
|
||||
([id, decoration]) => {
|
||||
if ((decoration as unknown) === 'Arrow') {
|
||||
shape.decorations = {
|
||||
...shape.decorations,
|
||||
[id]: Decoration.Arrow,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add document name and file system handle
|
||||
if (version <= 13.1) {
|
||||
document.name = 'New Document';
|
||||
}
|
||||
|
||||
if (version < 15) {
|
||||
document.assets = {};
|
||||
}
|
||||
|
||||
Object.values(document.pages).forEach(page => {
|
||||
Object.values(page.shapes).forEach(shape => {
|
||||
if (version < 15.2) {
|
||||
if (
|
||||
shape.type === TDShapeType.Image ||
|
||||
shape.type === TDShapeType.Video
|
||||
) {
|
||||
shape.style.isFilled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 15.3) {
|
||||
if (
|
||||
shape.type === TDShapeType.Rectangle ||
|
||||
shape.type === TDShapeType.Triangle ||
|
||||
shape.type === TDShapeType.Ellipse ||
|
||||
shape.type === TDShapeType.Arrow
|
||||
) {
|
||||
shape.label = (shape as any).text || '';
|
||||
shape.labelPoint = [0.5, 0.5];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
Object.values(document.pageStates).forEach(pageState => {
|
||||
pageState.selectedIds = pageState.selectedIds.filter(id => {
|
||||
return document.pages[pageState.id].shapes[id] !== undefined;
|
||||
});
|
||||
pageState.bindingId = undefined;
|
||||
pageState.editingId = undefined;
|
||||
pageState.hoveredId = undefined;
|
||||
pageState.pointedId = undefined;
|
||||
});
|
||||
|
||||
document.version = newVersion;
|
||||
|
||||
return document;
|
||||
}
|
||||
17
libs/components/board-state/src/idb-clipboard.ts
Normal file
17
libs/components/board-state/src/idb-clipboard.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
|
||||
// Used for clipboard
|
||||
|
||||
const ID = 'tldraw_clipboard';
|
||||
|
||||
export async function getClipboard(): Promise<string | undefined> {
|
||||
return get(ID);
|
||||
}
|
||||
|
||||
export async function setClipboard(item: string): Promise<void> {
|
||||
return set(ID, item);
|
||||
}
|
||||
|
||||
export function clearClipboard(): Promise<void> {
|
||||
return del(ID);
|
||||
}
|
||||
5
libs/components/board-state/src/index.ts
Normal file
5
libs/components/board-state/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TldrawApp } from './tldraw-app';
|
||||
export type { TldrawAppCtorProps } from './tldraw-app';
|
||||
export { TLDR } from './tldr';
|
||||
export { deepCopy } from './manager/deep-copy';
|
||||
export { BaseTool, Status as BaseToolStatus } from './types/tool';
|
||||
41
libs/components/board-state/src/manager/deep-copy.ts
Normal file
41
libs/components/board-state/src/manager/deep-copy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Deep copy function for TypeScript.
|
||||
* @param T Generic type of target/copied value.
|
||||
* @param target Target value to be copied.
|
||||
* @see Source project, ts-deeply https://github.com/ykdr2017/ts-deepcopy
|
||||
* @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
|
||||
*/
|
||||
export function deepCopy<T>(target: T): T {
|
||||
if (target === null) {
|
||||
return target;
|
||||
}
|
||||
if (target instanceof Date) {
|
||||
return new Date(target.getTime()) as any;
|
||||
}
|
||||
|
||||
// First part is for array and second part is for Realm.Collection
|
||||
// if (target instanceof Array || typeof (target as any).type === 'string') {
|
||||
if (typeof target === 'object') {
|
||||
if (typeof target[Symbol.iterator as keyof T] === 'function') {
|
||||
const cp = [] as any[];
|
||||
if ((target as any as any[]).length > 0) {
|
||||
for (const arrayMember of target as any as any[]) {
|
||||
cp.push(deepCopy(arrayMember));
|
||||
}
|
||||
}
|
||||
return cp as any as T;
|
||||
} else {
|
||||
const targetKeys = Object.keys(target);
|
||||
const cp = {} as T;
|
||||
if (targetKeys.length > 0) {
|
||||
for (const key of targetKeys) {
|
||||
cp[key as keyof T] = deepCopy(target[key as keyof T]);
|
||||
}
|
||||
}
|
||||
return cp;
|
||||
}
|
||||
}
|
||||
|
||||
// Means that object is atomic
|
||||
return target;
|
||||
}
|
||||
450
libs/components/board-state/src/manager/state-manager.ts
Normal file
450
libs/components/board-state/src/manager/state-manager.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import createVanilla, { StoreApi } from 'zustand/vanilla';
|
||||
import create, { UseBoundStore } from 'zustand';
|
||||
import * as idb from 'idb-keyval';
|
||||
import { deepCopy } from './deep-copy';
|
||||
import type { Patch, Command } from '@toeverything/components/board-types';
|
||||
import { Utils } from '@tldraw/core';
|
||||
|
||||
export class StateManager<T extends Record<string, any>> {
|
||||
/**
|
||||
* An ID used to persist state in indexdb.
|
||||
*/
|
||||
protected idb_id?: string;
|
||||
|
||||
/**
|
||||
* The initial state.
|
||||
*/
|
||||
private initial_state: T;
|
||||
|
||||
/**
|
||||
* A zustand store that also holds the state.
|
||||
*/
|
||||
private store: StoreApi<T>;
|
||||
|
||||
/**
|
||||
* The index of the current command.
|
||||
*/
|
||||
protected pointer = -1;
|
||||
|
||||
/**
|
||||
* The current state.
|
||||
*/
|
||||
private _state: T;
|
||||
|
||||
/**
|
||||
* The state manager's current status, with regard to restoring persisted state.
|
||||
*/
|
||||
private _status: 'loading' | 'ready' = 'loading';
|
||||
|
||||
/**
|
||||
* A stack of commands used for history (undo and redo).
|
||||
*/
|
||||
protected stack: Command<T>[] = [];
|
||||
|
||||
/**
|
||||
* A snapshot of the current state.
|
||||
*/
|
||||
protected _snapshot: T;
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/**
|
||||
* A React hook for accessing the zustand store.
|
||||
*/
|
||||
public readonly useStore: UseBoundStore<T>;
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
/**
|
||||
* A promise that will resolve when the state manager has loaded any peristed state.
|
||||
*/
|
||||
public ready: Promise<'none' | 'restored' | 'migrated'>;
|
||||
|
||||
public isPaused = false;
|
||||
|
||||
constructor(
|
||||
initialState: T,
|
||||
id?: string,
|
||||
version?: number,
|
||||
update?: (prev: T, next: T, prevVersion: number) => T
|
||||
) {
|
||||
this.idb_id = id;
|
||||
this._state = deepCopy(initialState);
|
||||
this._snapshot = deepCopy(initialState);
|
||||
this.initial_state = deepCopy(initialState);
|
||||
this.store = createVanilla(() => this._state);
|
||||
this.useStore = create(this.store);
|
||||
|
||||
this.ready = new Promise<'none' | 'restored' | 'migrated'>(resolve => {
|
||||
let message: 'none' | 'restored' | 'migrated' = 'none';
|
||||
|
||||
if (this.idb_id) {
|
||||
message = 'restored';
|
||||
|
||||
idb.get(this.idb_id)
|
||||
.then(async saved => {
|
||||
if (saved) {
|
||||
let next = saved;
|
||||
|
||||
if (version) {
|
||||
const savedVersion = await idb.get<number>(
|
||||
id + '_version'
|
||||
);
|
||||
|
||||
if (savedVersion && savedVersion < version) {
|
||||
next = update
|
||||
? update(
|
||||
saved,
|
||||
initialState,
|
||||
savedVersion
|
||||
)
|
||||
: initialState;
|
||||
|
||||
message = 'migrated';
|
||||
}
|
||||
}
|
||||
|
||||
await idb.set(id + '_version', version || -1);
|
||||
|
||||
// why is this necessary? but it is...
|
||||
const prevEmpty =
|
||||
this._state['appState'].isEmptyCanvas;
|
||||
|
||||
next = this.migrate(next);
|
||||
|
||||
this._state = deepCopy(next);
|
||||
this._snapshot = deepCopy(next);
|
||||
|
||||
this._state['appState'].isEmptyCanvas = prevEmpty;
|
||||
this.store.setState(this._state, true);
|
||||
} else {
|
||||
await idb.set(id + '_version', version || -1);
|
||||
}
|
||||
this._status = 'ready';
|
||||
resolve(message);
|
||||
})
|
||||
.catch(e => console.error(e));
|
||||
} else {
|
||||
// We need to wait for any override to `onReady` to take effect.
|
||||
this._status = 'ready';
|
||||
resolve(message);
|
||||
}
|
||||
}).then(message => {
|
||||
if (this.on_ready) this.on_ready(message);
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state to indexdb.
|
||||
*/
|
||||
protected persist = (id?: string): void | Promise<void> => {
|
||||
if (this._status !== 'ready') return;
|
||||
|
||||
if (this.onPersist) {
|
||||
this.onPersist(this._state, id);
|
||||
}
|
||||
|
||||
if (this.idb_id) {
|
||||
return idb
|
||||
.set(this.idb_id, this._state)
|
||||
.catch(e => console.error(e));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a patch to the current state.
|
||||
* This does not effect the undo/redo stack.
|
||||
* This does not persist the state.
|
||||
* @param patch The patch to apply.
|
||||
* @param id (optional) An id for the patch.
|
||||
*/
|
||||
private apply_patch = (patch: Patch<T>, id?: string) => {
|
||||
const prev = this._state;
|
||||
const next = Utils.deepMerge(this._state, patch as any);
|
||||
const final = this.cleanup(next, prev, patch, id);
|
||||
if (this.on_state_will_change) {
|
||||
this.on_state_will_change(final, id);
|
||||
}
|
||||
this._state = final;
|
||||
this.store.setState(this._state, true);
|
||||
if (this.on_state_did_change) {
|
||||
this.on_state_did_change(this._state, id);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
// Internal API ---------------------------------
|
||||
|
||||
protected migrate = (next: T): T => {
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform any last changes to the state before updating.
|
||||
* Override this on your extending class.
|
||||
* @param nextState The next state.
|
||||
* @param prevState The previous state.
|
||||
* @param patch The patch that was just applied.
|
||||
* @param id (optional) An id for the just-applied patch.
|
||||
* @returns The final new state to apply.
|
||||
*/
|
||||
protected cleanup = (
|
||||
nextState: T,
|
||||
prevState: T,
|
||||
patch: Patch<T>,
|
||||
id?: string
|
||||
): T => nextState;
|
||||
|
||||
/**
|
||||
* A life-cycle method called when the state is about to change.
|
||||
* @param state The next state.
|
||||
* @param id An id for the change.
|
||||
*/
|
||||
protected on_state_will_change?: (state: T, id?: string) => void;
|
||||
|
||||
/**
|
||||
* A life-cycle method called when the state has changed.
|
||||
* @param state The next state.
|
||||
* @param id An id for the change.
|
||||
*/
|
||||
protected on_state_did_change?: (state: T, id?: string) => void;
|
||||
|
||||
/**
|
||||
* Apply a patch to the current state.
|
||||
* This does not effect the undo/redo stack.
|
||||
* This does not persist the state.
|
||||
* @param patch The patch to apply.
|
||||
* @param id (optional) An id for this patch.
|
||||
*/
|
||||
patchState = (patch: Patch<T>, id?: string): this => {
|
||||
this.apply_patch(patch, id);
|
||||
if (this.onPatch) {
|
||||
this.onPatch(this._state, id);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the current state.
|
||||
* This does not effect the undo/redo stack.
|
||||
* This does not persist the state.
|
||||
* @param state The new state.
|
||||
* @param id An id for this change.
|
||||
*/
|
||||
protected replace_state = (state: T, id?: string): this => {
|
||||
const final = this.cleanup(state, this._state, state, id);
|
||||
if (this.on_state_will_change) {
|
||||
this.on_state_will_change(final, 'replace');
|
||||
}
|
||||
this._state = final;
|
||||
this.store.setState(this._state, true);
|
||||
if (this.on_state_did_change) {
|
||||
this.on_state_did_change(this._state, 'replace');
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the state using a Command.
|
||||
* This effects the undo/redo stack.
|
||||
* This persists the state.
|
||||
* @param command The command to apply and add to the undo/redo stack.
|
||||
* @param id (optional) An id for this command.
|
||||
*/
|
||||
protected set_state = (command: Command<T>, id = command.id) => {
|
||||
if (this.pointer < this.stack.length - 1) {
|
||||
this.stack = this.stack.slice(0, this.pointer + 1);
|
||||
}
|
||||
this.stack.push({ ...command, id });
|
||||
this.pointer = this.stack.length - 1;
|
||||
this.apply_patch(command.after, id);
|
||||
if (this.onCommand) this.onCommand(this._state, id);
|
||||
this.persist(id);
|
||||
return this;
|
||||
};
|
||||
|
||||
// Public API ---------------------------------
|
||||
|
||||
public pause() {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
public resume() {
|
||||
this.isPaused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback fired when the constructor finishes loading any
|
||||
* persisted data.
|
||||
*/
|
||||
protected on_ready?: (message: 'none' | 'restored' | 'migrated') => void;
|
||||
|
||||
/**
|
||||
* A callback fired when a patch is applied.
|
||||
*/
|
||||
public onPatch?: (state: T, id?: string) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when a patch is applied.
|
||||
*/
|
||||
public onCommand?: (state: T, id?: string) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when the state is persisted.
|
||||
*/
|
||||
public onPersist?: (state: T, id?: string) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when the state is replaced.
|
||||
*/
|
||||
public onReplace?: (state: T) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when the state is reset.
|
||||
*/
|
||||
public onReset?: (state: T) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when the history is reset.
|
||||
*/
|
||||
public onResetHistory?: (state: T) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when a command is undone.
|
||||
*/
|
||||
public onUndo?: (state: T) => void;
|
||||
|
||||
/**
|
||||
* A callback fired when a command is redone.
|
||||
*/
|
||||
public onRedo?: (state: T) => void;
|
||||
|
||||
/**
|
||||
* Reset the state to the initial state and reset history.
|
||||
*/
|
||||
public reset = () => {
|
||||
if (this.on_state_will_change) {
|
||||
this.on_state_will_change(this.initial_state, 'reset');
|
||||
}
|
||||
this._state = this.initial_state;
|
||||
this.store.setState(this._state, true);
|
||||
this.resetHistory();
|
||||
this.persist('reset');
|
||||
if (this.on_state_did_change) {
|
||||
this.on_state_did_change(this._state, 'reset');
|
||||
}
|
||||
if (this.onReset) {
|
||||
this.onReset(this._state);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force replace a new undo/redo history. It's your responsibility
|
||||
* to make sure that this is compatible with the current state!
|
||||
* @param history The new array of commands.
|
||||
* @param pointer (optional) The new pointer position.
|
||||
*/
|
||||
public replaceHistory = (
|
||||
history: Command<T>[],
|
||||
pointer = history.length - 1
|
||||
): this => {
|
||||
this.stack = history;
|
||||
this.pointer = pointer;
|
||||
if (this.onReplace) {
|
||||
this.onReplace(this._state);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the history stack (without resetting the state).
|
||||
*/
|
||||
public resetHistory = (): this => {
|
||||
this.stack = [];
|
||||
this.pointer = -1;
|
||||
if (this.onResetHistory) {
|
||||
this.onResetHistory(this._state);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move backward in the undo/redo stack.
|
||||
*/
|
||||
public undo = (): this => {
|
||||
if (!this.isPaused) {
|
||||
if (!this.canUndo) return this;
|
||||
const command = this.stack[this.pointer];
|
||||
this.pointer--;
|
||||
this.apply_patch(command.before, `undo`);
|
||||
this.persist('undo');
|
||||
}
|
||||
if (this.onUndo) this.onUndo(this._state);
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move forward in the undo/redo stack.
|
||||
*/
|
||||
public redo = (): this => {
|
||||
if (!this.isPaused) {
|
||||
if (!this.canRedo) return this;
|
||||
this.pointer++;
|
||||
const command = this.stack[this.pointer];
|
||||
this.apply_patch(command.after, 'redo');
|
||||
this.persist('undo');
|
||||
}
|
||||
if (this.onRedo) this.onRedo(this._state);
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Save a snapshot of the current state, accessible at `this.snapshot`.
|
||||
*/
|
||||
public setSnapshot = (): this => {
|
||||
this._snapshot = { ...this._state };
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force the zustand state to update.
|
||||
*/
|
||||
public forceUpdate = () => {
|
||||
this.store.setState(this._state, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get whether the state manager can undo.
|
||||
*/
|
||||
public get canUndo(): boolean {
|
||||
return this.pointer > -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the state manager can redo.
|
||||
*/
|
||||
public get canRedo(): boolean {
|
||||
return this.pointer < this.stack.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state.
|
||||
*/
|
||||
public get state(): T {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current status.
|
||||
*/
|
||||
public get status(): string {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most-recent snapshot.
|
||||
*/
|
||||
protected get snapshot(): T {
|
||||
return this._snapshot;
|
||||
}
|
||||
}
|
||||
1425
libs/components/board-state/src/tldr.ts
Normal file
1425
libs/components/board-state/src/tldr.ts
Normal file
File diff suppressed because it is too large
Load Diff
4567
libs/components/board-state/src/tldraw-app.ts
Normal file
4567
libs/components/board-state/src/tldraw-app.ts
Normal file
File diff suppressed because it is too large
Load Diff
105
libs/components/board-state/src/types/commands.ts
Normal file
105
libs/components/board-state/src/types/commands.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { TLBounds } from '@tldraw/core';
|
||||
import type { TldrawApp } from '../tldraw-app';
|
||||
import type {
|
||||
TldrawCommand,
|
||||
AlignType,
|
||||
TDShape,
|
||||
TDBinding,
|
||||
DistributeType,
|
||||
FlipType,
|
||||
MoveType,
|
||||
StretchType,
|
||||
ShapeStyles,
|
||||
GroupShape,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export interface Commands {
|
||||
alignShapes(app: TldrawApp, ids: string[], type: AlignType): TldrawCommand;
|
||||
changePage(app: TldrawApp, pageId: string): TldrawCommand;
|
||||
createPage(
|
||||
app: TldrawApp,
|
||||
center: number[],
|
||||
pageId?: string
|
||||
): TldrawCommand;
|
||||
createShapes(
|
||||
app: TldrawApp,
|
||||
shapes: TDShape[],
|
||||
bindings: TDBinding[]
|
||||
): TldrawCommand;
|
||||
deletePage(app: TldrawApp, pageId: string): TldrawCommand;
|
||||
deleteShapes(app: TldrawApp, ids: string[], pageId?: string): TldrawCommand;
|
||||
distributeShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: DistributeType
|
||||
): TldrawCommand;
|
||||
duplicatePage(app: TldrawApp, pageId: string): TldrawCommand;
|
||||
duplicateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
point?: number[]
|
||||
): TldrawCommand;
|
||||
flipShapes(app: TldrawApp, ids: string[], type: FlipType): TldrawCommand;
|
||||
groupShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
groupId: string,
|
||||
pageId: string
|
||||
): TldrawCommand | undefined;
|
||||
moveShapesToPage(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
viewportBounds: TLBounds,
|
||||
fromPageId: string,
|
||||
toPageId: string
|
||||
): TldrawCommand;
|
||||
renamePage(app: TldrawApp, pageId: string, name: string): TldrawCommand;
|
||||
reorderShapes(app: TldrawApp, ids: string[], type: MoveType): TldrawCommand;
|
||||
resetBounds(app: TldrawApp, ids: string[], pageId: string): TldrawCommand;
|
||||
rotateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
delta: number
|
||||
): TldrawCommand | void;
|
||||
setShapesProps<T extends TDShape>(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
partial: Partial<T>
|
||||
): TldrawCommand;
|
||||
stretchShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: StretchType
|
||||
): TldrawCommand;
|
||||
styleShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
changes: Partial<ShapeStyles>
|
||||
): TldrawCommand;
|
||||
toggleShapesDecoration(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
decorationId: 'start' | 'end'
|
||||
): TldrawCommand;
|
||||
toggleShapesProp(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
prop: keyof TDShape
|
||||
): TldrawCommand;
|
||||
translateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
delta: number[]
|
||||
): TldrawCommand;
|
||||
ungroupShapes(
|
||||
app: TldrawApp,
|
||||
selectedIds: string[],
|
||||
groupShapes: GroupShape[],
|
||||
pageId: string
|
||||
): TldrawCommand | undefined;
|
||||
updateShapes(
|
||||
app: TldrawApp,
|
||||
updates: ({ id: string } & Partial<TDShape>)[],
|
||||
pageId: string
|
||||
): TldrawCommand;
|
||||
}
|
||||
131
libs/components/board-state/src/types/tool.ts
Normal file
131
libs/components/board-state/src/types/tool.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
TLKeyboardEventHandler,
|
||||
TLPinchEventHandler,
|
||||
TLPointerEventHandler,
|
||||
Utils,
|
||||
} from '@tldraw/core';
|
||||
import type { TldrawApp } from '../tldraw-app';
|
||||
import {
|
||||
TDEventHandler,
|
||||
TDToolType,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export enum Status {
|
||||
Idle = 'idle',
|
||||
Creating = 'creating',
|
||||
Pinching = 'pinching',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export abstract class BaseTool<T extends string = any> extends TDEventHandler {
|
||||
type: TDToolType = 'select' as const;
|
||||
|
||||
previous?: TDToolType;
|
||||
|
||||
status: Status | T = Status.Idle;
|
||||
|
||||
constructor(public app: TldrawApp) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected readonly set_status = (status: Status | T) => {
|
||||
this.status = status as Status | T;
|
||||
this.app.setStatus(this.status as string);
|
||||
};
|
||||
|
||||
onEnter = () => {
|
||||
this.set_status(Status.Idle);
|
||||
};
|
||||
|
||||
onExit = () => {
|
||||
this.set_status(Status.Idle);
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
if (this.status === Status.Idle) {
|
||||
this.app.selectTool('select');
|
||||
} else {
|
||||
this.set_status(Status.Idle);
|
||||
}
|
||||
|
||||
this.app.cancelSession();
|
||||
};
|
||||
|
||||
getNextChildIndex = () => {
|
||||
const {
|
||||
shapes,
|
||||
appState: { currentPageId },
|
||||
} = this.app;
|
||||
|
||||
return shapes.length === 0
|
||||
? 1
|
||||
: shapes
|
||||
.filter(shape => shape.parentId === currentPageId)
|
||||
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex +
|
||||
1;
|
||||
};
|
||||
|
||||
/* --------------------- Camera --------------------- */
|
||||
|
||||
override onPinchStart: TLPinchEventHandler = () => {
|
||||
this.app.cancelSession();
|
||||
this.set_status(Status.Pinching);
|
||||
};
|
||||
|
||||
override onPinchEnd: TLPinchEventHandler = () => {
|
||||
if (Utils.isMobileSafari()) {
|
||||
this.app.undoSelect();
|
||||
}
|
||||
this.set_status(Status.Idle);
|
||||
};
|
||||
|
||||
override onPinch: TLPinchEventHandler = (info, e) => {
|
||||
if (this.status !== 'pinching') return;
|
||||
if (isNaN(info.delta[0]) || isNaN(info.delta[1])) return;
|
||||
this.app.pinchZoom(info.point, info.delta, info.delta[2]);
|
||||
this.onPointerMove?.(info, e as unknown as React.PointerEvent);
|
||||
};
|
||||
|
||||
/* ---------------------- Keys ---------------------- */
|
||||
|
||||
override onKeyDown: TLKeyboardEventHandler = key => {
|
||||
if (key === 'Escape') {
|
||||
this.onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||
this.app.updateSession();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
override onKeyUp: TLKeyboardEventHandler = key => {
|
||||
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||
this.app.updateSession();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/* --------------------- Pointer -------------------- */
|
||||
|
||||
override onPointerMove: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.app.updateSession();
|
||||
}
|
||||
};
|
||||
|
||||
override onPointerUp: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.app.completeSession();
|
||||
|
||||
const { isToolLocked } = this.app.appState;
|
||||
|
||||
if (!isToolLocked) {
|
||||
this.app.selectTool('select');
|
||||
}
|
||||
}
|
||||
|
||||
this.set_status(Status.Idle);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user