aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/apis/google_docs/GoogleApiClientUtils.ts265
-rw-r--r--src/client/apis/google_docs/GooglePhotosClientUtils.ts328
-rw-r--r--src/client/documents/Documents.ts16
-rw-r--r--src/client/northstar/utils/Extensions.ts2
-rw-r--r--src/client/util/Import & Export/DirectoryImportBox.tsx68
-rw-r--r--src/client/views/DocumentDecorations.scss31
-rw-r--r--src/client/views/DocumentDecorations.tsx7
-rw-r--r--src/client/views/InkingControl.tsx3
-rw-r--r--src/client/views/Main.tsx20
-rw-r--r--src/client/views/MainView.tsx21
-rw-r--r--src/client/views/collections/CollectionSubView.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx13
-rw-r--r--src/client/views/nodes/DocumentView.tsx13
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx94
-rw-r--r--src/client/views/nodes/ImageBox.scss108
-rw-r--r--src/client/views/nodes/ImageBox.tsx20
16 files changed, 731 insertions, 280 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts
index 798886def..828d4451a 100644
--- a/src/client/apis/google_docs/GoogleApiClientUtils.ts
+++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts
@@ -3,111 +3,130 @@ import { PostToServer } from "../../../Utils";
import { RouteStore } from "../../../server/RouteStore";
import { Opt } from "../../../new_fields/Doc";
import { isArray } from "util";
+import { EditorState } from "prosemirror-state";
+import { RichTextField } from "../../../new_fields/RichTextField";
export const Pulls = "googleDocsPullCount";
export const Pushes = "googleDocsPushCount";
export namespace GoogleApiClientUtils {
- export enum Service {
- Documents = "Documents",
- Slides = "Slides"
- }
-
export enum Actions {
Create = "create",
Retrieve = "retrieve",
Update = "update"
}
- export enum WriteMode {
- Insert,
- Replace
- }
-
- export type Identifier = string;
- export type Reference = Identifier | CreateOptions;
- export type TextContent = string | string[];
- export type IdHandler = (id: Identifier) => any;
- export type CreationResult = Opt<Identifier>;
- export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>;
- export type ReadResult = { title?: string, body?: string };
+ export namespace Docs {
- export interface CreateOptions {
- service: Service;
- title?: string; // if excluded, will use a default title annotated with the current date
- }
+ export type RetrievalResult = Opt<docs_v1.Schema$Document>;
+ export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>;
- export interface RetrieveOptions {
- service: Service;
- identifier: Identifier;
- }
+ export interface UpdateOptions {
+ documentId: DocumentId;
+ requests: docs_v1.Schema$Request[];
+ }
- export interface ReadOptions {
- identifier: Identifier;
- removeNewlines?: boolean;
- }
+ export enum WriteMode {
+ Insert,
+ Replace
+ }
- export interface WriteOptions {
- mode: WriteMode;
- content: TextContent;
- reference: Reference;
- index?: number; // if excluded, will compute the last index of the document and append the content there
- }
+ export type DocumentId = string;
+ export type Reference = DocumentId | CreateOptions;
+ export interface Content {
+ text: string | string[];
+ requests: docs_v1.Schema$Request[];
+ }
+ export type IdHandler = (id: DocumentId) => any;
+ export type CreationResult = Opt<DocumentId>;
+ export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>;
+ export type ReadResult = { title: string, body: string };
+ export interface ImportResult {
+ title: string;
+ text: string;
+ state: EditorState;
+ }
- /**
- * After following the authentication routine, which connects this API call to the current signed in account
- * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which
- * should appear in the user's Google Doc library instantaneously.
- *
- * @param options the title to assign to the new document, and the information necessary
- * to store the new documentId returned from the creation process
- * @returns the documentId of the newly generated document, or undefined if the creation process fails.
- */
- export const create = async (options: CreateOptions): Promise<CreationResult> => {
- const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Create}`;
- const parameters = {
- requestBody: {
- title: options.title || `Dash Export (${new Date().toDateString()})`
- }
- };
- try {
- const schema: any = await PostToServer(path, parameters);
- let key = ["document", "presentation"].find(prefix => `${prefix}Id` in schema) + "Id";
- return schema[key];
- } catch {
- return undefined;
+ export interface CreateOptions {
+ title?: string; // if excluded, will use a default title annotated with the current date
}
- };
- export namespace Docs {
+ export interface RetrieveOptions {
+ documentId: DocumentId;
+ }
- export type RetrievalResult = Opt<docs_v1.Schema$Document | slides_v1.Schema$Presentation>;
- export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>;
+ export interface ReadOptions {
+ documentId: DocumentId;
+ removeNewlines?: boolean;
+ }
- export interface UpdateOptions {
- documentId: Identifier;
- requests: docs_v1.Schema$Request[];
+ export interface WriteOptions {
+ mode: WriteMode;
+ content: Content;
+ reference: Reference;
+ index?: number; // if excluded, will compute the last index of the document and append the content there
}
+ /**
+ * After following the authentication routine, which connects this API call to the current signed in account
+ * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which
+ * should appear in the user's Google Doc library instantaneously.
+ *
+ * @param options the title to assign to the new document, and the information necessary
+ * to store the new documentId returned from the creation process
+ * @returns the documentId of the newly generated document, or undefined if the creation process fails.
+ */
+ export const create = async (options: CreateOptions): Promise<CreationResult> => {
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Create}`;
+ const parameters = {
+ requestBody: {
+ title: options.title || `Dash Export (${new Date().toDateString()})`
+ }
+ };
+ try {
+ const schema: docs_v1.Schema$Document = await PostToServer(path, parameters);
+ return schema.documentId;
+ } catch {
+ return undefined;
+ }
+ };
+
export namespace Utils {
- export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => {
- const fragments: string[] = [];
+ export type ExtractResult = { text: string, paragraphs: DeconstructedParagraph[] };
+ export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): ExtractResult => {
+ let paragraphs = extractParagraphs(document);
+ let text = paragraphs.map(paragraph => paragraph.runs.map(run => run.content).join("")).join("");
+ text = text.substring(0, text.length - 1);
+ removeNewlines && text.ReplaceAll("\n", "");
+ return { text, paragraphs };
+ };
+
+ export type DeconstructedParagraph = { runs: docs_v1.Schema$TextRun[], bullet: Opt<number> };
+ const extractParagraphs = (document: docs_v1.Schema$Document, filterEmpty = true): DeconstructedParagraph[] => {
+ const fragments: DeconstructedParagraph[] = [];
if (document.body && document.body.content) {
for (const element of document.body.content) {
- if (element.paragraph && element.paragraph.elements) {
- for (const inner of element.paragraph.elements) {
- if (inner && inner.textRun) {
- const fragment = inner.textRun.content;
- fragment && fragments.push(fragment);
+ let runs: docs_v1.Schema$TextRun[] = [];
+ let bullet: Opt<number>;
+ if (element.paragraph) {
+ if (element.paragraph.elements) {
+ for (const inner of element.paragraph.elements) {
+ if (inner && inner.textRun) {
+ let run = inner.textRun;
+ (run.content || !filterEmpty) && runs.push(inner.textRun);
+ }
}
}
+ if (element.paragraph.bullet) {
+ bullet = element.paragraph.bullet.nestingLevel || 0;
+ }
}
+ (runs.length || !filterEmpty) && fragments.push({ runs, bullet });
}
}
- const text = fragments.join("");
- return removeNewlines ? text.ReplaceAll("\n", "") : text;
+ return fragments;
};
export const endOf = (schema: docs_v1.Schema$Document): number | undefined => {
@@ -130,27 +149,19 @@ export namespace GoogleApiClientUtils {
}
- const KeyMapping = new Map<Service, string>([
- [Service.Documents, "documentId"],
- [Service.Slides, "presentationId"]
- ]);
-
export const retrieve = async (options: RetrieveOptions): Promise<RetrievalResult> => {
- const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Retrieve}`;
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Retrieve}`;
try {
- let parameters: any = {}, key: string | undefined;
- if ((key = KeyMapping.get(options.service))) {
- parameters[key] = options.identifier;
- const schema: RetrievalResult = await PostToServer(path, parameters);
- return schema;
- }
+ const parameters = { documentId: options.documentId };
+ const schema: RetrievalResult = await PostToServer(path, parameters);
+ return schema;
} catch {
return undefined;
}
};
export const update = async (options: UpdateOptions): Promise<UpdateResult> => {
- const path = `${RouteStore.googleDocs}/${Service.Documents}/${Actions.Update}`;
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Update}`;
const parameters = {
documentId: options.documentId,
requestBody: {
@@ -165,41 +176,49 @@ export namespace GoogleApiClientUtils {
}
};
- export const read = async (options: ReadOptions): Promise<ReadResult> => {
- return retrieve({ ...options, service: Service.Documents }).then(document => {
- let result: ReadResult = {};
+ export const read = async (options: ReadOptions): Promise<Opt<ReadResult>> => {
+ return retrieve({ documentId: options.documentId }).then(document => {
if (document) {
- let title = document.title;
- let body = Utils.extractText(document, options.removeNewlines);
- result = { title, body };
+ let title = document.title!;
+ let body = Utils.extractText(document, options.removeNewlines).text;
+ return { title, body };
}
- return result;
});
};
- export const readLines = async (options: ReadOptions): Promise<ReadLinesResult> => {
- return retrieve({ ...options, service: Service.Documents }).then(document => {
- let result: ReadLinesResult = {};
+ export const readLines = async (options: ReadOptions): Promise<Opt<ReadLinesResult>> => {
+ return retrieve({ documentId: options.documentId }).then(document => {
if (document) {
let title = document.title;
- let bodyLines = Utils.extractText(document).split("\n");
+ let bodyLines = Utils.extractText(document).text.split("\n");
options.removeNewlines && (bodyLines = bodyLines.filter(line => line.length));
- result = { title, bodyLines };
+ return { title, bodyLines };
}
- return result;
});
};
+ export const setStyle = async (options: UpdateOptions) => {
+ let replies: any = await update({
+ documentId: options.documentId,
+ requests: options.requests
+ });
+ if ("errors" in replies) {
+ console.log("Write operation failed:");
+ console.log(replies.errors.map((error: any) => error.message));
+ }
+ return replies;
+ };
+
export const write = async (options: WriteOptions): Promise<UpdateResult> => {
const requests: docs_v1.Schema$Request[] = [];
- const identifier = await Utils.initialize(options.reference);
- if (!identifier) {
+ const documentId = await Utils.initialize(options.reference);
+ if (!documentId) {
return undefined;
}
let index = options.index;
const mode = options.mode;
if (!(index && mode === WriteMode.Insert)) {
- let schema = await retrieve({ identifier, service: Service.Documents });
+ let schema = await retrieve({ documentId });
if (!schema || !(index = Utils.endOf(schema))) {
return undefined;
}
@@ -215,7 +234,7 @@ export namespace GoogleApiClientUtils {
});
index = 1;
}
- const text = options.content;
+ const text = options.content.text;
text.length && requests.push({
insertText: {
text: isArray(text) ? text.join("\n") : text,
@@ -225,47 +244,15 @@ export namespace GoogleApiClientUtils {
if (!requests.length) {
return undefined;
}
- let replies: any = await update({ documentId: identifier, requests });
- let errors = "errors";
- if (errors in replies) {
+ requests.push(...options.content.requests);
+ let replies: any = await update({ documentId: documentId, requests });
+ if ("errors" in replies) {
console.log("Write operation failed:");
- console.log(replies[errors].map((error: any) => error.message));
+ console.log(replies.errors.map((error: any) => error.message));
}
return replies;
};
}
- export namespace Slides {
-
- export namespace Utils {
-
- export const extractTextBoxes = (slides: slides_v1.Schema$Page[]) => {
- slides.map(slide => {
- let elements = slide.pageElements;
- if (elements) {
- let textboxes: slides_v1.Schema$TextContent[] = [];
- for (let element of elements) {
- if (element && element.shape && element.shape.shapeType === "TEXT_BOX" && element.shape.text) {
- textboxes.push(element.shape.text);
- }
- }
- textboxes.map(text => {
- if (text.textElements) {
- text.textElements.map(element => {
-
- });
- }
- if (text.lists) {
-
- }
- });
- }
- });
- };
-
- }
-
- }
-
} \ No newline at end of file
diff --git a/src/client/apis/google_docs/GooglePhotosClientUtils.ts b/src/client/apis/google_docs/GooglePhotosClientUtils.ts
new file mode 100644
index 000000000..700c0401a
--- /dev/null
+++ b/src/client/apis/google_docs/GooglePhotosClientUtils.ts
@@ -0,0 +1,328 @@
+import { PostToServer, Utils } from "../../../Utils";
+import { RouteStore } from "../../../server/RouteStore";
+import { ImageField } from "../../../new_fields/URLField";
+import { Cast, StrCast } from "../../../new_fields/Types";
+import { Doc, Opt, DocListCastAsync } from "../../../new_fields/Doc";
+import { Id } from "../../../new_fields/FieldSymbols";
+import Photos = require('googlephotos');
+import { RichTextField } from "../../../new_fields/RichTextField";
+import { RichTextUtils } from "../../../new_fields/RichTextUtils";
+import { EditorState } from "prosemirror-state";
+import { FormattedTextBox } from "../../views/nodes/FormattedTextBox";
+import { Docs, DocumentOptions } from "../../documents/Documents";
+import { NewMediaItemResult, MediaItem } from "../../../server/apis/google/SharedTypes";
+import { AssertionError } from "assert";
+import { List } from "../../../new_fields/List";
+import { listSpec } from "../../../new_fields/Schema";
+
+export namespace GooglePhotos {
+
+ const endpoint = async () => {
+ const getToken = Utils.prepend(RouteStore.googlePhotosAccessToken);
+ const token = await (await fetch(getToken)).text();
+ return new Photos(token);
+ };
+
+ export enum MediaType {
+ ALL_MEDIA = 'ALL_MEDIA',
+ PHOTO = 'PHOTO',
+ VIDEO = 'VIDEO'
+ }
+
+ export type AlbumReference = { id: string } | { title: string };
+
+ export interface MediaInput {
+ url: string;
+ description: string;
+ }
+
+ export const ContentCategories = {
+ NONE: 'NONE',
+ LANDSCAPES: 'LANDSCAPES',
+ RECEIPTS: 'RECEIPTS',
+ CITYSCAPES: 'CITYSCAPES',
+ LANDMARKS: 'LANDMARKS',
+ SELFIES: 'SELFIES',
+ PEOPLE: 'PEOPLE',
+ PETS: 'PETS',
+ WEDDINGS: 'WEDDINGS',
+ BIRTHDAYS: 'BIRTHDAYS',
+ DOCUMENTS: 'DOCUMENTS',
+ TRAVEL: 'TRAVEL',
+ ANIMALS: 'ANIMALS',
+ FOOD: 'FOOD',
+ SPORT: 'SPORT',
+ NIGHT: 'NIGHT',
+ PERFORMANCES: 'PERFORMANCES',
+ WHITEBOARDS: 'WHITEBOARDS',
+ SCREENSHOTS: 'SCREENSHOTS',
+ UTILITY: 'UTILITY',
+ ARTS: 'ARTS',
+ CRAFTS: 'CRAFTS',
+ FASHION: 'FASHION',
+ HOUSES: 'HOUSES',
+ GARDENS: 'GARDENS',
+ FLOWERS: 'FLOWERS',
+ HOLIDAYS: 'HOLIDAYS'
+ };
+
+ export namespace Export {
+
+ export interface AlbumCreationResult {
+ albumId: string;
+ mediaItems: MediaItem[];
+ }
+
+ export interface AlbumCreationOptions {
+ collection: Doc;
+ title?: string;
+ descriptionKey?: string;
+ tag?: boolean;
+ }
+
+ export const CollectionToAlbum = async (options: AlbumCreationOptions): Promise<Opt<AlbumCreationResult>> => {
+ const { collection, title, descriptionKey, tag } = options;
+ const dataDocument = Doc.GetProto(collection);
+ const images = ((await DocListCastAsync(dataDocument.data)) || []).filter(doc => Cast(doc.data, ImageField));
+ if (!images || !images.length) {
+ return undefined;
+ }
+ const resolved = title ? title : (StrCast(collection.title) || `Dash Collection (${collection[Id]}`);
+ const { id } = await Create.Album(resolved);
+ const newMediaItemResults = await Transactions.UploadImages(images, { id }, descriptionKey);
+ if (newMediaItemResults) {
+ const mediaItems = newMediaItemResults.map(item => item.mediaItem);
+ if (mediaItems.length !== images.length) {
+ throw new AssertionError({ actual: mediaItems.length, expected: images.length });
+ }
+ const idMapping = new Doc;
+ for (let i = 0; i < images.length; i++) {
+ const image = images[i];
+ const mediaItem = mediaItems[i];
+ image.googlePhotosId = mediaItem.id;
+ image.googlePhotosUrl = mediaItem.baseUrl || mediaItem.productUrl;
+ idMapping[mediaItem.id] = image;
+ }
+ collection.googlePhotosIdMapping = idMapping;
+ if (tag) {
+ await Query.TagChildImages(collection);
+ }
+ collection.albumId = id;
+ return { albumId: id, mediaItems };
+ }
+ };
+
+ }
+
+ export namespace Import {
+
+ export type CollectionConstructor = (data: Array<Doc>, options: DocumentOptions, ...args: any) => Doc;
+
+ export const CollectionFromSearch = async (constructor: CollectionConstructor, requested: Opt<Partial<Query.SearchOptions>>): Promise<Doc> => {
+ let response = await Query.Search(requested);
+ let uploads = await Transactions.WriteMediaItemsToServer(response);
+ const children = uploads.map((upload: Transactions.UploadInformation) => {
+ let document = Docs.Create.ImageDocument(Utils.fileUrl(upload.fileNames.clean));
+ document.fillColumn = true;
+ document.contentSize = upload.contentSize;
+ return document;
+ });
+ const options = { width: 500, height: 500 };
+ return constructor(children, options);
+ };
+
+ }
+
+ export namespace Query {
+
+ const delimiter = ", ";
+ export const TagChildImages = async (collection: Doc) => {
+ const idMapping = await Cast(collection.googlePhotosIdMapping, Doc);
+ if (!idMapping) {
+ throw new Error("Appending image metadata requires that the targeted collection have already been mapped to an album!");
+ }
+ const tagMapping = new Map<string, string>();
+ const images = await DocListCastAsync(collection.data);
+ images && images.forEach(image => tagMapping.set(image[Id], ContentCategories.NONE));
+ const values = Object.values(ContentCategories);
+ for (let value of values) {
+ if (value !== ContentCategories.NONE) {
+ const results = await Search({ included: [value] });
+ if (results.mediaItems) {
+ const ids = results.mediaItems.map(item => item.id);
+ for (let id of ids) {
+ const image = await Cast(idMapping[id], Doc);
+ if (image) {
+ const key = image[Id];
+ const tags = tagMapping.get(key)!;
+ if (!tags.includes(value)) {
+ tagMapping.set(key, tags + delimiter + value);
+ }
+ }
+ }
+ }
+ }
+ }
+ images && images.forEach(image => {
+ const concatenated = tagMapping.get(image[Id])!;
+ const tags = concatenated.split(delimiter);
+ if (tags.length > 1) {
+ const cleaned = concatenated.replace(ContentCategories.NONE + delimiter, "");
+ image.googlePhotosTags = cleaned.split(delimiter).sort((a, b) => (a < b) ? -1 : (a > b ? 1 : 0)).join(delimiter);
+ }
+ });
+
+ };
+
+ interface DateRange {
+ after: Date;
+ before: Date;
+ }
+
+ const DefaultSearchOptions: SearchOptions = {
+ pageSize: 50,
+ included: [],
+ excluded: [],
+ date: undefined,
+ includeArchivedMedia: true,
+ excludeNonAppCreatedData: false,
+ type: MediaType.ALL_MEDIA,
+ };
+
+ export interface SearchOptions {
+ pageSize: number;
+ included: string[];
+ excluded: string[];
+ date: Opt<Date | DateRange>;
+ includeArchivedMedia: boolean;
+ excludeNonAppCreatedData: boolean;
+ type: MediaType;
+ }
+
+ export interface SearchResponse {
+ mediaItems: any[];
+ nextPageToken: string;
+ }
+
+ export const Search = async (requested: Opt<Partial<SearchOptions>>): Promise<SearchResponse> => {
+ const options = requested || DefaultSearchOptions;
+ const photos = await endpoint();
+ const filters = new photos.Filters(options.includeArchivedMedia === undefined ? true : options.includeArchivedMedia);
+
+ const included = options.included || [];
+ const excluded = options.excluded || [];
+ const contentFilter = new photos.ContentFilter();
+ included.length && included.forEach(category => contentFilter.addIncludedContentCategories(category));
+ excluded.length && excluded.forEach(category => contentFilter.addExcludedContentCategories(category));
+ filters.setContentFilter(contentFilter);
+
+ const date = options.date;
+ if (date) {
+ const dateFilter = new photos.DateFilter();
+ if (date instanceof Date) {
+ dateFilter.addDate(date);
+ } else {
+ dateFilter.addRange(date.after, date.before);
+ }
+ filters.setDateFilter(dateFilter);
+ }
+
+ filters.setMediaTypeFilter(new photos.MediaTypeFilter(options.type || MediaType.ALL_MEDIA));
+
+ return new Promise<SearchResponse>(resolve => {
+ photos.mediaItems.search(filters, options.pageSize || 100).then(resolve);
+ });
+ };
+
+ export const GetImage = async (mediaItemId: string): Promise<Transactions.MediaItem> => {
+ return (await endpoint()).mediaItems.get(mediaItemId);
+ };
+
+ }
+
+ namespace Create {
+
+ export const Album = async (title: string) => {
+ return (await endpoint()).albums.create(title);
+ };
+
+ }
+
+ export namespace Transactions {
+
+ export interface UploadInformation {
+ mediaPaths: string[];
+ fileNames: { [key: string]: string };
+ contentSize?: number;
+ contentType?: string;
+ }
+
+ export interface MediaItem {
+ id: string;
+ filename: string;
+ baseUrl: string;
+ }
+
+ export const AddTextEnrichment = async (collection: Doc, content?: string) => {
+ const photos = await endpoint();
+ const albumId = StrCast(collection.albumId);
+ if (albumId && albumId.length) {
+ const enrichment = new photos.TextEnrichment(content || Utils.prepend("/doc/" + collection[Id]));
+ const position = new photos.AlbumPosition(photos.AlbumPosition.POSITIONS.FIRST_IN_ALBUM);
+ const enrichmentItem = await photos.albums.addEnrichment(albumId, enrichment, position);
+ if (enrichmentItem) {
+ return enrichmentItem.id;
+ }
+ }
+ };
+
+ export const WriteMediaItemsToServer = async (body: { mediaItems: any[] }): Promise<UploadInformation[]> => {
+ const uploads = await PostToServer(RouteStore.googlePhotosMediaDownload, body);
+ return uploads;
+ };
+
+ export const UploadThenFetch = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption") => {
+ const newMediaItems = await UploadImages(sources, album, descriptionKey);
+ if (!newMediaItems) {
+ return undefined;
+ }
+ const baseUrls: string[] = await Promise.all(newMediaItems.map(item => {
+ return new Promise<string>(resolve => Query.GetImage(item.mediaItem.id).then(item => resolve(item.baseUrl)));
+ }));
+ return baseUrls;
+ };
+
+ export const UploadImages = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption"): Promise<Opt<NewMediaItemResult[]>> => {
+ if (album && "title" in album) {
+ album = await Create.Album(album.title);
+ }
+ const media: MediaInput[] = [];
+ sources.forEach(source => {
+ const data = Cast(Doc.GetProto(source).data, ImageField);
+ if (!data) {
+ return;
+ }
+ const url = data.url.href;
+ const description = parseDescription(source, descriptionKey);
+ media.push({ url, description });
+ });
+ if (media.length) {
+ const uploads: NewMediaItemResult[] = await PostToServer(RouteStore.googlePhotosMediaUpload, { media, album });
+ return uploads;
+ }
+ };
+
+ const parseDescription = (document: Doc, descriptionKey: string) => {
+ let description: string = Utils.prepend("/doc/" + document[Id]);
+ const target = document[descriptionKey];
+ if (typeof target === "string") {
+ description = target;
+ } else if (target instanceof RichTextField) {
+ description = RichTextUtils.ToPlainText(EditorState.fromJSON(FormattedTextBox.Instance.config, JSON.parse(target.Data)));
+ }
+ return description;
+ };
+
+ }
+
+} \ No newline at end of file
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 602a7f9ad..5dd945c16 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -21,7 +21,7 @@ import { AggregateFunction } from "../northstar/model/idea/idea";
import { MINIMIZED_ICON_SIZE } from "../views/globalCssVariables.scss";
import { IconBox } from "../views/nodes/IconBox";
import { Field, Doc, Opt } from "../../new_fields/Doc";
-import { OmitKeys, JSONUtils } from "../../Utils";
+import { OmitKeys, JSONUtils, Utils } from "../../Utils";
import { ImageField, VideoField, AudioField, PdfField, WebField, YoutubeField } from "../../new_fields/URLField";
import { HtmlField } from "../../new_fields/HtmlField";
import { List } from "../../new_fields/List";
@@ -333,7 +333,12 @@ export namespace Docs {
export function ImageDocument(url: string, options: DocumentOptions = {}) {
let imgField = new ImageField(new URL(url));
let inst = InstanceFromProto(Prototypes.get(DocumentType.IMG), imgField, { title: path.basename(url), ...options });
- requestImageSize(imgField.url.href)
+ let target = imgField.url.href;
+ if (new RegExp(window.location.origin).test(target)) {
+ let extension = path.extname(target);
+ target = `${target.substring(0, target.length - extension.length)}_o${extension}`;
+ }
+ requestImageSize(target)
.then((size: any) => {
let aspect = size.height / size.width;
if (!inst.proto!.nativeWidth) {
@@ -507,10 +512,13 @@ export namespace Docs {
* @param title an optional title to give to the highest parent document in the hierarchy
*/
export function DocumentHierarchyFromJson(input: any, title?: string): Opt<Doc> {
- if (input === null || ![...primitives, "object"].includes(typeof input)) {
+ if (input === undefined || input === null || ![...primitives, "object"].includes(typeof input)) {
return undefined;
}
- let parsed: any = typeof input === "string" ? JSONUtils.tryParse(input) : input;
+ let parsed = input;
+ if (typeof input === "string") {
+ parsed = JSONUtils.tryParse(input);
+ }
let converted: Doc;
if (typeof parsed === "object" && !(parsed instanceof Array)) {
converted = convertObject(parsed, title);
diff --git a/src/client/northstar/utils/Extensions.ts b/src/client/northstar/utils/Extensions.ts
index df14d4da0..ab9384f1f 100644
--- a/src/client/northstar/utils/Extensions.ts
+++ b/src/client/northstar/utils/Extensions.ts
@@ -1,6 +1,8 @@
interface String {
ReplaceAll(toReplace: string, replacement: string): string;
Truncate(length: number, replacement: string): String;
+ removeTrailingNewlines(): string;
+ hasNewline(): boolean;
}
String.prototype.ReplaceAll = function (toReplace: string, replacement: string): string {
diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx
index 75b0b52a7..b0bbb5462 100644
--- a/src/client/util/Import & Export/DirectoryImportBox.tsx
+++ b/src/client/util/Import & Export/DirectoryImportBox.tsx
@@ -18,8 +18,14 @@ import { Id } from "../../../new_fields/FieldSymbols";
import { List } from "../../../new_fields/List";
import { Cast, BoolCast, NumCast } from "../../../new_fields/Types";
import { listSpec } from "../../../new_fields/Schema";
+import { GooglePhotos } from "../../apis/google_docs/GooglePhotosClientUtils";
const unsupported = ["text/html", "text/plain"];
+interface FileResponse {
+ name: string;
+ path: string;
+ type: string;
+}
@observer
export default class DirectoryImportBox extends React.Component<FieldViewProps> {
@@ -68,13 +74,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
handleSelection = async (e: React.ChangeEvent<HTMLInputElement>) => {
runInAction(() => this.uploading = true);
- let promises: Promise<void>[] = [];
let docs: Doc[] = [];
let files = e.target.files;
if (!files || files.length === 0) return;
- let directory = (files.item(0) as any).webkitRelativePath.split("/", 1);
+ let directory = (files.item(0) as any).webkitRelativePath.split("/", 1)[0];
let validated: File[] = [];
for (let i = 0; i < files.length; i++) {
@@ -87,32 +92,39 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
let sizes = [];
let modifiedDates = [];
- for (let uploaded_file of validated) {
+ let i = 0;
+ const uploads: FileResponse[] = [];
+ const batchSize = 15;
+
+ while (i < validated.length) {
+ const cap = Math.min(validated.length, i + batchSize);
let formData = new FormData();
- formData.append('file', uploaded_file);
- let dropFileName = uploaded_file ? uploaded_file.name : "-empty-";
- let type = uploaded_file.type;
-
- sizes.push(uploaded_file.size);
- modifiedDates.push(uploaded_file.lastModified);
-
- runInAction(() => this.remaining++);
-
- let prom = fetch(Utils.prepend(RouteStore.upload), {
- method: 'POST',
- body: formData
- }).then(async (res: Response) => {
- (await res.json()).map(action((file: any) => {
- let docPromise = Docs.Get.DocumentFromType(type, Utils.prepend(file), { nativeWidth: 300, width: 300, title: dropFileName });
- docPromise.then(doc => {
- doc && docs.push(doc) && runInAction(() => this.remaining--);
- });
- }));
- });
- promises.push(prom);
+ const batch = validated.slice(i, cap);
+
+ sizes.push(...batch.map(file => file.size));
+ modifiedDates.push(...batch.map(file => file.lastModified));
+
+ batch.forEach(file => formData.append(Utils.GenerateGuid(), file));
+ const parameters = { method: 'POST', body: formData };
+ uploads.push(...(await (await fetch(Utils.prepend(RouteStore.upload), parameters)).json()));
+
+ runInAction(() => this.remaining += batch.length);
+ i = cap;
}
- await Promise.all(promises);
+
+ await Promise.all(uploads.map(async upload => {
+ const type = upload.type;
+ const path = Utils.prepend(upload.path);
+ const options = {
+ nativeWidth: 300,
+ width: 300,
+ title: upload.name
+ };
+ const document = await Docs.Get.DocumentFromType(type, path, options);
+ document && docs.push(document) && runInAction(() => this.remaining--);
+ console.log(`(${this.quota - this.remaining}/${this.quota}) ${upload.name}`);
+ }));
for (let i = 0; i < docs.length; i++) {
let doc = docs[i];
@@ -136,12 +148,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
};
let parent = this.props.ContainingCollectionView;
if (parent) {
- let importContainer = Docs.Create.StackingDocument(docs, options);
+ let importContainer = Docs.Create.MasonryDocument(docs, options);
+ await GooglePhotos.Export.CollectionToAlbum({ collection: importContainer });
importContainer.singleColumn = false;
Doc.AddDocToList(Doc.GetProto(parent.props.Document), "data", importContainer);
!this.persistent && this.props.removeDocument && this.props.removeDocument(doc);
DocumentManager.Instance.jumpToDocument(importContainer, true);
-
}
runInAction(() => {
@@ -313,7 +325,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
style={{
pointerEvents: "none",
position: "absolute",
- right: isEditing ? 16.3 : 14.5,
+ right: isEditing ? 14 : 15,
top: isEditing ? 15.4 : 16,
opacity: uploading ? 0 : 1,
transition: "0.4s opacity ease"
diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss
index ac8497bd0..470365627 100644
--- a/src/client/views/DocumentDecorations.scss
+++ b/src/client/views/DocumentDecorations.scss
@@ -266,6 +266,31 @@ $linkGap : 3px;
}
}
-@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } }
-@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } }
-@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } } \ No newline at end of file
+@-moz-keyframes spin {
+ 100% {
+ -moz-transform: rotate(360deg);
+ }
+}
+
+@-webkit-keyframes spin {
+ 100% {
+ -webkit-transform: rotate(360deg);
+ }
+}
+
+@keyframes spin {
+ 100% {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes shadow-pulse {
+ 0% {
+ box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.2);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 35px rgba(0, 0, 0, 0);
+ }
+} \ No newline at end of file
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 814d718be..97876204e 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -83,6 +83,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
@observable public pullIcon: IconProp = "arrow-alt-circle-down";
@observable public pullColor: string = "white";
@observable public isAnimatingFetch = false;
+ @observable public isAnimatingPulse = false;
@observable public openHover = false;
public pullColorAnimating = false;
@@ -103,6 +104,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
public startPushOutcome = action((success: boolean) => {
if (!this.pushAnimating) {
this.pushAnimating = true;
+ this.isAnimatingPulse = false;
this.pushIcon = success ? "check-circle" : "stop-circle";
setTimeout(() => runInAction(() => {
this.pushIcon = "arrow-alt-circle-up";
@@ -725,9 +727,12 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
return (
<div className={"linkButtonWrapper"}>
<div title={`${published ? "Push" : "Publish"} to Google Docs`} className="linkButton-linker" onClick={() => {
+ if (!published) {
+ runInAction(() => this.isAnimatingPulse = true);
+ }
DocumentDecorations.hasPushedHack = false;
this.targetDoc[Pushes] = NumCast(this.targetDoc[Pushes]) + 1;
- }}>
+ }} style={{ animation: this.isAnimatingPulse ? "shadow-pulse 1s infinite" : "none" }}>
<FontAwesomeIcon className="documentdecorations-icon" icon={icon} size={published ? "sm" : "xs"} />
</div>
</div>
diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx
index aa573f16b..147418400 100644
--- a/src/client/views/InkingControl.tsx
+++ b/src/client/views/InkingControl.tsx
@@ -67,8 +67,9 @@ export class InkingControl extends React.Component {
cp[closest] = "rgba(" + color.rgb.r + "," + color.rgb.g + "," + color.rgb.b + "," + color.rgb.a + ")";
view.props.ContainingCollectionView.props.Document.colorPalette = new List(cp);
targetDoc.backgroundColor = cp[closest];
- } else
+ } else {
targetDoc.backgroundColor = this._selectedColor;
+ }
if (view.props.Document.heading) {
let cv = view.props.ContainingCollectionView;
let ruleProvider = cv && (Cast(cv.props.Document.ruleProvider, Doc) as Doc);
diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx
index 11ec6f0c9..e35ba18e4 100644
--- a/src/client/views/Main.tsx
+++ b/src/client/views/Main.tsx
@@ -8,6 +8,26 @@ import { Doc, DocListCastAsync } from "../../new_fields/Doc";
import { List } from "../../new_fields/List";
import { DocServer } from "../DocServer";
+String.prototype.removeTrailingNewlines = function () {
+ let sliced = this;
+ while (sliced.endsWith("\n")) {
+ sliced = sliced.substring(0, this.length - 1);
+ }
+ return sliced as string;
+};
+
+String.prototype.hasNewline = function () {
+ return this.endsWith("\n");
+};
+
+(Array.prototype as any).lastElement = function (this: any[]) {
+ if (!this.length) {
+ return undefined;
+ }
+ return this[this.length - 1];
+};
+
+
let swapDocs = async () => {
let oldDoc = await Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc);
// Docs.Prototypes.MainLinkDocument().allLinks = new List<Doc>();
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index b64986084..28edf181b 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -15,7 +15,7 @@ import { listSpec } from '../../new_fields/Schema';
import { BoolCast, Cast, FieldValue, StrCast, NumCast } from '../../new_fields/Types';
import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils';
import { RouteStore } from '../../server/RouteStore';
-import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString } from '../../Utils';
+import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString, PostToServer } from '../../Utils';
import { DocServer } from '../DocServer';
import { Docs } from '../documents/Documents';
import { ClientUtils } from '../util/ClientUtils';
@@ -40,6 +40,8 @@ import { PreviewCursor } from './PreviewCursor';
import { FilterBox } from './search/FilterBox';
import PresModeMenu from './presentationview/PresentationModeMenu';
import { PresBox } from './nodes/PresBox';
+import { GooglePhotos } from '../apis/google_docs/GooglePhotosClientUtils';
+import { ImageField } from '../../new_fields/URLField';
import { LinkFollowBox } from './linking/LinkFollowBox';
import { DocumentManager } from '../util/DocumentManager';
@@ -128,6 +130,8 @@ export class MainView extends React.Component {
window.removeEventListener("keydown", KeyManager.Instance.handle);
window.addEventListener("keydown", KeyManager.Instance.handle);
+ this.executeGooglePhotosRoutine();
+
reaction(() => {
let workspaces = CurrentUserUtils.UserDocument.workspaces;
let recent = CurrentUserUtils.UserDocument.recentlyClosed;
@@ -145,6 +149,16 @@ export class MainView extends React.Component {
}, { fireImmediately: true });
}
+ executeGooglePhotosRoutine = async () => {
+ // let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg";
+ // let doc = Docs.Create.ImageDocument(imgurl, { width: 200, title: "an image of a cat" });
+ // doc.caption = "Well isn't this a nice cat image!";
+ // let photos = await GooglePhotos.endpoint();
+ // let albumId = (await photos.albums.list(50)).albums.filter((album: any) => album.title === "This is a generically created album!")[0].id;
+ // console.log(await GooglePhotos.UploadImages([doc], { id: albumId }));
+ GooglePhotos.Query.Search({ included: [GooglePhotos.ContentCategories.ANIMALS] }).then(console.log);
+ }
+
componentWillUnMount() {
window.removeEventListener("keydown", KeyManager.Instance.handle);
//close presentation
@@ -457,12 +471,15 @@ export class MainView extends React.Component {
// let youtubeurl = "https://www.youtube.com/embed/TqcApsGRzWw";
// let addYoutubeSearcher = action(() => Docs.Create.YoutubeDocument(youtubeurl, { width: 600, height: 600, title: "youtube search" }));
- let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc][] = [
+ // let googlePhotosSearch = () => GooglePhotosClientUtils.CollectionFromSearch(Docs.Create.MasonryDocument, { included: [GooglePhotosClientUtils.ContentCategories.LANDSCAPES] });
+
+ let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc | Promise<Doc>][] = [
[React.createRef<HTMLDivElement>(), "object-group", "Add Collection", addColNode],
[React.createRef<HTMLDivElement>(), "tv", "Add Presentation Trail", addPresNode],
[React.createRef<HTMLDivElement>(), "globe-asia", "Add Website", addWebNode],
[React.createRef<HTMLDivElement>(), "bolt", "Add Button", addButtonDocument],
[React.createRef<HTMLDivElement>(), "file", "Add Document Dragger", addDragboxNode],
+ // [React.createRef<HTMLDivElement>(), "object-group", "Test Google Photos Search", googlePhotosSearch],
[React.createRef<HTMLDivElement>(), "cloud-upload-alt", "Import Directory", addImportCollectionNode], //remove at some point in favor of addImportCollectionNode
//[React.createRef<HTMLDivElement>(), "play", "Add Youtube Searcher", addYoutubeSearcher],
];
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 99e5ab7b3..5fc4f36a7 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -253,7 +253,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) {
}).then(async (res: Response) => {
(await res.json()).map(action((file: any) => {
let full = { ...options, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300, width: 300, title: dropFileName };
- let path = Utils.prepend(file);
+ let path = Utils.prepend(file.path);
Docs.Get.DocumentFromType(type, path, full).then(doc => doc && this.props.addDocument(doc));
}));
});
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 2591bdd8d..3377775db 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -99,7 +99,7 @@ export namespace PivotView {
groups.forEach((val, key) => minSize = Math.min(minSize, val.length));
const numCols = NumCast(collection.pivotNumColumns) || Math.ceil(Math.sqrt(minSize));
- const fontSize = NumCast(collection.pivotFontSize);
+ const fontSize = NumCast(collection.pivotFontSize, 30);
const docMap = new Map<Doc, ViewDefBounds>();
const groupNames: PivotData[] = [];
@@ -114,7 +114,8 @@ export namespace PivotView {
x,
y: width + 50,
width: width * 1.25 * numCols,
- height: 100, fontSize: fontSize
+ height: 100,
+ fontSize
});
for (const doc of val) {
docMap.set(doc, {
@@ -761,7 +762,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
return result.result === undefined ? { x: Cast(doc.x, "number"), y: Cast(doc.y, "number"), z: Cast(doc.z, "number"), width: Cast(doc.width, "number"), height: Cast(doc.height, "number") } : result.result;
}
- viewDefsToJSX = (views: any[]) => {
+ viewDefsToJSX = (views: PivotView.PivotData[]) => {
let elements: ViewDefResult[] = [];
if (Array.isArray(views)) {
elements = views.reduce<typeof elements>((prev, ele) => {
@@ -773,12 +774,12 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
return elements;
}
- private viewDefToJSX(viewDef: any): Opt<ViewDefResult> {
+ private viewDefToJSX(viewDef: PivotView.PivotData): Opt<ViewDefResult> {
if (viewDef.type === "text") {
const text = Cast(viewDef.text, "string");
const x = Cast(viewDef.x, "number");
const y = Cast(viewDef.y, "number");
- const z = Cast(viewDef.z, "number");
+ // const z = Cast(viewDef.z, "number");
const width = Cast(viewDef.width, "number");
const height = Cast(viewDef.height, "number");
const fontSize = Cast(viewDef.fontSize, "number");
@@ -790,7 +791,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
ele: <div className="collectionFreeform-customText" style={{
transform: `translate(${x}px, ${y}px)`,
width, height, fontSize
- }}>{text}</div>, bounds: { x: x!, y: y!, z: z, width: width!, height: height! }
+ }}>{text}</div>, bounds: { x: x!, y: y!, width: width!, height: height! }
};
}
}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index c2143bb82..2a8030c0c 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -41,6 +41,8 @@ import "./DocumentView.scss";
import { FormattedTextBox } from './FormattedTextBox';
import React = require("react");
import { DocumentType } from '../../documents/DocumentTypes';
+import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils';
+import { ImageField } from '../../../new_fields/URLField';
const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this?
library.add(fa.faTrash);
@@ -629,12 +631,19 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
subitems.push({ description: "Open Right Alias", event: () => this.props.addDocTab && this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.dataDoc, "onRight"), icon: "caret-square-right" });
subitems.push({ description: "Open Fields", event: this.fieldsClicked, icon: "layer-group" });
cm.addItem({ description: "Open...", subitems: subitems, icon: "external-link-alt" });
-
+ if (Cast(this.props.Document.data, ImageField)) {
+ cm.addItem({ description: "Export to Google Photos", event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: "caret-square-right" });
+ }
+ if (Cast(Doc.GetProto(this.props.Document).data, listSpec(Doc))) {
+ cm.addItem({ description: "Export to Google Photos Album", event: () => GooglePhotos.Export.CollectionToAlbum({ collection: this.props.Document }).then(console.log), icon: "caret-square-right" });
+ cm.addItem({ description: "Tag Child Images via Google Photos", event: () => GooglePhotos.Query.TagChildImages(this.props.Document), icon: "caret-square-right" });
+ cm.addItem({ description: "Write Back Link to Album", event: () => GooglePhotos.Transactions.AddTextEnrichment(this.props.Document), icon: "caret-square-right" });
+ }
let existingMake = ContextMenu.Instance.findByDescription("Make...");
let makes: ContextMenuProps[] = existingMake && "subitems" in existingMake ? existingMake.subitems : [];
makes.push({ description: this.props.Document.isBackground ? "Remove Background" : "Into Background", event: this.makeBackground, icon: this.props.Document.lockedPosition ? "unlock" : "lock" });
makes.push({ description: "Custom Document View", event: this.makeCustomViewClicked, icon: "concierge-bell" });
- makes.push({ description: "Metadata Field View", event: () => this.props.ContainingCollectionView && Doc.MakeTemplate(this.props.Document, StrCast(this.props.Document.title), this.props.ContainingCollectionView.props.Document), icon: "concierge-bell" })
+ makes.push({ description: "Metadata Field View", event: () => this.props.ContainingCollectionView && Doc.MakeTemplate(this.props.Document, StrCast(this.props.Document.title), this.props.ContainingCollectionView.props.Document), icon: "concierge-bell" });
makes.push({ description: "Into Portal", event: this.makeIntoPortal, icon: "window-restore" });
makes.push({ description: this.layoutDoc.ignoreClick ? "Selectable" : "Unselectable", event: () => this.layoutDoc.ignoreClick = !this.layoutDoc.ignoreClick, icon: this.layoutDoc.ignoreClick ? "unlock" : "lock" });
!existingMake && cm.addItem({ description: "Make...", subitems: makes, icon: "hand-point-right" });
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 0ea36cdc2..8a623e648 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -12,7 +12,7 @@ import { DateField } from '../../../new_fields/DateField';
import { Doc, DocListCast, Opt, WidthSym } from "../../../new_fields/Doc";
import { Copy, Id } from '../../../new_fields/FieldSymbols';
import { List } from '../../../new_fields/List';
-import { RichTextField, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField";
+import { RichTextField } from "../../../new_fields/RichTextField";
import { BoolCast, Cast, NumCast, StrCast, DateCast, PromiseValue } from "../../../new_fields/Types";
import { createSchema, makeInterface } from "../../../new_fields/Schema";
import { Utils, numberRange, timenow } from '../../../Utils';
@@ -37,6 +37,8 @@ import { DocumentDecorations } from '../DocumentDecorations';
import { DictationManager } from '../../util/DictationManager';
import { ReplaceStep } from 'prosemirror-transform';
import { DocumentType } from '../../documents/DocumentTypes';
+import { RichTextUtils } from '../../../new_fields/RichTextUtils';
+import * as _ from "lodash";
import { formattedTextBoxCommentPlugin, FormattedTextBoxComment } from './FormattedTextBoxComment';
import { inputRules } from 'prosemirror-inputrules';
import { select } from 'async';
@@ -44,8 +46,6 @@ import { select } from 'async';
library.add(faEdit);
library.add(faSmile, faTextHeight, faUpload);
-export const Blank = `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`;
-
export interface FormattedTextBoxProps {
isOverlay?: boolean;
hideOnLeave?: boolean;
@@ -64,13 +64,15 @@ export const GoogleRef = "googleDocId";
type RichTextDocument = makeInterface<[typeof richTextSchema]>;
const RichTextDocument = makeInterface(richTextSchema);
-type PullHandler = (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => void;
+type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void;
@observer
export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) {
public static LayoutString(fieldStr: string = "data") {
return FieldView.LayoutString(FormattedTextBox, fieldStr);
}
+ public static blankState = () => EditorState.create(FormattedTextBox.Instance.config);
+ public static Instance: FormattedTextBox;
private static _toolTipTextMenu: TooltipTextMenu | undefined = undefined;
private _ref: React.RefObject<HTMLDivElement> = React.createRef();
private _proseRef?: HTMLDivElement;
@@ -119,7 +121,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
@undoBatch
public setFontColor(color: string) {
- this._editorView!.state.storedMarks
+ this._editorView!.state.storedMarks;
if (this._editorView!.state.selection.from === this._editorView!.state.selection.to) return false;
if (this._editorView!.state.selection.to - this._editorView!.state.selection.from > this._editorView!.state.doc.nodeSize - 3) {
this.props.Document.color = color;
@@ -135,6 +137,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
if (this.props.isOverlay) {
DragManager.StartDragFunctions.push(() => FormattedTextBox.InputBoxOverlay = undefined);
}
+ FormattedTextBox.Instance = this;
}
public get CurrentDiv(): HTMLDivElement { return this._ref.current!; }
@@ -174,7 +177,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
let range = tx.selection.$from.blockRange(tx.selection.$to);
let text = range ? tx.doc.textBetween(range.start, range.end) : "";
let textEndSelection = tx.selection.to;
- for (; textEndSelection < range!.end && text[textEndSelection - range!.start] != " "; textEndSelection++) { }
+ for (; textEndSelection < range!.end && text[textEndSelection - range!.start] !== " "; textEndSelection++) { }
text = text.substr(0, textEndSelection - range!.start);
text = text.split(" ")[text.split(" ").length - 1];
let split = text.split("::");
@@ -188,7 +191,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
this.dataDoc[key] = doc || Docs.Create.FreeformDocument([], { title: value, width: 500, height: 500 }, value);
if (linkDoc) { (linkDoc as Doc).anchor2 = this.dataDoc[key] as Doc; }
else DocUtils.MakeLink(this.dataDoc, this.dataDoc[key] as Doc, undefined, "Ref:" + value, undefined, undefined, id);
- })
+ });
});
const link = this._editorView!.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value });
const mval = this._editorView!.state.schema.marks.metadataVal.create();
@@ -392,7 +395,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
this._reactionDisposer = reaction(
() => {
const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined;
- return field ? field.Data : Blank;
+ return field ? field.Data : RichTextUtils.Initialize();
},
incomingValue => {
if (this._editorView && !this._applyingChange) {
@@ -462,18 +465,17 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}
pushToGoogleDoc = async () => {
- this.pullFromGoogleDoc(async (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => {
- let modes = GoogleApiClientUtils.WriteMode;
+ this.pullFromGoogleDoc(async (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
+ let modes = GoogleApiClientUtils.Docs.WriteMode;
let mode = modes.Replace;
- let reference: Opt<GoogleApiClientUtils.Reference> = Cast(this.dataDoc[GoogleRef], "string");
+ let reference: Opt<GoogleApiClientUtils.Docs.Reference> = Cast(this.dataDoc[GoogleRef], "string");
if (!reference) {
mode = modes.Insert;
- reference = { service: GoogleApiClientUtils.Service.Documents, title: StrCast(this.dataDoc.title) };
+ reference = { title: StrCast(this.dataDoc.title) };
}
let redo = async () => {
- let data = Cast(this.dataDoc.data, RichTextField);
- if (this._editorView && reference && data) {
- let content = data[ToPlainText]();
+ if (this._editorView && reference) {
+ let content = await RichTextUtils.GoogleDocs.Export(this._editorView.state);
let response = await GoogleApiClientUtils.Docs.write({ reference, content, mode });
response && (this.dataDoc[GoogleRef] = response.documentId);
let pushSuccess = response !== undefined && !("errors" in response);
@@ -482,7 +484,13 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}
};
let undo = () => {
- let content = exportState.body;
+ if (!exportState) {
+ return;
+ }
+ let content: GoogleApiClientUtils.Docs.Content = {
+ text: exportState.text,
+ requests: []
+ };
if (reference && content) {
GoogleApiClientUtils.Docs.write({ reference, content, mode });
}
@@ -495,49 +503,41 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
pullFromGoogleDoc = async (handler: PullHandler) => {
let dataDoc = this.dataDoc;
let documentId = StrCast(dataDoc[GoogleRef]);
- let exportState: GoogleApiClientUtils.ReadResult = {};
+ let exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>;
if (documentId) {
- exportState = await GoogleApiClientUtils.Docs.read({ identifier: documentId });
+ exportState = await RichTextUtils.GoogleDocs.Import(documentId);
}
UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls);
}
- updateState = (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => {
+ updateState = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
let pullSuccess = false;
- if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) {
- const data = Cast(dataDoc.data, RichTextField);
- if (data instanceof RichTextField) {
- pullSuccess = true;
- dataDoc.data = new RichTextField(data[FromPlainText](exportState.body));
- setTimeout(() => {
- if (this._editorView) {
- let state = this._editorView.state;
- let end = state.doc.content.size - 1;
- this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end)));
- }
- }, 0);
- dataDoc.title = exportState.title;
- this.Document.customTitle = true;
- dataDoc.unchanged = true;
- }
+ if (exportState !== undefined) {
+ pullSuccess = true;
+ dataDoc.data = new RichTextField(JSON.stringify(exportState.state.toJSON()));
+ setTimeout(() => {
+ if (this._editorView) {
+ let state = this._editorView.state;
+ let end = state.doc.content.size - 1;
+ this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end)));
+ }
+ }, 0);
+ dataDoc.title = exportState.title;
+ this.Document.customTitle = true;
+ dataDoc.unchanged = true;
} else {
delete dataDoc[GoogleRef];
}
DocumentDecorations.Instance.startPullOutcome(pullSuccess);
}
- checkState = (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => {
- if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) {
- let data = Cast(dataDoc.data, RichTextField);
- if (data) {
- let storedPlainText = data[ToPlainText]() + "\n";
- let receivedPlainText = exportState.body;
- let storedTitle = dataDoc.title;
- let receivedTitle = exportState.title;
- let unchanged = storedPlainText === receivedPlainText && storedTitle === receivedTitle;
- dataDoc.unchanged = unchanged;
- DocumentDecorations.Instance.setPullState(unchanged);
- }
+ checkState = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
+ if (exportState && this._editorView) {
+ let equalContent = _.isEqual(this._editorView.state.doc, exportState.state.doc);
+ let equalTitles = dataDoc.title === exportState.title;
+ let unchanged = equalContent && equalTitles;
+ dataDoc.unchanged = unchanged;
+ DocumentDecorations.Instance.setPullState(unchanged);
}
}
diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss
index 00c069e1f..98cf7f92f 100644
--- a/src/client/views/nodes/ImageBox.scss
+++ b/src/client/views/nodes/ImageBox.scss
@@ -1,43 +1,59 @@
.imageBox-cont {
- padding: 0vw;
- position: relative;
- text-align: center;
- width: 100%;
- height: auto;
- max-width: 100%;
- max-height: 100%;
- pointer-events: none;
+ padding: 0vw;
+ position: relative;
+ text-align: center;
+ width: 100%;
+ height: auto;
+ max-width: 100%;
+ max-height: 100%;
+ pointer-events: none;
}
+
.imageBox-cont-interactive {
- pointer-events: all;
- width:100%;
- height:auto;
+ pointer-events: all;
+ width: 100%;
+ height: auto;
}
.imageBox-dot {
- position:absolute;
+ position: absolute;
bottom: 10;
left: 0;
border-radius: 10px;
- width:20px;
- height:20px;
- background:gray;
+ width: 20px;
+ height: 20px;
+ background: gray;
}
.imageBox-cont img {
height: auto;
- width:100%;
+ width: 100%;
}
+
.imageBox-cont-interactive img {
height: auto;
- width:100%;
+ width: 100%;
+}
+
+#google-photos {
+ transition: all 0.5s ease 0s;
+ width: 30px;
+ height: 30px;
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ border: 2px solid black;
+ border-radius: 50%;
+ padding: 3px;
+ background: white;
+ cursor: pointer;
}
.imageBox-button {
- padding: 0vw;
- border: none;
- width: 100%;
- height: 100%;
+ padding: 0vw;
+ border: none;
+ width: 100%;
+ height: 100%;
}
.imageBox-audioBackground {
@@ -49,6 +65,7 @@
border-radius: 25px;
background: white;
opacity: 0.3;
+
svg {
width: 90% !important;
height: 70%;
@@ -59,44 +76,47 @@
}
#cf {
- position:relative;
- width:100%;
- margin:0 auto;
- display:flex;
+ position: relative;
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
align-items: center;
- height:100%;
- overflow:hidden;
+ height: 100%;
+ overflow: hidden;
+
.imageBox-fadeBlocker {
- width:100%;
- height:100%;
+ width: 100%;
+ height: 100%;
background: black;
- display:flex;
+ display: flex;
flex-direction: row;
align-items: center;
z-index: 1;
+
.imageBox-fadeaway {
object-fit: contain;
- width:100%;
- height:100%;
+ width: 100%;
+ height: 100%;
}
}
- }
-
- #cf img {
- position:absolute;
- left:0;
- }
-
- .imageBox-fadeBlocker {
+}
+
+#cf img {
+ position: absolute;
+ left: 0;
+}
+
+.imageBox-fadeBlocker {
-webkit-transition: opacity 1s ease-in-out;
-moz-transition: opacity 1s ease-in-out;
-o-transition: opacity 1s ease-in-out;
transition: opacity 1s ease-in-out;
- }
- .imageBox-fadeBlocker:hover {
+}
+
+.imageBox-fadeBlocker:hover {
-webkit-transition: opacity 1s ease-in-out;
-moz-transition: opacity 1s ease-in-out;
-o-transition: opacity 1s ease-in-out;
transition: opacity 1s ease-in-out;
- opacity:0;
- } \ No newline at end of file
+ opacity: 0;
+} \ No newline at end of file
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 6fc94a140..b7aadcd3d 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -63,11 +63,10 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
private _lastTap: number = 0;
@observable private _isOpen: boolean = false;
private dropDisposer?: DragManager.DragDropDisposer;
-
+ @observable private hoverActive = false;
@computed get dataDoc() { return this.props.DataDoc && (BoolCast(this.props.Document.isTemplate) || BoolCast(this.props.DataDoc.isTemplate) || this.props.DataDoc.layout === this.props.Document) ? this.props.DataDoc : Doc.GetProto(this.props.Document); }
-
protected createDropTarget = (ele: HTMLDivElement) => {
if (this.dropDisposer) {
this.dropDisposer();
@@ -372,6 +371,20 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
this.recordAudioAnnotation();
}
+ considerGooglePhotosLink = () => {
+ const remoteUrl = StrCast(this.props.Document.googlePhotosUrl);
+ if (remoteUrl) {
+ return (
+ <img
+ id={"google-photos"}
+ src={"/assets/google_photos.png"}
+ style={{ opacity: this.hoverActive ? 1 : 0 }}
+ onClick={() => window.open(remoteUrl)}
+ />
+ );
+ }
+ return (null);
+ }
render() {
// let transform = this.props.ScreenToLocalTransform().inverse();
@@ -408,6 +421,8 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
return (
<div id={id} className={`imageBox-cont${interactive}`} style={{ background: "transparent" }}
onPointerDown={this.onPointerDown}
+ onPointerEnter={action(() => this.hoverActive = true)}
+ onPointerLeave={action(() => this.hoverActive = false)}
onDrop={this.onDrop} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}>
<div id="cf">
<img id={id}
@@ -434,6 +449,7 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
<FontAwesomeIcon className="imageBox-audioFont"
style={{ color: [DocListCast(this.extensionDoc.audioAnnotations).length ? "blue" : "gray", "green", "red"][this._audioState] }} icon={faFileAudio} size="sm" />
</div>
+ {this.considerGooglePhotosLink()}
{/* {this.lightbox(paths)} */}
<FaceRectangles document={this.extensionDoc} color={"#0000FF"} backgroundColor={"#0000FF"} />
</div>);