aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/apis/google_docs/GoogleApiClientUtils.ts224
-rw-r--r--src/client/views/DocumentDecorations.scss6
-rw-r--r--src/client/views/DocumentDecorations.tsx136
-rw-r--r--src/client/views/MainView.tsx6
-rw-r--r--src/client/views/collections/CollectionSubView.tsx14
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx148
6 files changed, 514 insertions, 20 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts
new file mode 100644
index 000000000..821c52270
--- /dev/null
+++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts
@@ -0,0 +1,224 @@
+import { docs_v1 } from "googleapis";
+import { PostToServer } from "../../../Utils";
+import { RouteStore } from "../../../server/RouteStore";
+import { Opt } from "../../../new_fields/Doc";
+import { isArray } from "util";
+
+export const Pulls = "googleDocsPullCount";
+export const Pushes = "googleDocsPushCount";
+
+export namespace GoogleApiClientUtils {
+
+ export namespace Docs {
+
+ export enum Actions {
+ Create = "create",
+ Retrieve = "retrieve",
+ Update = "update"
+ }
+
+ export enum WriteMode {
+ Insert,
+ Replace
+ }
+
+ export type DocumentId = string;
+ export type Reference = DocumentId | CreateOptions;
+ export type TextContent = string | string[];
+ export type IdHandler = (id: DocumentId) => any;
+
+ export type CreationResult = Opt<DocumentId>;
+ export type RetrievalResult = Opt<docs_v1.Schema$Document>;
+ export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>;
+ export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>;
+ export type ReadResult = { title?: string, body?: string };
+
+ export interface CreateOptions {
+ handler: IdHandler; // callback to process the documentId of the newly created Google Doc
+ title?: string; // if excluded, will use a default title annotated with the current date
+ }
+
+ export interface RetrieveOptions {
+ documentId: DocumentId;
+ }
+
+ export type ReadOptions = RetrieveOptions & { removeNewlines?: boolean };
+
+ 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 interface UpdateOptions {
+ documentId: DocumentId;
+ requests: docs_v1.Schema$Request[];
+ }
+
+ export namespace Utils {
+
+ export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => {
+ const fragments: string[] = [];
+ 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);
+ }
+ }
+ }
+ }
+ }
+ const text = fragments.join("");
+ return removeNewlines ? text.ReplaceAll("\n", "") : text;
+ };
+
+ export const endOf = (schema: docs_v1.Schema$Document): number | undefined => {
+ if (schema.body && schema.body.content) {
+ const paragraphs = schema.body.content.filter(el => el.paragraph);
+ if (paragraphs.length) {
+ const target = paragraphs[paragraphs.length - 1];
+ if (target.paragraph && target.paragraph.elements) {
+ length = target.paragraph.elements.length;
+ if (length) {
+ const final = target.paragraph.elements[length - 1];
+ return final.endIndex ? final.endIndex - 1 : undefined;
+ }
+ }
+ }
+ }
+ };
+
+ export const initialize = async (reference: Reference) => typeof reference === "string" ? reference : create(reference);
+
+ }
+
+ /**
+ * 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 + Actions.Create;
+ const parameters = {
+ requestBody: {
+ title: options.title || `Dash Export (${new Date().toDateString()})`
+ }
+ };
+ try {
+ const schema: docs_v1.Schema$Document = await PostToServer(path, parameters);
+ const generatedId = schema.documentId;
+ if (generatedId) {
+ options.handler(generatedId);
+ return generatedId;
+ }
+ } catch {
+ return undefined;
+ }
+ };
+
+ export const retrieve = async (options: RetrieveOptions): Promise<RetrievalResult> => {
+ const path = RouteStore.googleDocs + Actions.Retrieve;
+ try {
+ const schema: RetrievalResult = await PostToServer(path, options);
+ return schema;
+ } catch {
+ return undefined;
+ }
+ };
+
+ export const update = async (options: UpdateOptions): Promise<UpdateResult> => {
+ const path = RouteStore.googleDocs + Actions.Update;
+ const parameters = {
+ documentId: options.documentId,
+ requestBody: {
+ requests: options.requests
+ }
+ };
+ try {
+ const replies: UpdateResult = await PostToServer(path, parameters);
+ return replies;
+ } catch {
+ return undefined;
+ }
+ };
+
+ export const read = async (options: ReadOptions): Promise<ReadResult> => {
+ return retrieve(options).then(document => {
+ let result: ReadResult = {};
+ if (document) {
+ let title = document.title;
+ let body = Utils.extractText(document, options.removeNewlines);
+ result = { title, body };
+ }
+ return result;
+ });
+ };
+
+ export const readLines = async (options: ReadOptions): Promise<ReadLinesResult> => {
+ return retrieve(options).then(document => {
+ let result: ReadLinesResult = {};
+ if (document) {
+ let title = document.title;
+ let bodyLines = Utils.extractText(document).split("\n");
+ options.removeNewlines && (bodyLines = bodyLines.filter(line => line.length));
+ result = { title, bodyLines };
+ }
+ return result;
+ });
+ };
+
+ export const write = async (options: WriteOptions): Promise<UpdateResult> => {
+ const requests: docs_v1.Schema$Request[] = [];
+ 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({ documentId });
+ if (!schema || !(index = Utils.endOf(schema))) {
+ return undefined;
+ }
+ }
+ if (mode === WriteMode.Replace) {
+ index > 1 && requests.push({
+ deleteContentRange: {
+ range: {
+ startIndex: 1,
+ endIndex: index
+ }
+ }
+ });
+ index = 1;
+ }
+ const text = options.content;
+ text.length && requests.push({
+ insertText: {
+ text: isArray(text) ? text.join("\n") : text,
+ location: { index }
+ }
+ });
+ if (!requests.length) {
+ return undefined;
+ }
+ let replies: any = await update({ documentId, requests });
+ let errors = "errors";
+ if (errors in replies) {
+ console.log("Write operation failed:");
+ console.log(replies[errors].map((error: any) => error.message));
+ }
+ return replies;
+ };
+
+ }
+
+} \ No newline at end of file
diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss
index 3627edaae..ac8497bd0 100644
--- a/src/client/views/DocumentDecorations.scss
+++ b/src/client/views/DocumentDecorations.scss
@@ -264,4 +264,8 @@ $linkGap : 3px;
input {
margin-right: 10px;
}
-} \ 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); } } \ No newline at end of file
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index aae7f0d3f..891fd7847 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -1,5 +1,5 @@
-import { library } from '@fortawesome/fontawesome-svg-core';
-import { faLink, faTag, faTimes } from '@fortawesome/free-solid-svg-icons';
+import { library, IconProp } from '@fortawesome/fontawesome-svg-core';
+import { faLink, faTag, faTimes, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faStopCircle, faCloudUploadAlt, faSyncAlt, faShare } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { action, computed, observable, reaction, runInAction } from "mobx";
import { observer } from "mobx-react";
@@ -18,7 +18,7 @@ import { CollectionView } from "./collections/CollectionView";
import './DocumentDecorations.scss';
import { DocumentView, PositionDocument } from "./nodes/DocumentView";
import { FieldView } from "./nodes/FieldView";
-import { FormattedTextBox } from "./nodes/FormattedTextBox";
+import { FormattedTextBox, GoogleRef } from "./nodes/FormattedTextBox";
import { IconBox } from "./nodes/IconBox";
import { LinkMenu } from "./nodes/LinkMenu";
import { TemplateMenu } from "./TemplateMenu";
@@ -26,10 +26,10 @@ import { Template, Templates } from "./Templates";
import React = require("react");
import { RichTextField } from '../../new_fields/RichTextField';
import { LinkManager } from '../util/LinkManager';
-import { ObjectField } from '../../new_fields/ObjectField';
import { MetadataEntryMenu } from './MetadataEntryMenu';
import { ImageBox } from './nodes/ImageBox';
import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils';
+import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils';
const higflyout = require("@hig/flyout");
export const { anchorPoints } = higflyout;
export const Flyout = higflyout.default;
@@ -37,6 +37,16 @@ export const Flyout = higflyout.default;
library.add(faLink);
library.add(faTag);
library.add(faTimes);
+library.add(faArrowAltCircleDown);
+library.add(faArrowAltCircleUp);
+library.add(faStopCircle);
+library.add(faCheckCircle);
+library.add(faCloudUploadAlt);
+library.add(faSyncAlt);
+library.add(faShare);
+
+const cloud: IconProp = "cloud-upload-alt";
+const fetch: IconProp = "sync-alt";
@observer
export class DocumentDecorations extends React.Component<{}, { value: string }> {
@@ -68,6 +78,52 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
@observable public Interacting = false;
@observable private _isMoving = false;
+ @observable public pushIcon: IconProp = "arrow-alt-circle-up";
+ @observable public pullIcon: IconProp = "arrow-alt-circle-down";
+ @observable public pullColor: string = "white";
+ @observable public isAnimatingFetch = false;
+ @observable public openHover = false;
+ public pullColorAnimating = false;
+
+ private pullAnimating = false;
+ private pushAnimating = false;
+
+ public startPullOutcome = action((success: boolean) => {
+ if (!this.pullAnimating) {
+ this.pullAnimating = true;
+ this.pullIcon = success ? "check-circle" : "stop-circle";
+ setTimeout(() => runInAction(() => {
+ this.pullIcon = "arrow-alt-circle-down";
+ this.pullAnimating = false;
+ }), 1000);
+ }
+ });
+
+ public startPushOutcome = action((success: boolean) => {
+ if (!this.pushAnimating) {
+ this.pushAnimating = true;
+ this.pushIcon = success ? "check-circle" : "stop-circle";
+ setTimeout(() => runInAction(() => {
+ this.pushIcon = "arrow-alt-circle-up";
+ this.pushAnimating = false;
+ }), 1000);
+ }
+ });
+
+ public setPullState = action((unchanged: boolean) => {
+ this.isAnimatingFetch = false;
+ if (!this.pullColorAnimating) {
+ this.pullColorAnimating = true;
+ this.pullColor = unchanged ? "lawngreen" : "red";
+ setTimeout(this.clearPullColor, 1000);
+ }
+ });
+
+ private clearPullColor = action(() => {
+ this.pullColor = "white";
+ this.pullColorAnimating = false;
+ });
+
constructor(props: Readonly<{}>) {
super(props);
DocumentDecorations.Instance = this;
@@ -630,6 +686,76 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
);
}
+ private get targetDoc() {
+ return SelectionManager.SelectedDocuments()[0].props.Document;
+ }
+
+ considerGoogleDocsPush = () => {
+ let canPush = this.targetDoc.data && this.targetDoc.data instanceof RichTextField;
+ if (!canPush) return (null);
+ let published = Doc.GetProto(this.targetDoc)[GoogleRef] !== undefined;
+ if (!published) {
+ this.targetDoc.autoHeight = true;
+ }
+ let icon: IconProp = published ? (this.pushIcon as any) : cloud;
+ return (
+ <div className={"linkButtonWrapper"}>
+ <div title={`${published ? "Push" : "Publish"} to Google Docs`} className="linkButton-linker" onClick={() => {
+ DocumentDecorations.hasPushedHack = false;
+ this.targetDoc[Pushes] = NumCast(this.targetDoc[Pushes]) + 1;
+ }}>
+ <FontAwesomeIcon className="documentdecorations-icon" icon={icon} size={published ? "sm" : "xs"} />
+ </div>
+ </div>
+ );
+ }
+
+ considerGoogleDocsPull = () => {
+ let canPull = this.targetDoc.data && this.targetDoc.data instanceof RichTextField;
+ let dataDoc = Doc.GetProto(this.targetDoc);
+ if (!canPull || !dataDoc[GoogleRef]) return (null);
+ let icon = !dataDoc.unchanged ? (this.pullIcon as any) : fetch;
+ icon = this.openHover ? "share" : icon;
+ let animation = this.isAnimatingFetch ? "spin 0.5s linear infinite" : "none";
+ let title = `${!dataDoc.unchanged ? "Pull from" : "Fetch"} Google Docs`;
+ return (
+ <div className={"linkButtonWrapper"}>
+ <div
+ title={title}
+ className="linkButton-linker"
+ style={{
+ backgroundColor: this.pullColor,
+ transition: "0.2s ease all"
+ }}
+ onPointerEnter={e => e.ctrlKey && runInAction(() => this.openHover = true)}
+ onPointerLeave={() => runInAction(() => this.openHover = false)}
+ onClick={e => {
+ if (e.ctrlKey) {
+ window.open(`https://docs.google.com/document/d/${dataDoc[GoogleRef]}/edit`);
+ } else {
+ this.clearPullColor();
+ DocumentDecorations.hasPulledHack = false;
+ this.targetDoc[Pulls] = NumCast(this.targetDoc[Pulls]) + 1;
+ dataDoc.unchanged && runInAction(() => this.isAnimatingFetch = true);
+ }
+ }}>
+ <FontAwesomeIcon
+ style={{
+ WebkitAnimation: animation,
+ MozAnimation: animation
+ }}
+ className="documentdecorations-icon"
+ icon={icon}
+ size="sm"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ public static hasPushedHack = false;
+ public static hasPulledHack = false;
+
considerTooltip = () => {
let thisDoc = SelectionManager.SelectedDocuments()[0].props.Document;
let isTextDoc = thisDoc.data && thisDoc.data instanceof RichTextField;
@@ -782,6 +908,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
</div>
{this.metadataMenu}
{this.considerEmbed()}
+ {this.considerGoogleDocsPush()}
+ {this.considerGoogleDocsPull()}
{/* {this.considerTooltip()} */}
</div>
</div >
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index b27b91c12..f28844009 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 } from '../../new_fields/Types';
import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils';
import { RouteStore } from '../../server/RouteStore';
-import { emptyFunction, returnEmptyString, returnOne, returnTrue, Utils } from '../../Utils';
+import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString } from '../../Utils';
import { DocServer } from '../DocServer';
import { Docs } from '../documents/Documents';
import { ClientUtils } from '../util/ClientUtils';
@@ -546,8 +546,8 @@ export class MainView extends React.Component {
let next = () => PresBox.CurrentPresentation.next();
let back = () => PresBox.CurrentPresentation.back();
let startOrResetPres = () => PresBox.CurrentPresentation.startOrResetPres();
- let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document) });
- return !PresBox.CurrentPresentation || !PresBox.CurrentPresentation.presMode ? (null) : <PresModeMenu next={next} back={back} presStatus={PresBox.CurrentPresentation.presStatus} startOrResetPres={startOrResetPres} closePresMode={closePresMode} > </PresModeMenu>
+ let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document); });
+ return !PresBox.CurrentPresentation || !PresBox.CurrentPresentation.presMode ? (null) : <PresModeMenu next={next} back={back} presStatus={PresBox.CurrentPresentation.presStatus} startOrResetPres={startOrResetPres} closePresMode={closePresMode} > </PresModeMenu>;
}
render() {
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 818e76619..99e5ab7b3 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -17,7 +17,7 @@ import { DragManager } from "../../util/DragManager";
import { undoBatch, UndoManager } from "../../util/UndoManager";
import { DocComponent } from "../DocComponent";
import { FieldViewProps } from "../nodes/FieldView";
-import { FormattedTextBox } from "../nodes/FormattedTextBox";
+import { FormattedTextBox, GoogleRef } from "../nodes/FormattedTextBox";
import { CollectionPDFView } from "./CollectionPDFView";
import { CollectionVideoView } from "./CollectionVideoView";
import { CollectionView } from "./CollectionView";
@@ -207,7 +207,17 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) {
this.props.addDocument(Docs.Create.VideoDocument(url, { ...options, title: url, width: 400, height: 315, nativeWidth: 600, nativeHeight: 472.5 }));
return;
}
-
+ let matches: RegExpExecArray | null;
+ if ((matches = /(https:\/\/)?docs\.google\.com\/document\/d\/([^\\]+)\/edit/g.exec(text)) !== null) {
+ let newBox = Docs.Create.TextDocument({ ...options, width: 400, height: 200, title: "Awaiting title from Google Docs..." });
+ let proto = newBox.proto!;
+ proto.autoHeight = true;
+ proto[GoogleRef] = matches[2];
+ proto.data = "Please select this document and then click on its pull button to load its contents from from Google Docs...";
+ proto.backgroundColor = "#eeeeff";
+ this.props.addDocument(newBox);
+ return;
+ }
let batch = UndoManager.StartBatch("collection view drop");
let promises: Promise<void>[] = [];
// tslint:disable-next-line:prefer-for-of
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 0e347ca67..606e8edb0 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -1,6 +1,6 @@
import { library } from '@fortawesome/fontawesome-svg-core';
-import { faEdit, faSmile, faTextHeight } from '@fortawesome/free-solid-svg-icons';
-import { action, computed, IReactionDisposer, Lambda, observable, reaction } from "mobx";
+import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons';
+import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx";
import { observer } from "mobx-react";
import { baseKeymap } from "prosemirror-commands";
import { history } from "prosemirror-history";
@@ -12,9 +12,9 @@ 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 } from "../../../new_fields/RichTextField";
+import { RichTextField, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField";
+import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types";
import { createSchema, makeInterface } from "../../../new_fields/Schema";
-import { BoolCast, Cast, DateCast, NumCast, StrCast } from "../../../new_fields/Types";
import { Utils } from '../../../Utils';
import { DocServer } from "../../DocServer";
import { Docs, DocUtils } from '../../documents/Documents';
@@ -29,17 +29,21 @@ import { TooltipTextMenu } from "../../util/TooltipTextMenu";
import { undoBatch, UndoManager } from "../../util/UndoManager";
import { DocComponent } from "../DocComponent";
import { InkingControl } from "../InkingControl";
-import { MainOverlayTextBox } from '../MainOverlayTextBox';
import { FieldView, FieldViewProps } from "./FieldView";
import "./FormattedTextBox.scss";
import React = require("react");
+import { GoogleApiClientUtils, Pulls, Pushes } from '../../apis/google_docs/GoogleApiClientUtils';
+import { DocumentDecorations } from '../DocumentDecorations';
+import { MainOverlayTextBox } from '../MainOverlayTextBox';
library.add(faEdit);
-library.add(faSmile, faTextHeight);
+library.add(faSmile, faTextHeight, faUpload);
// FormattedTextBox: Displays an editable plain text node that maps to a specified Key of a Document
//
+export const Blank = `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`;
+
export interface FormattedTextBoxProps {
isOverlay?: boolean;
hideOnLeave?: boolean;
@@ -53,9 +57,13 @@ const richTextSchema = createSchema({
documentText: "string"
});
+export const GoogleRef = "googleDocId";
+
type RichTextDocument = makeInterface<[typeof richTextSchema]>;
const RichTextDocument = makeInterface(richTextSchema);
+type PullHandler = (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => void;
+
@observer
export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) {
public static LayoutString(fieldStr: string = "data") {
@@ -73,8 +81,11 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
private _textReactionDisposer: Opt<IReactionDisposer>;
private _heightReactionDisposer: Opt<IReactionDisposer>;
private _proxyReactionDisposer: Opt<IReactionDisposer>;
+ private pullReactionDisposer: Opt<IReactionDisposer>;
+ private pushReactionDisposer: Opt<IReactionDisposer>;
private dropDisposer?: DragManager.DragDropDisposer;
public get CurrentDiv(): HTMLDivElement { return this._ref.current!; }
+ private isGoogleDocsUpdate = false;
@observable _entered = false;
@observable public static InputBoxOverlay?: FormattedTextBox = undefined;
@@ -286,13 +297,49 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}, { fireImmediately: true });
}
+ this.pullFromGoogleDoc(this.checkState);
+ runInAction(() => DocumentDecorations.Instance.isAnimatingFetch = true);
+
this._reactionDisposer = reaction(
() => {
const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined;
- return field ? field.Data : `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`;
+ return field ? field.Data : Blank;
},
- field2 => this._editorView && !this._applyingChange &&
- this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field2)))
+ incomingValue => {
+ if (this._editorView && !this._applyingChange) {
+ let updatedState = JSON.parse(incomingValue);
+ this._editorView.updateState(EditorState.fromJSON(config, updatedState));
+ // manually sets cursor selection at the end of the text on focus
+ if (this.isGoogleDocsUpdate) {
+ this.isGoogleDocsUpdate = false;
+ let end = this._editorView.state.doc.content.size - 1;
+ updatedState.selection = { type: "text", anchor: end, head: end };
+ this._editorView.updateState(EditorState.fromJSON(config, updatedState));
+ }
+ this.tryUpdateHeight();
+ }
+ }
+ );
+
+ this.pullReactionDisposer = reaction(
+ () => this.props.Document[Pulls],
+ () => {
+ if (!DocumentDecorations.hasPulledHack) {
+ DocumentDecorations.hasPulledHack = true;
+ let unchanged = this.dataDoc.unchanged;
+ this.pullFromGoogleDoc(unchanged ? this.checkState : this.updateState);
+ }
+ }
+ );
+
+ this.pushReactionDisposer = reaction(
+ () => this.props.Document[Pushes],
+ () => {
+ if (!DocumentDecorations.hasPushedHack) {
+ DocumentDecorations.hasPushedHack = true;
+ this.pushToGoogleDoc();
+ }
+ }
);
this._heightReactionDisposer = reaction(
@@ -329,6 +376,83 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}, { fireImmediately: true });
}
+ pushToGoogleDoc = async () => {
+ this.pullFromGoogleDoc(async (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => {
+ let modes = GoogleApiClientUtils.Docs.WriteMode;
+ let mode = modes.Replace;
+ let reference: Opt<GoogleApiClientUtils.Docs.Reference> = Cast(this.dataDoc[GoogleRef], "string");
+ if (!reference) {
+ mode = modes.Insert;
+ reference = {
+ title: StrCast(this.dataDoc.title),
+ handler: id => this.dataDoc[GoogleRef] = id
+ };
+ }
+ let redo = async () => {
+ let data = Cast(this.dataDoc.data, RichTextField);
+ if (this._editorView && reference && data) {
+ let content = data[ToPlainText]();
+ let response = await GoogleApiClientUtils.Docs.write({ reference, content, mode });
+ let pushSuccess = response !== undefined && !("errors" in response);
+ dataDoc.unchanged = pushSuccess;
+ DocumentDecorations.Instance.startPushOutcome(pushSuccess);
+ }
+ };
+ let undo = () => {
+ let content = exportState.body;
+ if (reference && content) {
+ GoogleApiClientUtils.Docs.write({ reference, content, mode });
+ }
+ };
+ UndoManager.AddEvent({ undo, redo });
+ redo();
+ });
+ }
+
+ pullFromGoogleDoc = async (handler: PullHandler) => {
+ let dataDoc = this.dataDoc;
+ let documentId = StrCast(dataDoc[GoogleRef]);
+ let exportState: GoogleApiClientUtils.Docs.ReadResult = {};
+ if (documentId) {
+ exportState = await GoogleApiClientUtils.Docs.read({ documentId });
+ }
+ UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls);
+ }
+
+ updateState = (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => {
+ let pullSuccess = false;
+ if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) {
+ let data = Cast(dataDoc.data, RichTextField);
+ if (data) {
+ pullSuccess = true;
+ this.isGoogleDocsUpdate = true;
+ dataDoc.data = new RichTextField(data[FromPlainText](exportState.body));
+ dataDoc.title = exportState.title;
+ dataDoc.unchanged = true;
+ }
+ } else {
+ delete dataDoc[GoogleRef];
+ }
+ DocumentDecorations.Instance.startPullOutcome(pullSuccess);
+ this.tryUpdateHeight();
+ }
+
+ checkState = (exportState: GoogleApiClientUtils.Docs.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);
+ }
+ }
+ }
+
+
clipboardTextSerializer = (slice: Slice): string => {
let text = "", separated = true;
const from = 0, to = slice.content.size;
@@ -456,6 +580,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
this._reactionDisposer && this._reactionDisposer();
this._proxyReactionDisposer && this._proxyReactionDisposer();
this._textReactionDisposer && this._textReactionDisposer();
+ this.pushReactionDisposer && this.pushReactionDisposer();
+ this.pullReactionDisposer && this.pullReactionDisposer();
this._heightReactionDisposer && this._heightReactionDisposer();
this._searchReactionDisposer && this._searchReactionDisposer();
}
@@ -610,7 +736,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
@action
tryUpdateHeight() {
if (this.props.Document.autoHeight && this._ref.current!.scrollHeight !== 0) {
- console.log("DT = " + this.props.Document.title + " " + this._ref.current!.clientHeight + " " + this._ref.current!.scrollHeight + " " + this._ref.current!.textContent)
+ console.log("DT = " + this.props.Document.title + " " + this._ref.current!.clientHeight + " " + this._ref.current!.scrollHeight + " " + this._ref.current!.textContent);
let xf = this._ref.current!.getBoundingClientRect();
let scrBounds = this.props.ScreenToLocalTransform().transformBounds(0, 0, xf.width, this._ref.current!.textContent === "" ? 35 : this._ref.current!.scrollHeight);
let nh = this.props.Document.isTemplate ? 0 : NumCast(this.dataDoc.nativeHeight, 0);
@@ -639,6 +765,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
// });
// ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: subitems, icon: "text-height" });
}
+
+
render() {
let self = this;
let style = this.props.isOverlay ? "scroll" : "hidden";