init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
@@ -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;
};
@@ -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;
@@ -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'
);
});
@@ -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;
});
}
@@ -0,0 +1,3 @@
export * from './migrate';
export * from './filesystem';
export * from './browser-fs-access';
@@ -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');
});
});
@@ -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;
}