diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/apis/google_docs/GoogleApiClientUtils.ts | 22 | ||||
-rw-r--r-- | src/client/northstar/utils/Extensions.ts | 2 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.scss | 31 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.tsx | 7 | ||||
-rw-r--r-- | src/client/views/Main.tsx | 20 | ||||
-rw-r--r-- | src/client/views/nodes/FormattedTextBox.tsx | 50 | ||||
-rw-r--r-- | src/new_fields/RichTextUtils.ts | 82 |
7 files changed, 159 insertions, 55 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index ae7c2f997..9bf3cae38 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -3,6 +3,8 @@ 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"; @@ -40,6 +42,11 @@ export namespace GoogleApiClientUtils { 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; + data: RichTextField; + } export interface CreateOptions { title?: string; // if excluded, will use a default title annotated with the current date @@ -87,13 +94,16 @@ export namespace GoogleApiClientUtils { export namespace Utils { - export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => { + export type ExtractResult = { text: string, runs: docs_v1.Schema$TextRun[] }; + export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): ExtractResult => { let runs = extractTextRuns(document); - const text = runs.map(run => run.content).join(""); - return removeNewlines ? text.ReplaceAll("\n", "") : text; + let text = runs.map(run => run.content).join(""); + text = text.substring(0, text.length - 1); + removeNewlines && text.ReplaceAll("\n", ""); + return { text, runs }; }; - export const extractTextRuns = (document: docs_v1.Schema$Document, filterEmpty = true) => { + const extractTextRuns = (document: docs_v1.Schema$Document, filterEmpty = true) => { const fragments: docs_v1.Schema$TextRun[] = []; if (document.body && document.body.content) { for (const element of document.body.content) { @@ -160,7 +170,7 @@ export namespace GoogleApiClientUtils { return retrieve({ documentId: options.documentId }).then(document => { if (document) { let title = document.title!; - let body = Utils.extractText(document, options.removeNewlines); + let body = Utils.extractText(document, options.removeNewlines).text; return { title, body }; } }); @@ -170,7 +180,7 @@ export namespace GoogleApiClientUtils { 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)); return { title, bodyLines }; } 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/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 579806a89..109228b54 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -82,6 +82,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; @@ -102,6 +103,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"; @@ -698,9 +700,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/Main.tsx b/src/client/views/Main.tsx index 0e687737d..271326f70 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/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index eefac2285..83011e590 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -60,16 +60,14 @@ export const GoogleRef = "googleDocId"; type RichTextDocument = makeInterface<[typeof richTextSchema]>; const RichTextDocument = makeInterface(richTextSchema); -type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.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 = () => { - return EditorState.create(FormattedTextBox.Instance._configuration); - } + public static blankState = () => EditorState.create(FormattedTextBox.Instance._configuration); public static Instance: FormattedTextBox; private _configuration: any; private _ref: React.RefObject<HTMLDivElement>; @@ -434,7 +432,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } pushToGoogleDoc = async () => { - this.pullFromGoogleDoc(async (exportState: Opt<GoogleApiClientUtils.Docs.ReadResult>, dataDoc: Doc) => { + this.pullFromGoogleDoc(async (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => { let modes = GoogleApiClientUtils.Docs.WriteMode; let mode = modes.Replace; let reference: Opt<GoogleApiClientUtils.Docs.Reference> = Cast(this.dataDoc[GoogleRef], "string"); @@ -457,7 +455,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe return; } let content: GoogleApiClientUtils.Docs.Content = { - text: exportState.body, + text: exportState.text, requests: [] }; if (reference && content) { @@ -472,42 +470,38 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe pullFromGoogleDoc = async (handler: PullHandler) => { let dataDoc = this.dataDoc; let documentId = StrCast(dataDoc[GoogleRef]); - let test = await RichTextUtils.GoogleDocs.Import(documentId); - let exportState: Opt<GoogleApiClientUtils.Docs.ReadResult>; + let exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>; if (documentId) { - exportState = await GoogleApiClientUtils.Docs.read({ documentId }); + exportState = await RichTextUtils.GoogleDocs.Import(documentId); } UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); } - updateState = (exportState: Opt<GoogleApiClientUtils.Docs.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 = RichTextUtils.Synthesize(exportState.body, data); - 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 = exportState.data; + 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: Opt<GoogleApiClientUtils.Docs.ReadResult>, dataDoc: Doc) => { + checkState = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => { if (exportState && this._editorView) { let storedPlainText = RichTextUtils.ToPlainText(this._editorView.state) + "\n"; - let receivedPlainText = exportState.body; + let receivedPlainText = exportState.text; let storedTitle = dataDoc.title; let receivedTitle = exportState.title; let unchanged = storedPlainText === receivedPlainText && storedTitle === receivedTitle; diff --git a/src/new_fields/RichTextUtils.ts b/src/new_fields/RichTextUtils.ts index 189819591..4ca51d311 100644 --- a/src/new_fields/RichTextUtils.ts +++ b/src/new_fields/RichTextUtils.ts @@ -1,5 +1,5 @@ import { EditorState } from "prosemirror-state"; -import { Node } from "prosemirror-model"; +import { Node, Fragment, Mark } from "prosemirror-model"; import { RichTextField } from "./RichTextField"; import { docs_v1 } from "googleapis"; import { GoogleApiClientUtils } from "../client/apis/google_docs/GoogleApiClientUtils"; @@ -95,27 +95,75 @@ export namespace RichTextUtils { }; }; - export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId) => { - let document = await GoogleApiClientUtils.Docs.retrieve({ documentId }); + export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => { + let Docs = GoogleApiClientUtils.Docs; + let document = await Docs.retrieve({ documentId }); + if (!document) { - return; + return undefined; } - // let title = document.title!; - let runs = GoogleApiClientUtils.Docs.Utils.extractTextRuns(document); + + let title = document.title!; + + let { text, runs } = Docs.Utils.extractText(document); + let segments = runs[Symbol.iterator](); + let state = FormattedTextBox.blankState(); + let breaks: number[] = []; let from = 0; - runs.map(run => { - let text = run.content!; - state = state.apply(state.tr.insertText(text, from)); - let to = from + text.length + 1; - let href: Opt<string>; - if (run.textStyle && run.textStyle.link && (href = run.textStyle.link.url)) { - let mark = state.schema.mark(state.schema.marks.link, { href }); - state = state.apply(state.tr.addMark(from, to, mark)); + let result = segments.next(); + while (!result.done) { + let run = result.value; + let fragment = run.content!; + if (fragment.hasNewline()) { + let trimmed = fragment.removeTrailingNewlines(); + if (fragment.length === 1) { + breaks.push(from); + } else { + let content = Fragment.from(state.schema.text(trimmed, styleToMarks(state.schema, run.textStyle))); + let node = state.schema.node("paragraph", null, content); + state = state.apply(state.tr.insert(from, node)); + from += node.nodeSize; + } + result = segments.next(); + } else { + let nodes: Node[] = []; + nodes.push(state.schema.text(fragment, styleToMarks(state.schema, run.textStyle))); + result = segments.next(); + while (!result.done) { + run = result.value; + fragment = run.content!; + let trimmed = fragment.removeTrailingNewlines(); + nodes.push(state.schema.text(trimmed, styleToMarks(state.schema, run.textStyle))); + if (fragment.hasNewline()) { + let node = state.schema.node("paragraph", null, Fragment.fromArray(nodes)); + state = state.apply(state.tr.insert(from, node)); + from += node.nodeSize; + result = segments.next(); + break; + } + result = segments.next(); + } + if (result.done) { + break; + } } - from = to; - }); - // return { title, body }; + } + breaks.forEach(position => state = state.apply(state.tr.insert(position, state.schema.node("paragraph")))); + let data = new RichTextField(JSON.stringify(state.toJSON())); + return { title, text, data }; + }; + + const styleToMarks = (schema: any, textStyle?: docs_v1.Schema$TextStyle) => { + if (!textStyle) { + return undefined; + } + let marks: Mark[] = []; + if (textStyle.link) { + let href = textStyle.link.url; + marks.push(schema.mark(schema.marks.link, { href })); + } + return marks; }; interface LinkInformation { |