diff options
Diffstat (limited to 'src')
80 files changed, 3005 insertions, 1921 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index d2a79f189..0d44ee8e0 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -248,7 +248,7 @@ export namespace GoogleApiClientUtils { return undefined; } requests.push(...options.content.requests); - const replies: any = await update({ documentId: documentId, requests }); + const replies: any = await update({ documentId, requests }); if ("errors" in replies) { console.log("Write operation failed:"); console.log(replies.errors.map((error: any) => error.message)); diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index df0a33821..1220e9923 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -9,11 +9,11 @@ export enum DocumentType { VID = "video", AUDIO = "audio", PDF = "pdf", - ICON = "icon", IMPORT = "import", LINK = "link", LINKDOC = "linkdoc", BUTTON = "button", + SLIDER = "slider", TEMPLATE = "template", EXTENSION = "extension", YOUTUBE = "youtube", diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 7233fbea5..86f68e883 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -16,9 +16,7 @@ import { action } from "mobx"; import { ColumnAttributeModel } from "../northstar/core/attribute/AttributeModel"; import { AttributeTransformationModel } from "../northstar/core/attribute/AttributeTransformationModel"; import { AggregateFunction } from "../northstar/model/idea/idea"; -import { MINIMIZED_ICON_SIZE } from "../views/globalCssVariables.scss"; -import { IconBox } from "../views/nodes/IconBox"; -import { OmitKeys, JSONUtils } from "../../Utils"; +import { OmitKeys, JSONUtils, Utils } from "../../Utils"; import { Field, Doc, Opt, DocListCastAsync, FieldResult, DocListCast } from "../../new_fields/Doc"; import { ImageField, VideoField, AudioField, PdfField, WebField, YoutubeField } from "../../new_fields/URLField"; import { HtmlField } from "../../new_fields/HtmlField"; @@ -37,6 +35,7 @@ import { DocumentManager } from "../util/DocumentManager"; import DirectoryImportBox from "../util/Import & Export/DirectoryImportBox"; import { Scripting } from "../util/Scripting"; import { ButtonBox } from "../views/nodes/ButtonBox"; +import { SliderBox } from "../views/nodes/SliderBox"; import { FontIconBox } from "../views/nodes/FontIconBox"; import { SchemaHeaderField } from "../../new_fields/SchemaHeaderField"; import { PresBox } from "../views/nodes/PresBox"; @@ -55,6 +54,8 @@ import { InkField } from "../../new_fields/InkField"; import { InkingControl } from "../views/InkingControl"; import { RichTextField } from "../../new_fields/RichTextField"; import { Networking } from "../Network"; +import { extname } from "path"; +import { MessageStore } from "../../server/Message"; const requestImageSize = require('../util/request-image-size'); const path = require('path'); @@ -131,6 +132,7 @@ export interface DocumentOptions { strokeWidth?: number; color?: string; treeViewHideTitle?: boolean; // whether to hide the title of a tree view + treeViewHideHeaderFields?: boolean; // whether to hide the drop down options for tree view items. treeViewOpen?: boolean; // whether this document is expanded in a tree view treeViewChecked?: ScriptField; // script to call when a tree view checkbox is checked isFacetFilter?: boolean; // whether document functions as a facet filter in a tree view @@ -213,10 +215,6 @@ export namespace Docs { layout: { view: PDFBox, dataField: data }, options: { curPage: 1 } }], - [DocumentType.ICON, { - layout: { view: IconBox, dataField: data }, - options: { _width: Number(MINIMIZED_ICON_SIZE), _height: Number(MINIMIZED_ICON_SIZE) }, - }], [DocumentType.IMPORT, { layout: { view: DirectoryImportBox, dataField: data }, options: { _height: 150 } @@ -231,6 +229,9 @@ export namespace Docs { [DocumentType.BUTTON, { layout: { view: ButtonBox, dataField: data }, }], + [DocumentType.SLIDER, { + layout: { view: SliderBox, dataField: data }, + }], [DocumentType.PRES, { layout: { view: PresBox, dataField: data }, options: {} @@ -345,8 +346,52 @@ export namespace Docs { */ export namespace Create { - export async function Buxton() { - console.log(await Networking.FetchFromServer("/newBuxton")); + export function Buxton() { + let responded = false; + const loading = new Doc; + loading.title = "Please wait for the import script..."; + const parent = TreeDocument([loading], { + title: "The Buxton Collection", + _width: 400, + _height: 400, + _LODdisable: true + }); + const parentProto = Doc.GetProto(parent); + const { _socket } = DocServer; + Utils.AddServerHandler(_socket, MessageStore.BuxtonDocumentResult, ({ device, errors }) => { + if (!responded) { + responded = true; + parentProto.data = new List<Doc>(); + } + if (device) { + const { __images } = device; + delete device.__images; + const { ImageDocument, StackingDocument } = Docs.Create; + const constructed = __images.map(({ url, nativeWidth, nativeHeight }) => ({ url: Utils.prepend(url), nativeWidth, nativeHeight })); + const deviceImages = constructed.map(({ url, nativeWidth, nativeHeight }, i) => ImageDocument(url, { + title: `image${i}.${extname(url)}`, + _nativeWidth: nativeWidth, + _nativeHeight: nativeHeight + })); + const doc = StackingDocument(deviceImages, { title: device.title, _LODdisable: true }); + const deviceProto = Doc.GetProto(doc); + deviceProto.hero = new ImageField(constructed[0].url); + Docs.Get.DocumentHierarchyFromJson(device, undefined, deviceProto); + Doc.AddDocToList(parentProto, "data", doc); + } else if (errors) { + console.log(errors); + } else { + alert("A Buxton document import was completely empty (??)"); + } + }); + Utils.AddServerHandler(_socket, MessageStore.BuxtonImportComplete, ({ deviceCount, errorCount }) => { + _socket.off(MessageStore.BuxtonDocumentResult.Message); + _socket.off(MessageStore.BuxtonImportComplete.Message); + alert(`Successfully imported ${deviceCount} device${deviceCount === 1 ? "" : "s"}, with ${errorCount} error${errorCount === 1 ? "" : "s"}, in ${(Date.now() - startTime) / 1000} seconds.`); + }); + const startTime = Date.now(); + Utils.Emit(_socket, MessageStore.BeginBuxtonImport, ""); + return parent; } Scripting.addGlobal(Buxton); @@ -476,10 +521,6 @@ export namespace Docs { return doc; } - export function IconDocument(icon: string, options: DocumentOptions = {}) { - return InstanceFromProto(Prototypes.get(DocumentType.ICON), new IconField(icon), options); - } - export function PdfDocument(url: string, options: DocumentOptions = {}) { return InstanceFromProto(Prototypes.get(DocumentType.PDF), new PdfField(new URL(url)), options); } @@ -558,6 +599,10 @@ export namespace Docs { export function MulticolumnDocument(documents: Array<Doc>, options: DocumentOptions) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _chromeStatus: "collapsed", schemaColumns: new List([new SchemaHeaderField("title", "#f1efeb")]), ...options, _viewType: CollectionViewType.Multicolumn }); } + export function MultirowDocument(documents: Array<Doc>, options: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _chromeStatus: "collapsed", schemaColumns: new List([new SchemaHeaderField("title", "#f1efeb")]), ...options, _viewType: CollectionViewType.Multirow }); + } + export function MasonryDocument(documents: Array<Doc>, options: DocumentOptions) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { _chromeStatus: "collapsed", schemaColumns: new List([new SchemaHeaderField("title", "#f1efeb")]), ...options, _viewType: CollectionViewType.Masonry }); @@ -567,6 +612,10 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.BUTTON), undefined, { ...(options || {}) }); } + export function SliderDocument(options?: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.SLIDER), undefined, { ...(options || {}) }); + } + export function FontIconDocument(options?: DocumentOptions) { return InstanceFromProto(Prototypes.get(DocumentType.FONTICON), undefined, { ...(options || {}) }); @@ -636,7 +685,7 @@ export namespace Docs { * or the result of any JSON.parse() call. * @param title an optional title to give to the highest parent document in the hierarchy */ - export function DocumentHierarchyFromJson(input: any, title?: string): Opt<Doc> { + export function DocumentHierarchyFromJson(input: any, title?: string, appendToTarget?: Doc): Opt<Doc> { if (input === undefined || input === null || ![...primitives, "object"].includes(typeof input)) { return undefined; } @@ -646,7 +695,7 @@ export namespace Docs { } let converted: Doc; if (typeof parsed === "object" && !(parsed instanceof Array)) { - converted = convertObject(parsed, title); + converted = convertObject(parsed, title, appendToTarget); } else { (converted = new Doc).json = toField(parsed); } @@ -661,12 +710,12 @@ export namespace Docs { * @returns the object mapped from JSON to field values, where each mapping * might involve arbitrary recursion (since toField might itself call convertObject) */ - const convertObject = (object: any, title?: string): Doc => { - const target = new Doc(); + const convertObject = (object: any, title?: string, target?: Doc): Doc => { + const resolved = target ?? new Doc; let result: Opt<Field>; - Object.keys(object).map(key => (result = toField(object[key], key)) && (target[key] = result)); - title && !target.title && (target.title = title); - return target; + Object.keys(object).map(key => (result = toField(object[key], key)) && (resolved[key] = result)); + title && !resolved.title && (resolved.title = title); + return resolved; }; /** @@ -713,9 +762,6 @@ export namespace Docs { } else if (field instanceof PdfField) { created = Docs.Create.PdfDocument((field).url.href, resolved); layout = PDFBox.LayoutString; - } else if (field instanceof IconField) { - created = Docs.Create.IconDocument((field).icon, resolved); - layout = IconBox.LayoutString; } else if (field instanceof AudioField) { created = Docs.Create.AudioDocument((field).url.href, resolved); layout = AudioBox.LayoutString; diff --git a/src/client/util/DropConverter.ts b/src/client/util/DropConverter.ts index d0f1d86cb..3c7caa60b 100644 --- a/src/client/util/DropConverter.ts +++ b/src/client/util/DropConverter.ts @@ -8,7 +8,7 @@ import { ScriptField, ComputedField } from "../../new_fields/ScriptField"; import { RichTextField } from "../../new_fields/RichTextField"; import { ImageField } from "../../new_fields/URLField"; -export function makeTemplate(doc: Doc): boolean { +export function makeTemplate(doc: Doc, first: boolean = true): boolean { const layoutDoc = doc.layout instanceof Doc && doc.layout.isTemplateForField ? doc.layout : doc; const layout = StrCast(layoutDoc.layout).match(/fieldKey={'[^']*'}/)![0]; const fieldKey = layout.replace("fieldKey={'", "").replace(/'}$/, ""); @@ -18,9 +18,12 @@ export function makeTemplate(doc: Doc): boolean { if (!StrCast(d.title).startsWith("-")) { any = Doc.MakeMetadataFieldTemplate(d, Doc.GetProto(layoutDoc)) || any; } else if (d.type === DocumentType.COL || d.data instanceof RichTextField) { - any = makeTemplate(d) || any; + any = makeTemplate(d, false) || any; } }); + if (!docs.length && first) { + any = Doc.MakeMetadataFieldTemplate(doc, Doc.GetProto(layoutDoc)) || any; + } if (layoutDoc[fieldKey] instanceof RichTextField || layoutDoc[fieldKey] instanceof ImageField) { if (!StrCast(layoutDoc.title).startsWith("-")) { any = Doc.MakeMetadataFieldTemplate(layoutDoc, Doc.GetProto(layoutDoc)); diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx index 071015193..d04f56e57 100644 --- a/src/client/util/Import & Export/DirectoryImportBox.tsx +++ b/src/client/util/Import & Export/DirectoryImportBox.tsx @@ -120,8 +120,8 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> runInAction(() => this.completed += batch.length); }); - await Promise.all(uploads.map(async ({ name, type, clientAccessPath, exifData }) => { - const path = Utils.prepend(clientAccessPath); + await Promise.all(uploads.map(async ({ name, type, accessPaths, exifData }) => { + const path = Utils.prepend(accessPaths.agnostic.client); const document = await Docs.Get.DocumentFromType(type, path, { _width: 300, title: name }); const { data, error } = exifData; if (document) { diff --git a/src/client/util/ProsemirrorExampleTransfer.ts b/src/client/util/ProsemirrorExampleTransfer.ts index da3815181..81cf54d4c 100644 --- a/src/client/util/ProsemirrorExampleTransfer.ts +++ b/src/client/util/ProsemirrorExampleTransfer.ts @@ -175,13 +175,16 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, mapKeys?: }); const path = (state.doc.resolve(state.selection.from - 1) as any).path; const spaceSeparator = path[path.length - 3].childCount > 1 ? 0 : -1; - const textsel = TextSelection.create(state.doc, range!.end - path[path.length - 3].lastChild.nodeSize + spaceSeparator, range!.end); - const text = range ? state.doc.textBetween(textsel.from, textsel.to) : ""; - let whitespace = text.length - 1; - for (; whitespace >= 0 && text[whitespace] !== " "; whitespace--) { } - if (text.endsWith(":")) { - dispatch(state.tr.addMark(textsel.from + whitespace + 1, textsel.to, schema.marks.metadata.create() as any). - addMark(textsel.from + whitespace + 1, textsel.to - 2, schema.marks.metadataKey.create() as any)); + const anchor = range!.end - path[path.length - 3].lastChild.nodeSize + spaceSeparator; + if (anchor >= 0) { + const textsel = TextSelection.create(state.doc, anchor, range!.end); + const text = range ? state.doc.textBetween(textsel.from, textsel.to) : ""; + let whitespace = text.length - 1; + for (; whitespace >= 0 && text[whitespace] !== " "; whitespace--) { } + if (text.endsWith(":")) { + dispatch(state.tr.addMark(textsel.from + whitespace + 1, textsel.to, schema.marks.metadata.create() as any). + addMark(textsel.from + whitespace + 1, textsel.to - 2, schema.marks.metadataKey.create() as any)); + } } return false; }); diff --git a/src/client/util/RichTextRules.ts b/src/client/util/RichTextRules.ts index 3b30b5b3f..6b8762a5e 100644 --- a/src/client/util/RichTextRules.ts +++ b/src/client/util/RichTextRules.ts @@ -1,295 +1,303 @@ -import { textblockTypeInputRule, smartQuotes, emDash, ellipsis, InputRule } from "prosemirror-inputrules"; -import { schema } from "./RichTextSchema"; -import { wrappingInputRule } from "./prosemirrorPatches"; +import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from "prosemirror-inputrules"; import { NodeSelection, TextSelection } from "prosemirror-state"; -import { StrCast, Cast, NumCast } from "../../new_fields/Types"; -import { Doc, DataSym } from "../../new_fields/Doc"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { Docs, DocUtils } from "../documents/Documents"; +import { DataSym, Doc } from "../../new_fields/Doc"; import { Id } from "../../new_fields/FieldSymbols"; -import { DocServer } from "../DocServer"; +import { ComputedField } from "../../new_fields/ScriptField"; +import { Cast, NumCast } from "../../new_fields/Types"; import { returnFalse, Utils } from "../../Utils"; +import { DocServer } from "../DocServer"; +import { Docs, DocUtils } from "../documents/Documents"; +import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; +import { wrappingInputRule } from "./prosemirrorPatches"; import RichTextMenu from "./RichTextMenu"; -import { RichTextField } from "../../new_fields/RichTextField"; -import { ComputedField } from "../../new_fields/ScriptField"; +import { schema } from "./RichTextSchema"; -export const inpRules = { - rules: [ - ...smartQuotes, - ellipsis, - emDash, +export class RichTextRules { + public Document: Doc; + public TextBox: FormattedTextBox; + public EnteringStyle: boolean = false; + constructor(doc: Doc, textBox: FormattedTextBox) { + this.Document = doc; + this.TextBox = textBox; + } + public inpRules = { + rules: [ + ...smartQuotes, + ellipsis, + emDash, - // > blockquote - wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), + // > blockquote + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), - // 1. ordered list - wrappingInputRule( - /^1\.\s$/, - schema.nodes.ordered_list, - () => { - return ({ mapStyle: "decimal", bulletStyle: 1 }); - }, - (match: any, node: any) => { - return node.childCount + node.attrs.order === +match[1]; - }, - (type: any) => ({ type: type, attrs: { mapStyle: "decimal", bulletStyle: 1 } }) - ), - // a. alphabbetical list - wrappingInputRule( - /^a\.\s$/, - schema.nodes.ordered_list, - // match => { - () => { - return ({ mapStyle: "alpha", bulletStyle: 1 }); - // return ({ order: +match[1] }) - }, - (match: any, node: any) => { - return node.childCount + node.attrs.order === +match[1]; - }, - (type: any) => ({ type: type, attrs: { mapStyle: "alpha", bulletStyle: 1 } }) - ), + // 1. ordered list + wrappingInputRule( + /^1\.\s$/, + schema.nodes.ordered_list, + () => { + return ({ mapStyle: "decimal", bulletStyle: 1 }); + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "decimal", bulletStyle: 1 } }) + ), + // a. alphabbetical list + wrappingInputRule( + /^a\.\s$/, + schema.nodes.ordered_list, + // match => { + () => { + return ({ mapStyle: "alpha", bulletStyle: 1 }); + // return ({ order: +match[1] }) + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "alpha", bulletStyle: 1 } }) + ), - // * bullet list - wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), + // * bullet list + wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), - // ``` code block - textblockTypeInputRule(/^```$/, schema.nodes.code_block), + // ``` code block + textblockTypeInputRule(/^```$/, schema.nodes.code_block), - // # heading - textblockTypeInputRule( - new RegExp(/^(#{1,6})\s$/), - schema.nodes.heading, - match => { - return ({ level: match[1].length }); - } - ), + // # heading + textblockTypeInputRule( + new RegExp(/^(#{1,6})\s$/), + schema.nodes.heading, + match => { + return ({ level: match[1].length }); + } + ), - // set the font size using #<font-size> - new InputRule( - new RegExp(/%([0-9]+)\s$/), - (state, match, start, end) => { - const size = Number(match[1]); - return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); - }), + // set the font size using #<font-size> + new InputRule( + new RegExp(/%([0-9]+)\s$/), + (state, match, start, end) => { + const size = Number(match[1]); + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); + }), - // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document [[ <fieldKey> : <Doc>]] // [[:Doc]] => hyperlink [[fieldKey]] => show field [[fieldKey:Doc]] => show field of doc - new InputRule( - new RegExp(/\[\[([a-zA-Z_ \-0-9]*)(:[a-zA-Z_ \-0-9]+)?\]\]$/), - (state, match, start, end) => { - const fieldKey = match[1]; - const docid = match[2]?.substring(1); - if (!fieldKey) { - if (docid) { - DocServer.GetRefField(docid).then(docx => { - const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); - DocUtils.Publish(target, docid, returnFalse, returnFalse); - DocUtils.MakeLink({ doc: (schema as any).Document }, { doc: target }, "portal link", ""); - }); - const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); - return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link); + // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document [[ <fieldKey> : <Doc>]] // [[:Doc]] => hyperlink [[fieldKey]] => show field [[fieldKey:Doc]] => show field of doc + new InputRule( + new RegExp(/\[\[([a-zA-Z_ \-0-9]*)(:[a-zA-Z_ \-0-9]+)?\]\]$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docid = match[2]?.substring(1); + if (!fieldKey) { + if (docid) { + DocServer.GetRefField(docid).then(docx => { + const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); + DocUtils.Publish(target, docid, returnFalse, returnFalse); + DocUtils.MakeLink({ doc: this.Document }, { doc: target }, "portal link", ""); + }); + const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); + return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link); + } + return state.tr; } - return state.tr; - } - const fieldView = state.schema.nodes.dashField.create({ fieldKey, docid }); - return state.tr.deleteRange(start, end).insert(start, fieldView); - }), - // create an inline view of a document {{ <layoutKey> : <Doc> }} // {{:Doc}} => show default view of document {{<layout>}} => show layout for this doc {{<layout> : Doc}} => show layout for another doc - new InputRule( - new RegExp(/\{\{([a-zA-Z_ \-0-9]*)(:[a-zA-Z_ \-0-9]+)?\}\}$/), - (state, match, start, end) => { - const fieldKey = match[1]; - const docid = match[2]?.substring(1); - if (!fieldKey && !docid) return state.tr; - docid && DocServer.GetRefField(docid).then(docx => { - if (!(docx instanceof Doc && docx)) { - const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); - DocUtils.Publish(docx, docid, returnFalse, returnFalse); - } - }); - const node = (state.doc.resolve(start) as any).nodeAfter; - const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 75, title: "dashDoc", docid, fieldKey, float: "right", alias: Utils.GenerateGuid() }); - const sm = state.storedMarks || undefined; - return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; - }), - new InputRule( - new RegExp(/##$/), - (state, match, start, end) => { - const schemaDoc = Doc.GetDataDoc((schema as any).Document); - const textDoc = Doc.GetProto(Cast(schemaDoc[DataSym], Doc, null)!); - const numInlines = NumCast(textDoc.inlineTextCount); - textDoc.inlineTextCount = numInlines + 1; - const inlineFieldKey = "inline" + numInlines; // which field on the text document this annotation will write to - const inlineLayoutKey = "layout_" + inlineFieldKey; // the field holding the layout string that will render the inline annotation - const textDocInline = Docs.Create.TextDocument("", { layoutKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _autoHeight: true, fontSize: 9, title: "inline comment" }); - textDocInline.title = inlineFieldKey; // give the annotation its own title - textDocInline.customTitle = true; // And make sure that it's 'custom' so that editing text doesn't change the title of the containing doc - textDocInline.isTemplateForField = inlineFieldKey; // this is needed in case the containing text doc is converted to a template at some point - textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] - textDocInline._textContext = ComputedField.MakeFunction(`copyField(this.${inlineFieldKey})`, { this: Doc.name }); - textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text - textDoc[inlineFieldKey] = ""; // set a default value for the annotation - const node = (state.doc.resolve(start) as any).nodeAfter; - const newNode = schema.nodes.dashComment.create({ docid: textDocInline[Id] }); - const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: textDocInline[Id], float: "right" }); - const sm = state.storedMarks || undefined; - const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : - state.tr; - return replaced; - }), - // stop using active style - new InputRule( - new RegExp(/%%$/), - (state, match, start, end) => { - const tr = state.tr.deleteRange(start, end); - const marks = state.tr.selection.$anchor.nodeBefore?.marks; - return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; - }), + const fieldView = state.schema.nodes.dashField.create({ fieldKey, docid }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + // create an inline view of a document {{ <layoutKey> : <Doc> }} // {{:Doc}} => show default view of document {{<layout>}} => show layout for this doc {{<layout> : Doc}} => show layout for another doc + new InputRule( + new RegExp(/\{\{([a-zA-Z_ \-0-9]*)(:[a-zA-Z_ \-0-9]+)?\}\}$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docid = match[2]?.substring(1); + if (!fieldKey && !docid) return state.tr; + docid && DocServer.GetRefField(docid).then(docx => { + if (!(docx instanceof Doc && docx)) { + const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); + DocUtils.Publish(docx, docid, returnFalse, returnFalse); + } + }); + const node = (state.doc.resolve(start) as any).nodeAfter; + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 75, title: "dashDoc", docid, fieldKey, float: "right", alias: Utils.GenerateGuid() }); + const sm = state.storedMarks || undefined; + return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; + }), + new InputRule( + new RegExp(/##$/), + (state, match, start, end) => { + const schemaDoc = Doc.GetDataDoc(this.Document); + const textDoc = Doc.GetProto(Cast(schemaDoc[DataSym], Doc, null)!); + const numInlines = NumCast(textDoc.inlineTextCount); + textDoc.inlineTextCount = numInlines + 1; + const inlineFieldKey = "inline" + numInlines; // which field on the text document this annotation will write to + const inlineLayoutKey = "layout_" + inlineFieldKey; // the field holding the layout string that will render the inline annotation + const textDocInline = Docs.Create.TextDocument("", { layoutKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _autoHeight: true, fontSize: 9, title: "inline comment" }); + textDocInline.title = inlineFieldKey; // give the annotation its own title + textDocInline.customTitle = true; // And make sure that it's 'custom' so that editing text doesn't change the title of the containing doc + textDocInline.isTemplateForField = inlineFieldKey; // this is needed in case the containing text doc is converted to a template at some point + textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] + textDocInline._textContext = ComputedField.MakeFunction(`copyField(this.${inlineFieldKey})`, { this: Doc.name }); + textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text + textDoc[inlineFieldKey] = ""; // set a default value for the annotation + const node = (state.doc.resolve(start) as any).nodeAfter; + const newNode = schema.nodes.dashComment.create({ docid: textDocInline[Id] }); + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: textDocInline[Id], float: "right" }); + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced; + }), + // stop using active style + new InputRule( + new RegExp(/%%$/), + (state, match, start, end) => { + const tr = state.tr.deleteRange(start, end); + const marks = state.tr.selection.$anchor.nodeBefore?.marks; + return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; + }), - // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) - new InputRule( - new RegExp(/[ti!x]$/), - (state, match, start, end) => { - if (state.selection.to === state.selection.from || !(schema as any).EnteringStyle) return null; - const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; - const node = (state.doc.resolve(start) as any).nodeAfter; - if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); - return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; - }), + // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/[ti!x]$/), + (state, match, start, end) => { + if (state.selection.to === state.selection.from || !this.EnteringStyle) return null; + const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; + const node = (state.doc.resolve(start) as any).nodeAfter; + if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + }), - // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) - new InputRule( - new RegExp(/(%d|d)$/), - (state, match, start, end) => { - if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; - const pos = (state.doc.resolve(start) as any); - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { - const node = pos.node(depth); - if (node.type === schema.nodes.paragraph) { - const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); - const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); - return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%d|d)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } } - } - return null; - }), + return null; + }), - // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) - new InputRule( - new RegExp(/(%h|h)$/), - (state, match, start, end) => { - if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; - const pos = (state.doc.resolve(start) as any); - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { - const node = pos.node(depth); - if (node.type === schema.nodes.paragraph) { - const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); - const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); - return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%h|h)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } } - } - return null; - }), - // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) - new InputRule( - new RegExp(/(%q|q)$/), - (state, match, start, end) => { - if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; - const pos = (state.doc.resolve(start) as any); - if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { - const node = state.selection.node; - return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); - } - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { - const node = pos.node(depth); - if (node.type === schema.nodes.paragraph) { - const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); - const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); - return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + return null; + }), + // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%q|q)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { + const node = state.selection.node; + return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); } - } - return null; - }), + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), - // center justify text - new InputRule( - new RegExp(/%\^$/), - (state, match, start, end) => { - const node = (state.doc.resolve(start) as any).nodeAfter; - const sm = state.storedMarks || undefined; - const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "center" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : - state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); - }), - // left justify text - new InputRule( - new RegExp(/%\[$/), - (state, match, start, end) => { - const node = (state.doc.resolve(start) as any).nodeAfter; - const sm = state.storedMarks || undefined; - const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "left" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : - state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); - }), - // right justify text - new InputRule( - new RegExp(/%\]$/), - (state, match, start, end) => { - const node = (state.doc.resolve(start) as any).nodeAfter; - const sm = state.storedMarks || undefined; - const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "right" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : - state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); - }), - new InputRule( - new RegExp(/%\(/), - (state, match, start, end) => { - const node = (state.doc.resolve(start) as any).nodeAfter; - const sm = state.storedMarks || []; - const mark = state.schema.marks.summarizeInclusive.create(); - sm.push(mark); - const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); - const content = selected.selection.content(); - const replaced = node ? selected.replaceRangeWith(start, end, - schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : - state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); - }), - new InputRule( - new RegExp(/%\)/), - (state, match, start, end) => { - return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); - }), - new InputRule( - new RegExp(/%f$/), - (state, match, start, end) => { - const newNode = schema.nodes.footnote.create({}); - const tr = state.tr; - tr.deleteRange(start, end).replaceSelectionWith(newNode); // replace insertion with a footnote. - return tr.setSelection(new NodeSelection( // select the footnote node to open its display - tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) - tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); - }), + // center justify text + new InputRule( + new RegExp(/%\^$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "center" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // left justify text + new InputRule( + new RegExp(/%\[$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "left" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // right justify text + new InputRule( + new RegExp(/%\]$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "right" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + new InputRule( + new RegExp(/%\(/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || []; + const mark = state.schema.marks.summarizeInclusive.create(); + sm.push(mark); + const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); + const content = selected.selection.content(); + const replaced = node ? selected.replaceRangeWith(start, end, + schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); + }), + new InputRule( + new RegExp(/%\)/), + (state, match, start, end) => { + return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); + }), + new InputRule( + new RegExp(/%f$/), + (state, match, start, end) => { + const newNode = schema.nodes.footnote.create({}); + const tr = state.tr; + tr.deleteRange(start, end).replaceSelectionWith(newNode); // replace insertion with a footnote. + return tr.setSelection(new NodeSelection( // select the footnote node to open its display + tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); + }), - // activate a style by name using prefix '%' - new InputRule( - new RegExp(/%[a-z]+$/), - (state, match, start, end) => { - const color = match[0].substring(1, match[0].length); - const marks = RichTextMenu.Instance._brushMap.get(color); - if (marks) { - const tr = state.tr.deleteRange(start, end); - return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; - } - const isValidColor = (strColor: string) => { - const s = new Option().style; - s.color = strColor; - return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned - }; - if (isValidColor(color)) { - return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); - } - return null; - }), - ] -}; + // activate a style by name using prefix '%' + new InputRule( + new RegExp(/%[a-z]+$/), + (state, match, start, end) => { + const color = match[0].substring(1, match[0].length); + const marks = RichTextMenu.Instance._brushMap.get(color); + if (marks) { + const tr = state.tr.deleteRange(start, end); + return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; + } + const isValidColor = (strColor: string) => { + const s = new Option().style; + s.color = strColor; + return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned + }; + if (isValidColor(color)) { + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); + } + return null; + }), + ] + } +} diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx index c07ebe2ed..3cf0561dc 100644 --- a/src/client/util/RichTextSchema.tsx +++ b/src/client/util/RichTextSchema.tsx @@ -1,28 +1,26 @@ -import { reaction, IReactionDisposer, observable, runInAction } from "mobx"; +import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { baseKeymap, toggleMark } from "prosemirror-commands"; import { redo, undo } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; -import { EditorState, NodeSelection, TextSelection, Plugin } from "prosemirror-state"; +import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; import { StepMap } from "prosemirror-transform"; import { EditorView } from "prosemirror-view"; import * as ReactDOM from 'react-dom'; -import { Doc, WidthSym, HeightSym, DataSym, Field } from "../../new_fields/Doc"; +import { Doc, Field, HeightSym, WidthSym } from "../../new_fields/Doc"; +import { Id } from "../../new_fields/FieldSymbols"; +import { ObjectField } from "../../new_fields/ObjectField"; +import { ComputedField } from "../../new_fields/ScriptField"; +import { BoolCast, NumCast, StrCast } from "../../new_fields/Types"; import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils } from "../../Utils"; import { DocServer } from "../DocServer"; import { DocumentView } from "../views/nodes/DocumentView"; +import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; import { DocumentManager } from "./DocumentManager"; import ParagraphNodeSpec from "./ParagraphNodeSpec"; import { Transform } from "./Transform"; import React = require("react"); -import { BoolCast, NumCast, StrCast } from "../../new_fields/Types"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { ObjectField } from "../../new_fields/ObjectField"; -import { ComputedField } from "../../new_fields/ScriptField"; -import { observer } from "mobx-react"; -import { Id } from "../../new_fields/FieldSymbols"; -import { OnChangeHandler } from "react-color/lib/components/common/ColorWrap"; const blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], preDOM: DOMOutputSpecArray = ["pre", ["code", 0]], brDOM: DOMOutputSpecArray = ["br"], ulDOM: DOMOutputSpecArray = ["ul", 0]; @@ -135,6 +133,7 @@ export const nodes: { [index: string]: NodeSpec } = { inline: true, attrs: { src: {}, + agnostic: { default: null }, width: { default: 100 }, alt: { default: null }, title: { default: null }, @@ -768,7 +767,7 @@ export class DashDocView { alias && DocServer.GetRefField(docid).then(async dashDocBase => { if (dashDocBase instanceof Doc) { const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); - aliasedDoc.layoutKey = "layout" + (node.attrs.fieldKey ? "_" + node.attrs.fieldKey : ""); + aliasedDoc.layoutKey = node.attrs.fieldKey === "layout" ? "layout" : "layout" + (node.attrs.fieldKey ? "_" + node.attrs.fieldKey : ""); self.doRender(aliasedDoc, removeDoc, node, view, getPos); } }); @@ -885,7 +884,7 @@ export class DashFieldView { e.stopPropagation(); if ((e.key === "a" && e.ctrlKey) || (e.key === "a" && e.metaKey)) { if (window.getSelection) { - var range = document.createRange(); + const range = document.createRange(); range.selectNodeContents(self._fieldSpan); window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range); diff --git a/src/client/util/SettingsManager.scss b/src/client/util/SettingsManager.scss index 7a0fb0741..6513cb223 100644 --- a/src/client/util/SettingsManager.scss +++ b/src/client/util/SettingsManager.scss @@ -1,6 +1,6 @@ @import "../views/globalCssVariables"; -.dialogue-box { +.settings-interface { background-color: whitesmoke !important; color: grey; width: 450px; diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index ce48e1215..0bf944f22 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -1,4 +1,4 @@ -import { Doc } from '../../new_fields/Doc'; +import { Doc, Opt } from '../../new_fields/Doc'; import { Touchable } from './Touchable'; import { computed, action, observable } from 'mobx'; import { Cast } from '../../new_fields/Types'; @@ -11,12 +11,13 @@ import { PositionDocument } from '../../new_fields/documentSchemas'; /// DocComponent returns a generic React base class used by views that don't have any data extensions (e.g.,CollectionFreeFormDocumentView, DocumentView, ButtonBox) interface DocComponentProps { Document: Doc; + LayoutDoc?: () => Opt<Doc>; } export function DocComponent<P extends DocComponentProps, T>(schemaCtor: (doc: Doc) => T) { class Component extends Touchable<P> { //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then @computed get Document(): T { return schemaCtor(this.props.Document); } - @computed get layoutDoc() { return PositionDocument(Doc.Layout(this.props.Document)); } + @computed get layoutDoc() { return PositionDocument(Doc.Layout(this.props.Document, this.props.LayoutDoc?.())); } } return Component; } diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index d6029dbe5..ee850922a 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -24,6 +24,7 @@ import React = require("react"); import { DragManager } from '../util/DragManager'; import { MetadataEntryMenu } from './MetadataEntryMenu'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; +import GoogleAuthenticationManager from '../apis/GoogleAuthenticationManager'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -162,7 +163,8 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | title={`${published ? "Push" : "Publish"} to Google Docs`} className="documentButtonBar-linker" style={{ animation }} - onClick={() => { + onClick={async () => { + await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); !published && runInAction(() => this.isAnimatingPulse = true); DocumentButtonBar.hasPushedHack = false; targetDoc[Pushes] = NumCast(targetDoc[Pushes]) + 1; diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 32346165d..455e53a79 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -98,6 +98,18 @@ $linkGap : 3px; pointer-events: auto; overflow: hidden; text-align: center; + display:inline-block; + } + .publishBox { + width: 20px; + height: 22px; + grid-column-start: 3; + grid-column-end: 4; + pointer-events: all; + background: darkgray; + display: inline-block; + position: absolute; + right: 0; } } @@ -153,11 +165,12 @@ $linkGap : 3px; .link-button-container { margin-top: $linkGap; - grid-column: 1/4; width: max-content; height: auto; display: flex; flex-direction: row; + z-index: 5; + position: absolute; } .linkButtonWrapper { diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 4ec1659cc..65c02591c 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,11 +1,10 @@ import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faTextHeight, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, reaction } from "mobx"; import { observer } from "mobx-react"; import { Doc } from "../../new_fields/Doc"; import { PositionDocument } from '../../new_fields/documentSchemas'; -import { ObjectField } from '../../new_fields/ObjectField'; import { ScriptField } from '../../new_fields/ScriptField'; import { Cast, StrCast } from "../../new_fields/Types"; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; @@ -18,12 +17,17 @@ import { undoBatch, UndoManager } from "../util/UndoManager"; import { DocumentButtonBar } from './DocumentButtonBar'; import './DocumentDecorations.scss'; import { DocumentView } from "./nodes/DocumentView"; -import { IconBox } from "./nodes/IconBox"; import React = require("react"); +import { Id } from '../../new_fields/FieldSymbols'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; +library.add(faCaretUp); +library.add(faObjectGroup); +library.add(faStickyNote); +library.add(faFilePdf); +library.add(faFilm, faTextHeight); library.add(faLink); library.add(faTag); library.add(faTimes); @@ -102,27 +106,10 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (key === 13) { const text = e.target.value; if (text.startsWith("::")) { - const targetID = text.slice(2, text.length); + this._accumulatedTitle = text.slice(2, text.length); const promoteDoc = SelectionManager.SelectedDocuments()[0]; - DocUtils.Publish(promoteDoc.props.Document, targetID, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); - } else if (text.startsWith(">")) { - const fieldTemplateView = SelectionManager.SelectedDocuments()[0]; - SelectionManager.DeselectAll(); - const fieldTemplate = fieldTemplateView.props.Document; - const containerView = fieldTemplateView.props.ContainingCollectionView; - const docTemplate = fieldTemplateView.props.ContainingCollectionDoc; - if (containerView && docTemplate) { - const metaKey = text.startsWith(">>") ? text.slice(2, text.length) : text.slice(1, text.length); - if (metaKey !== containerView.props.fieldKey && containerView.props.DataDoc) { - const fd = fieldTemplate.data; - fd instanceof ObjectField && (Doc.GetProto(containerView.props.DataDoc)[metaKey] = ObjectField.MakeCopy(fd)); - } - fieldTemplate.title = metaKey; - Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate)); - if (text.startsWith(">>")) { - Doc.GetProto(docTemplate).layout = StrCast(fieldTemplateView.props.Document.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={"${metaKey}"}`); - } - } + Doc.SetInPlace(promoteDoc.props.Document, "title", this._accumulatedTitle, true); + DocUtils.Publish(promoteDoc.props.Document, this._accumulatedTitle, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); } e.target.blur(); } @@ -279,11 +266,11 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> const layoutKey = Cast(dv.props.Document.layoutKey, "string", null); const collapse = layoutKey !== "layout_icon"; if (collapse) { - dv.setCustomView(collapse, "icon"); + dv.switchViews(collapse, "icon"); if (layoutKey && layoutKey !== "layout") dv.props.Document.deiconifyLayout = layoutKey.replace("layout_", ""); } else { const deiconifyLayout = Cast(dv.props.Document.deiconifyLayout, "string", null); - dv.setCustomView(deiconifyLayout ? true : false, deiconifyLayout); + dv.switchViews(deiconifyLayout ? true : false, deiconifyLayout); dv.props.Document.deiconifyLayout = undefined; } }); @@ -482,6 +469,15 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> this.TextBar = ele; } } + public static DocumentIcon(layout: string) { + const button = layout.indexOf("PDFBox") !== -1 ? faFilePdf : + layout.indexOf("ImageBox") !== -1 ? faImage : + layout.indexOf("Formatted") !== -1 ? faStickyNote : + layout.indexOf("Video") !== -1 ? faFilm : + layout.indexOf("Collection") !== -1 ? faObjectGroup : + faCaretUp; + return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />; + } render() { const bounds = this.Bounds; const seldoc = SelectionManager.SelectedDocuments().length ? SelectionManager.SelectedDocuments()[0] : undefined; @@ -491,7 +487,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> const minimizeIcon = ( <div className="documentDecorations-minimizeButton" onPointerDown={this.onMinimizeDown}> {/* Currently, this is set to be enabled if there is no ink selected. It might be interesting to think about minimizing ink if it's useful? -syip2*/} - {SelectionManager.SelectedDocuments().length === 1 ? IconBox.DocumentIcon(StrCast(SelectionManager.SelectedDocuments()[0].props.Document.layout, "...")) : "..."} + {SelectionManager.SelectedDocuments().length === 1 ? DocumentDecorations.DocumentIcon(StrCast(SelectionManager.SelectedDocuments()[0].props.Document.layout, "...")) : "..."} </div>); bounds.x = Math.max(0, bounds.x - this._resizeBorderWidth / 2) + this._resizeBorderWidth / 2; @@ -513,19 +509,28 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> top: bounds.y - this._resizeBorderWidth / 2, pointerEvents: this.Interacting ? "none" : "all", zIndex: SelectionManager.SelectedDocuments().length > 1 ? 900 : 0, - }} onPointerDown={this.onBackgroundDown} onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); }} > + }} onPointerDown={this.onBackgroundDown} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} > </div> <div className="documentDecorations-container" ref={this.setTextBar} style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", - height: (bounds.b - bounds.y + this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight + 3) + "px", + height: (bounds.b - bounds.y + this._resizeBorderWidth + this._titleHeight) + "px", left: bounds.x - this._resizeBorderWidth / 2, top: bounds.y - this._resizeBorderWidth / 2 - this._titleHeight, opacity: this._opacity }}> {minimizeIcon} - {this._edtingTitle ? - <input ref={this._keyinput} className="title" type="text" name="dynbox" value={this._accumulatedTitle} onBlur={e => this.titleBlur(true)} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> : + {this._edtingTitle ? <> + <input ref={this._keyinput} className="title" type="text" name="dynbox" autoComplete="on" value={this._accumulatedTitle} style={{ width: "calc(100% - 20px)" }} + onBlur={e => this.titleBlur(true)} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> + <div className="publishBox" title="make document referenceable by its title" + onPointerDown={e => { + const promoteDoc = SelectionManager.SelectedDocuments()[0]; + DocUtils.Publish(promoteDoc.props.Document, this._accumulatedTitle, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); + }}> + <FontAwesomeIcon size="lg" color={SelectionManager.SelectedDocuments()[0].props.Document.title === SelectionManager.SelectedDocuments()[0].props.Document[Id] ? "green" : undefined} icon="sticky-note"></FontAwesomeIcon> + </div> + </> : <div className="title" onPointerDown={this.onTitleDown} ><span>{`${this.selectionTitle}`}</span></div>} <div className="documentDecorations-closeButton" title="Close Document" onPointerDown={this.onCloseDown}> <FontAwesomeIcon className="documentdecorations-times" icon={faTimes} size="lg" /> @@ -540,10 +545,11 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> <div id="documentDecorations-bottomResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> <div id="documentDecorations-bottomRightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> <div id="documentDecorations-borderRadius" className="documentDecorations-radius" onPointerDown={this.onRadiusDown} onContextMenu={(e) => e.preventDefault()}><span className="borderRadiusTooltip" title="Drag Corner Radius"></span></div> - <div className="link-button-container"> - <DocumentButtonBar views={SelectionManager.SelectedDocuments()} /> - </div> + </div > + <div className="link-button-container" style={{ left: bounds.x - this._resizeBorderWidth / 2, top: bounds.b + this._resizeBorderWidth / 2 }}> + <DocumentButtonBar views={SelectionManager.SelectedDocuments()} /> + </div> </div> ); } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index a9e81093a..401a4b15c 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -493,7 +493,6 @@ export class MainView extends React.Component { fieldKey={"data"} annotationsKey={""} select={emptyFunction} - chromeCollapsed={true} active={returnFalse} isSelected={returnFalse} moveDocument={this.moveButtonDoc} diff --git a/src/client/views/ScriptBox.tsx b/src/client/views/ScriptBox.tsx index d24256886..cc5d7640e 100644 --- a/src/client/views/ScriptBox.tsx +++ b/src/client/views/ScriptBox.tsx @@ -93,29 +93,33 @@ export class ScriptBox extends React.Component<ScriptBoxProps> { const params: string[] = []; const setParams = (p: string[]) => params.splice(0, params.length, ...p); const scriptingBox = <ScriptBox initialText={originalText} setParams={setParams} onCancel={overlayDisposer} onSave={(text, onError) => { - const script = CompileScript(text, { - params: { this: Doc.name, ...contextParams }, - typecheck: false, - editable: true, - transformer: DocumentIconContainer.getTransformer() - }); - if (!script.compiled) { - onError(script.errors.map(error => error.messageText).join("\n")); - return; - } + if (!text) { + doc[fieldKey] = undefined; + } else { + const script = CompileScript(text, { + params: { this: Doc.name, ...contextParams }, + typecheck: false, + editable: true, + transformer: DocumentIconContainer.getTransformer() + }); + if (!script.compiled) { + onError(script.errors.map(error => error.messageText).join("\n")); + return; + } - const div = document.createElement("div"); - div.style.width = "90"; - div.style.height = "20"; - div.style.background = "gray"; - div.style.position = "absolute"; - div.style.display = "inline-block"; - div.style.transform = `translate(${clientX}px, ${clientY}px)`; - div.innerHTML = "button"; - params.length && DragManager.StartButtonDrag([div], text, doc.title + "-instance", {}, params, (button: Doc) => { }, clientX, clientY); + const div = document.createElement("div"); + div.style.width = "90"; + div.style.height = "20"; + div.style.background = "gray"; + div.style.position = "absolute"; + div.style.display = "inline-block"; + div.style.transform = `translate(${clientX}px, ${clientY}px)`; + div.innerHTML = "button"; + params.length && DragManager.StartButtonDrag([div], text, doc.title + "-instance", {}, params, (button: Doc) => { }, clientX, clientY); - doc[fieldKey] = new ScriptField(script); - overlayDisposer(); + doc[fieldKey] = new ScriptField(script); + overlayDisposer(); + } }} showDocumentIcons />; overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, { x: 400, y: 200, width: 500, height: 400, title: title }); } diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index f61eb9cd0..64d1e96e1 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -4,14 +4,11 @@ import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; import './TemplateMenu.scss'; import { DocumentView } from "./nodes/DocumentView"; -import { Template, Templates } from "./Templates"; +import { Template } from "./Templates"; import React = require("react"); import { Doc, DocListCast } from "../../new_fields/Doc"; import { StrCast, Cast } from "../../new_fields/Types"; import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; -const higflyout = require("@hig/flyout"); -export const { anchorPoints } = higflyout; -export const Flyout = higflyout.default; @observer class TemplateToggle extends React.Component<{ template: Template, checked: boolean, toggle: (event: React.ChangeEvent<HTMLInputElement>, template: Template) => void }> { @@ -48,10 +45,12 @@ export interface TemplateMenuProps { @observer export class TemplateMenu extends React.Component<TemplateMenuProps> { + _addedKeys = new ObservableSet(); + _customRef = React.createRef<HTMLInputElement>(); @observable private _hidden: boolean = true; toggleLayout = (e: React.ChangeEvent<HTMLInputElement>, layout: string): void => { - this.props.docViews.map(dv => dv.setCustomView(e.target.checked, layout)); + this.props.docViews.map(dv => dv.switchViews(e.target.checked, layout));//.setCustomView(e.target.checked, layout)); } toggleFloat = (e: React.ChangeEvent<HTMLInputElement>): void => { @@ -62,15 +61,10 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { DocumentView.FloatDoc(topDocView, ex, ey); } - @undoBatch @action toggleTemplate = (event: React.ChangeEvent<HTMLInputElement>, template: Template): void => { - if (event.target.checked) { - this.props.docViews.map(d => d.Document["show" + template.Name] = template.Name.toLowerCase()); - } else { - this.props.docViews.map(d => d.Document["show" + template.Name] = ""); - } + this.props.docViews.forEach(d => Doc.Layout(d.Document)["show" + template.Name] = event.target.checked ? template.Name.toLowerCase() : ""); } @action @@ -81,10 +75,8 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { @undoBatch @action toggleChrome = (): void => { - this.props.docViews.map(dv => { - const layout = Doc.Layout(dv.Document); - layout._chromeStatus = (layout._chromeStatus !== "disabled" ? "disabled" : "enabled"); - }); + this.props.docViews.map(dv => Doc.Layout(dv.Document)).forEach(layout => + layout._chromeStatus = (layout._chromeStatus !== "disabled" ? "disabled" : StrCast(layout._replacedChrome, "enabled"))); } // todo: add brushes to brushMap to save with a style name @@ -105,8 +97,6 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { }); } - _addedKeys = new ObservableSet(); - _customRef = React.createRef<HTMLInputElement>(); render() { const layout = Doc.Layout(this.props.docViews[0].Document); const templateMenu: Array<JSX.Element> = []; diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss index 4815f1a59..ad369bbff 100644 --- a/src/client/views/collections/CollectionCarouselView.scss +++ b/src/client/views/collections/CollectionCarouselView.scss @@ -14,27 +14,23 @@ width: 100%; } } -.carouselView-back { +.carouselView-back, .carouselView-fwd { position: absolute; display: flex; - left: 0; top: 50%; width: 30; height: 30; - background: lightgray; align-items: center; border-radius: 5px; justify-content: center; + background : rgba(255, 255, 255, 0.46); } -.carouselView-fwd { - position: absolute; - display: flex; +.carouselView-fwd { right: 0; - top: 50%; - width: 30; - height: 30; +} +.carouselView-back { + left: 0; +} +.carouselView-back:hover, .carouselView-fwd:hover { background: lightgray; - align-items: center; - border-radius: 5px; - justify-content: center; }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index d772ace23..d21ae32bc 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -4,25 +4,25 @@ import { CursorProperty } from "csstype"; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import Switch from 'rc-switch'; -import { Doc, HeightSym, WidthSym, DataSym } from "../../../new_fields/Doc"; +import { Doc, HeightSym, WidthSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; -import { BoolCast, Cast, NumCast, StrCast, ScriptCast } from "../../../new_fields/Types"; -import { emptyFunction, Utils } from "../../../Utils"; +import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from "../../../new_fields/Types"; +import { TraceMobx } from "../../../new_fields/util"; +import { Utils } from "../../../Utils"; import { DragManager } from "../../util/DragManager"; import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; import { EditableView } from "../EditableView"; import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; +import { CollectionMasonryViewFieldRow } from "./CollectionMasonryViewFieldRow"; import "./CollectionStackingView.scss"; import { CollectionStackingViewFieldColumn } from "./CollectionStackingViewFieldColumn"; import { CollectionSubView } from "./CollectionSubView"; -import { ContextMenu } from "../ContextMenu"; -import { ContextMenuProps } from "../ContextMenuItem"; -import { CollectionMasonryViewFieldRow } from "./CollectionMasonryViewFieldRow"; -import { TraceMobx } from "../../../new_fields/util"; import { CollectionViewType } from "./CollectionView"; @observer @@ -55,7 +55,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { this._docXfs.length = 0; return docs.map((d, i) => { const height = () => this.getDocHeight(d); - const width = () => this.widthScale * Math.min(d._nativeWidth && !d.ignoreAspect && !this.props.Document.fillColumn ? d[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); + const width = () => this.getDocWidth(d); const dref = React.createRef<HTMLDivElement>(); const dxf = () => this.getDocTransform(d, dref.current!); this._docXfs.push({ dxf: dxf, width: width, height: height }); @@ -154,11 +154,12 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @computed get onClickHandler() { return ScriptCast(this.Document.onChildClick); } getDisplayDoc(doc: Doc, dataDoc: Doc | undefined, dxf: () => Transform, width: () => number) { - const layoutDoc = Doc.Layout(doc); + const layoutDoc = Doc.Layout(doc, this.props.childLayoutTemplate?.()); const height = () => this.getDocHeight(doc); return <ContentFittingDocumentView Document={doc} DataDocument={dataDoc} + LayoutDoc={this.props.childLayoutTemplate} LibraryPath={this.props.LibraryPath} renderDepth={this.props.renderDepth + 1} fitToBox={this.props.fitToBox} @@ -178,19 +179,26 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { pinToPres={this.props.pinToPres}> </ContentFittingDocumentView>; } + + getDocWidth(d?: Doc) { + if (!d) return 0; + const layoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); + const nw = NumCast(layoutDoc._nativeWidth); + return Math.min(nw && !d.ignoreAspect && !this.props.Document.fillColumn ? d[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); + } getDocHeight(d?: Doc) { if (!d) return 0; - const layoutDoc = Doc.Layout(d); + let layoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); const nw = NumCast(layoutDoc._nativeWidth); const nh = NumCast(layoutDoc._nativeHeight); let wid = this.columnWidth / (this.isStackingView ? this.numGroupColumns : 1); if (!layoutDoc.ignoreAspect && !layoutDoc._fitWidth && nw && nh) { const aspect = nw && nh ? nh / nw : 1; - if (!(d._nativeWidth && !layoutDoc.ignoreAspect && this.props.Document.fillColumn)) wid = Math.min(layoutDoc[WidthSym](), wid); + if (!(!layoutDoc.ignoreAspect && this.props.Document.fillColumn)) wid = Math.min(layoutDoc[WidthSym](), wid); return wid * aspect; } - return layoutDoc._fitWidth ? !layoutDoc._nativeHeight ? this.props.PanelHeight() - 2 * this.yMargin : - Math.min(wid * NumCast(layoutDoc.scrollHeight, NumCast(layoutDoc._nativeHeight)) / NumCast(layoutDoc._nativeWidth, 1), this.props.PanelHeight() - 2 * this.yMargin) : layoutDoc[HeightSym](); + return layoutDoc._fitWidth ? !nh ? this.props.PanelHeight() - 2 * this.yMargin : + Math.min(wid * NumCast(layoutDoc.scrollHeight, nh) / (nw || 1), this.props.PanelHeight() - 2 * this.yMargin) : layoutDoc[HeightSym](); } columnDividerDown = (e: React.PointerEvent) => { @@ -376,16 +384,9 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } return sections.map(section => this.isStackingView ? this.sectionStacking(section[0], section[1]) : this.sectionMasonry(section[0], section[1])); } - @computed get contentScale() { - const heightExtra = this.heightPercent > 1 ? this.heightPercent : 1; - return Math.min(this.props.Document[WidthSym]() / this.props.PanelWidth(), heightExtra * this.props.Document[HeightSym]() / this.props.PanelHeight()); - } - @computed get widthScale() { - return this.heightPercent < 1 ? Math.max(1, this.contentScale) : 1; - } - @computed get heightPercent() { - return this.props.PanelHeight() / this.layoutDoc[HeightSym](); - } + + @computed get scaling() { return !this.props.Document._nativeWidth ? 1 : this.props.PanelHeight() / NumCast(this.props.Document._nativeHeight); } + render() { TraceMobx(); const editableViewProps = { @@ -399,9 +400,9 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { ref={this.createRef} style={{ overflowY: this.props.active() ? "auto" : "hidden", - transform: `scale(${Math.min(1, this.heightPercent)})`, - height: `${100 * Math.max(1, this.contentScale)}%`, - width: `${100 * this.widthScale}%`, + transform: `scale(${this.scaling}`, + height: `${1 / this.scaling * 100}%`, + width: `${1 / this.scaling * 100}%`, transformOrigin: "top left", }} onScroll={action((e: React.UIEvent<HTMLDivElement>) => this._scroll = e.currentTarget.scrollTop)} diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index 21982f1ca..3fc05c6b7 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -285,8 +285,10 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC description: ":" + fieldKey, event: () => { const created = Docs.Create.CarouselDocument([], { _width: 400, _height: 200, title: fieldKey }); if (created) { - if (this.props.parent.Document.isTemplateDoc) { - Doc.MakeMetadataFieldTemplate(created, this.props.parent.props.Document); + const container = this.props.parent.Document.resolvedDataDoc ? Doc.GetProto(this.props.parent.Document) : this.props.parent.Document; + if (container.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, container); + return Doc.AddDocToList(container, Doc.LayoutFieldKey(container), created); } return this.props.parent.props.addDocument(created); } diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 62b9e8380..32480ad4e 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -1,7 +1,7 @@ import { action, computed, IReactionDisposer, reaction, trace } from "mobx"; import * as rp from 'request-promise'; import CursorField from "../../../new_fields/CursorField"; -import { Doc, DocListCast, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; @@ -33,7 +33,6 @@ export interface CollectionViewProps extends FieldViewProps { PanelWidth: () => number; PanelHeight: () => number; VisibleHeight?: () => number; - chromeCollapsed: boolean; setPreviewCursor?: (func: (x: number, y: number, drag: boolean) => void) => void; fieldKey: string; } @@ -86,7 +85,10 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { this._childLayoutDisposer && this._childLayoutDisposer(); } - @computed get dataDoc() { return this.props.DataDoc && this.props.Document.isTemplateForField ? Doc.GetProto(this.props.DataDoc) : Doc.GetProto(this.props.Document); } + @computed get dataDoc() { + return (this.props.DataDoc && this.props.Document.isTemplateForField ? Doc.GetProto(this.props.DataDoc) : + this.props.Document.resolvedDataDoc ? this.props.Document : Doc.GetProto(this.props.Document)); // if the layout document has a resolvedDataDoc, then we don't want to get its parent which would be the unexpanded template + } // The data field for rendering this collection will be on the this.props.Document unless we're rendering a template in which case we try to use props.DataDoc. // When a document has a DataDoc but it's not a template, then it contains its own rendering data, but needs to pass the DataDoc through @@ -167,6 +169,9 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { return true; } let added = false; + if (this.props.Document._freezeOnDrop) { + de.complete.docDragData?.droppedDocuments.forEach(drop => Doc.freezeNativeDimensions(drop, drop[WidthSym](), drop[HeightSym]())); + } if (docDragData.dropAction || docDragData.userDropAction) { added = docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); } else if (docDragData.moveDocument) { @@ -230,8 +235,8 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { const split = img.split("src=\"")[1].split("\"")[0]; let source = split; if (split.startsWith("data:image") && split.includes("base64")) { - const [{ clientAccessPath }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [split] }); - source = Utils.prepend(clientAccessPath); + const [{ accessPaths }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [split] }); + source = Utils.prepend(accessPaths.agnostic.client); } const doc = Docs.Create.ImageDocument(source, { ...options, _width: 300 }); ImageUtils.ExtractExif(doc); @@ -310,9 +315,9 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { const dropFileName = file ? file.name : "-empty-"; promises.push(Networking.PostFormDataToServer("/uploadFormData", formData).then(results => { results.map(action((result: any) => { - const { clientAccessPath, nativeWidth, nativeHeight, contentSize } = result; + const { accessPaths, nativeWidth, nativeHeight, contentSize } = result; const full = { ...options, _width: 300, title: dropFileName }; - const pathname = Utils.prepend(clientAccessPath); + const pathname = Utils.prepend(accessPaths.agnostic.client); Docs.Get.DocumentFromType(type, pathname, full).then(doc => { if (doc) { const proto = Doc.GetProto(doc); diff --git a/src/client/views/collections/CollectionTimeView.scss b/src/client/views/collections/CollectionTimeView.scss index a5ce73a92..2dffb3ea0 100644 --- a/src/client/views/collections/CollectionTimeView.scss +++ b/src/client/views/collections/CollectionTimeView.scss @@ -1,21 +1,26 @@ -.collectionTimeView, .collectionTimeView-pivot { +.collectionTimeView, +.collectionTimeView-pivot { display: flex; flex-direction: row; position: absolute; height: 100%; width: 100%; overflow: hidden; + .collectionTimeView-backBtn { background: green; display: inline; margin-right: 20px; } + .collectionFreeform-customText { text-align: left; } + .collectionFreeform-customDiv { position: absolute; } + .collectionTimeView-thumb { position: absolute; width: 30px; @@ -28,19 +33,21 @@ border-radius: 9px; opacity: 0.25; } + .collectionTimeView-thumb-min { - margin-left:25%; + margin-left: 25%; } + .collectionTimeView-thumb-max { - margin-left:75%; + margin-left: 75%; } + .collectionTimeView-thumb-mid { - margin-left:50%; + margin-left: 50%; } .collectionTimeView-flyout { width: 400px; - height: 300px; display: inline-block; .collectionTimeView-flyout-item { @@ -119,6 +126,7 @@ left: -10px; } } + .collectionTimeView-pivot { .collectionFreeform-customText { text-align: center; diff --git a/src/client/views/collections/CollectionTimeView.tsx b/src/client/views/collections/CollectionTimeView.tsx index 4983acbc2..50e297f0b 100644 --- a/src/client/views/collections/CollectionTimeView.tsx +++ b/src/client/views/collections/CollectionTimeView.tsx @@ -1,27 +1,29 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, observable, trace, runInAction } from "mobx"; +import { action, computed, observable, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; -import { Set } from "typescript-collections"; -import { Doc, DocListCast, Field } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Field, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { List } from "../../../new_fields/List"; +import { ObjectField } from "../../../new_fields/ObjectField"; import { RichTextField } from "../../../new_fields/RichTextField"; import { listSpec } from "../../../new_fields/Schema"; import { ComputedField, ScriptField } from "../../../new_fields/ScriptField"; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; import { Docs } from "../../documents/Documents"; +import { DocumentType } from "../../documents/DocumentTypes"; import { Scripting } from "../../util/Scripting"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { EditableView } from "../EditableView"; -import { anchorPoints, Flyout } from "../TemplateMenu"; import { ViewDefBounds } from "./collectionFreeForm/CollectionFreeFormLayoutEngines"; import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTimeView.scss"; -import React = require("react"); import { CollectionTreeView } from "./CollectionTreeView"; -import { ObjectField } from "../../../new_fields/ObjectField"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; +import React = require("react"); @observer export class CollectionTimeView extends CollectionSubView(doc => doc) { @@ -29,11 +31,12 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { @observable _layoutEngine = "pivot"; componentDidMount() { + this.props.Document._freezeOnDrop = true; const childDetailed = this.props.Document.childDetailed; // bcz: needs to be here to make sure the childDetailed layout template has been loaded when the first item is clicked; if (!this.props.Document._facetCollection) { - const facetCollection = Docs.Create.TreeDocument([], { title: "facetFilters", _yMargin: 0, treeViewHideTitle: true }); + const facetCollection = Docs.Create.TreeDocument([], { title: "facetFilters", _yMargin: 0, treeViewHideTitle: true, treeViewHideHeaderFields: true }); facetCollection.target = this.props.Document; - this.props.Document.excludeFields = new List<string>(["_facetCollection", "_docFilter"]); + this.props.Document.excludeFields = new List<string>(["_facetCollection", "_docFilters"]); const scriptText = "setDocFilter(containingTreeView.target, heading, this.title, checked)"; const childText = "const alias = getAlias(this); Doc.ApplyTemplateTo(containingCollection.childDetailed, alias, 'layout_detailView'); alias.dropAction='alias'; alias.removeDropProperties=new List<string>(['dropAction']); useRightSplit(alias, shiftKey); "; @@ -43,7 +46,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { this.props.Document._fitToBox = true; } if (!this.props.Document.onViewDefClick) { - this.props.Document.onViewDefDivClick = ScriptField.MakeScript("pivotColumnClick(this,payload)", { payload: "any" }) + this.props.Document.onViewDefDivClick = ScriptField.MakeScript("pivotColumnClick(this,payload)", { payload: "any" }); } } @@ -54,7 +57,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { const facets = new Set<string>(); this.childDocs.forEach(child => Object.keys(Doc.GetProto(child)).forEach(key => facets.add(key))); Doc.AreProtosEqual(this.dataDoc, this.props.Document) && this.childDocs.forEach(child => Object.keys(child).forEach(key => facets.add(key))); - return facets.toArray(); + return Array.from(facets); } /** @@ -66,19 +69,58 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { const found = DocListCast(facetCollection.data).findIndex(doc => doc.title === facetHeader); if (found !== -1) { (facetCollection.data as List<Doc>).splice(found, 1); - const docFilter = Cast(this.props.Document._docFilter, listSpec("string")); + const docFilter = Cast(this.props.Document._docFilters, listSpec("string")); if (docFilter) { let index: number; while ((index = docFilter.findIndex(item => item === facetHeader)) !== -1) { docFilter.splice(index, 3); } } + const docRangeFilters = Cast(this.props.Document._docRangeFilters, listSpec("string")); + if (docRangeFilters) { + let index: number; + while ((index = docRangeFilters.findIndex(item => item === facetHeader)) !== -1) { + docRangeFilters.splice(index, 3); + } + } } else { - const newFacet = Docs.Create.TreeDocument([], { title: facetHeader, treeViewOpen: true, isFacetFilter: true }); - const capturedVariables = { layoutDoc: this.props.Document, dataDoc: this.dataDoc }; - const params = { layoutDoc: Doc.name, dataDoc: Doc.name, }; - newFacet.data = ComputedField.MakeFunction(`readFacetData(layoutDoc, dataDoc, "${this.props.fieldKey}", "${facetHeader}")`, params, capturedVariables); - Doc.AddDocToList(facetCollection, "data", newFacet); + const allCollectionDocs = DocListCast(this.dataDoc[this.props.fieldKey]); + const facetValues = Array.from(allCollectionDocs.reduce((set, child) => + set.add(Field.toString(child[facetHeader] as Field)), new Set<string>())); + + let nonNumbers = 0; + let minVal = Number.MAX_VALUE, maxVal = -Number.MAX_VALUE; + facetValues.map(val => { + const num = Number(val); + if (Number.isNaN(num)) { + nonNumbers++; + } else { + minVal = Math.min(num, minVal); + maxVal = Math.max(num, maxVal); + } + }); + if (nonNumbers / allCollectionDocs.length < .1) { + const ranged = Doc.readDocRangeFilter(this.props.Document, facetHeader); + const newFacet = Docs.Create.SliderDocument({ title: facetHeader }); + Doc.GetProto(newFacet).type = DocumentType.COL; // forces item to show an open/close button instead ofa checkbox + newFacet.treeViewExpandedView = "layout"; + newFacet.treeViewOpen = true; + newFacet._sliderMin = ranged === undefined ? minVal : ranged[0]; + newFacet._sliderMax = ranged === undefined ? maxVal : ranged[1]; + newFacet._sliderMinThumb = minVal; + newFacet._sliderMaxThumb = maxVal; + newFacet.target = this.props.Document; + const scriptText = `setDocFilterRange(this.target, "${facetHeader}", range)`; + newFacet.onThumbChanged = ScriptField.MakeScript(scriptText, { this: Doc.name, range: "number" }); + + Doc.AddDocToList(facetCollection, "data", newFacet); + } else { + const newFacet = Docs.Create.TreeDocument([], { title: facetHeader, treeViewOpen: true, isFacetFilter: true }); + const capturedVariables = { layoutDoc: this.props.Document, dataDoc: this.dataDoc }; + const params = { layoutDoc: Doc.name, dataDoc: Doc.name, }; + newFacet.data = ComputedField.MakeFunction(`readFacetData(layoutDoc, dataDoc, "${this.props.fieldKey}", "${facetHeader}")`, params, capturedVariables); + Doc.AddDocToList(facetCollection, "data", newFacet); + } } } } @@ -120,9 +162,9 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { pair.layout[fieldKey] instanceof RichTextField || typeof (pair.layout[fieldKey]) === "number" || typeof (pair.layout[fieldKey]) === "string").map(fieldKey => keySet.add(fieldKey))); - keySet.toArray().map(fieldKey => + Array.from(keySet).map(fieldKey => docItems.push({ description: ":" + fieldKey, event: () => this.props.Document._pivotField = fieldKey, icon: "compress-arrows-alt" })); - docItems.push({ description: ":(null)", event: () => this.props.Document._pivotField = undefined, icon: "compress-arrows-alt" }) + docItems.push({ description: ":(null)", event: () => this.props.Document._pivotField = undefined, icon: "compress-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Pivot Fields ...", subitems: docItems, icon: "eye" }); const pt = this.props.ScreenToLocalTransform().inverse().transformPoint(x, y); ContextMenu.Instance.displayMenu(x, y, ":"); @@ -148,6 +190,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); this.props.Document[this.props.fieldKey + "-timelineMinReq"] = minReq + (maxReq - minReq) * delta / this.props.PanelWidth(); + this.props.Document[this.props.fieldKey + "-timelineSpan"] = undefined; } onMinUp = (e: PointerEvent) => { document.removeEventListener("pointermove", this.onMinMove); @@ -170,6 +213,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); this.props.Document[this.props.fieldKey + "-timelineMaxReq"] = maxReq + (maxReq - minReq) * delta / this.props.PanelWidth(); + this.props.Document[this.props.fieldKey + "-timelineSpan"] = undefined; } onMaxUp = (e: PointerEvent) => { document.removeEventListener("pointermove", this.onMaxMove); @@ -209,7 +253,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { trace(); const facetCollection = Cast(this.props.Document?._facetCollection, Doc, null); const flyout = ( - <div className="collectionTimeView-flyout" style={{ width: `${this._facetWidth}` }}> + <div className="collectionTimeView-flyout" style={{ width: `${this._facetWidth}`, height: this.props.PanelHeight() - 30, display: "block" }} onWheel={e => e.stopPropagation()}> {this._allFacets.map(facet => <label className="collectionTimeView-flyout-item" key={`${facet}`} onClick={e => this.facetClick(facet)}> <input type="checkbox" onChange={e => { }} checked={DocListCast((this.props.Document._facetCollection as Doc)?.data).some(d => d.title === facet)} /> <span className="checkmark" /> @@ -227,7 +271,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { </Flyout> </div> <div className="collectionTimeView-tree" key="tree"> - <CollectionTreeView {...this.props} Document={facetCollection} /> + <CollectionTreeView {...this.props} PanelWidth={() => this._facetWidth} Document={facetCollection} /> </div> </div>; } @@ -240,9 +284,9 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { const layoutItems: ContextMenuProps[] = []; const doc = this.props.Document; - layoutItems.push({ description: "Force Timeline", event: () => { doc._forceRenderEngine = "timeline" }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: "Force Pivot", event: () => { doc._forceRenderEngine = "pivot" }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: "Auto Time/Pivot layout", event: () => { doc._forceRenderEngine = undefined }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Force Timeline", event: () => { doc._forceRenderEngine = "timeline"; }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Force Pivot", event: () => { doc._forceRenderEngine = "pivot"; }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Auto Time/Pivot layout", event: () => { doc._forceRenderEngine = undefined; }, icon: "compress-arrows-alt" }); layoutItems.push({ description: "Sync with presentation", event: () => CollectionTimeView.SyncTimelineToPresentation(doc), icon: "compress-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Pivot/Time Options ...", subitems: layoutItems, icon: "eye" }); @@ -289,14 +333,16 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { <div className={"collectionTimeView" + (doTimeline ? "" : "-pivot")} onContextMenu={this.specificMenu} style={{ height: `calc(100% - ${this.props.Document._chromeStatus === "enabled" ? 51 : 0}px)` }}> <div className={"pivotKeyEntry"}> - <button className="collectionTimeView-backBtn" style={{ width: 50, height: 20, background: "green" }} + <button className="collectionTimeView-backBtn" onClick={action(() => { - let pfilterIndex = NumCast(this.props.Document._pfilterIndex); - if (pfilterIndex > 0) { - this.props.Document._docFilter = ObjectField.MakeCopy(this.props.Document["_pfilter" + --pfilterIndex] as ObjectField); - this.props.Document._pfilterIndex = pfilterIndex; + let prevFilterIndex = NumCast(this.props.Document._prevFilterIndex); + if (prevFilterIndex > 0) { + prevFilterIndex--; + this.props.Document._docFilters = ObjectField.MakeCopy(this.props.Document["_prevDocFilter" + prevFilterIndex] as ObjectField); + this.props.Document._docRangeFilters = ObjectField.MakeCopy(this.props.Document["_prevDocRangeFilters" + prevFilterIndex] as ObjectField); + this.props.Document._prevFilterIndex = prevFilterIndex; } else { - this.props.Document._docFilter = new List([]); + this.props.Document._docFilters = new List([]); } })}> back @@ -320,11 +366,12 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { } Scripting.addGlobal(function pivotColumnClick(pivotDoc: Doc, bounds: ViewDefBounds) { - let pfilterIndex = NumCast(pivotDoc._pfilterIndex); - pivotDoc["_pfilter" + pfilterIndex] = ObjectField.MakeCopy(pivotDoc._docFilter as ObjectField); - pivotDoc._pfilterIndex = ++pfilterIndex; + let prevFilterIndex = NumCast(pivotDoc._prevFilterIndex); + pivotDoc["_prevDocFilter" + prevFilterIndex] = ObjectField.MakeCopy(pivotDoc._docFilters as ObjectField); + pivotDoc["_prevDocRangeFilters" + prevFilterIndex] = ObjectField.MakeCopy(pivotDoc._docRangeFilters as ObjectField); + pivotDoc._prevFilterIndex = ++prevFilterIndex; runInAction(() => { - pivotDoc._docFilter = new List(); + pivotDoc._docFilters = new List(); (bounds.payload as string[]).map(filterVal => Doc.setDocFilter(pivotDoc, StrCast(pivotDoc._pivotField), filterVal, "check")); }); diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 001064b30..be2947dff 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -60,7 +60,7 @@ export interface TreeViewProps { treeViewId: Doc; parentKey: string; active: (outsideReaction?: boolean) => boolean; - hideHeaderFields: () => boolean; + treeViewHideHeaderFields: () => boolean; preventTreeViewOpen: boolean; renderedIds: string[]; onCheckedClick?: ScriptField; @@ -292,7 +292,7 @@ class TreeView extends React.Component<TreeViewProps> { contentElement = TreeView.GetChildElements(contents instanceof Doc ? [contents] : DocListCast(contents), this.props.treeViewId, doc, undefined, key, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, this.props.outerXf, this.props.active, - this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, + this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.treeViewHideHeaderFields, this.props.preventTreeViewOpen, [...this.props.renderedIds, doc[Id]], this.props.libraryPath, this.props.onCheckedClick); } else { contentElement = <EditableView @@ -335,7 +335,7 @@ class TreeView extends React.Component<TreeViewProps> { TreeView.GetChildElements(docs, this.props.treeViewId, Doc.Layout(this.props.document), this.templateDataDoc, expandKey, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, - this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, + this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.treeViewHideHeaderFields, this.props.preventTreeViewOpen, [...this.props.renderedIds, this.props.document[Id]], this.props.libraryPath, this.props.onCheckedClick)} </ul >; } else if (this.treeViewExpandedView === "fields") { @@ -425,7 +425,7 @@ class TreeView extends React.Component<TreeViewProps> { }} > {this.editableView("title")} </div > - {this.props.hideHeaderFields() ? (null) : headerElements} + {this.props.treeViewHideHeaderFields() ? (null) : headerElements} {openRight} </>; } @@ -464,7 +464,7 @@ class TreeView extends React.Component<TreeViewProps> { panelWidth: () => number, ChromeHeight: undefined | (() => number), renderDepth: number, - hideHeaderFields: () => boolean, + treeViewHideHeaderFields: () => boolean, preventTreeViewOpen: boolean, renderedIds: string[], libraryPath: Doc[] | undefined, @@ -574,7 +574,7 @@ class TreeView extends React.Component<TreeViewProps> { outerXf={outerXf} parentKey={key} active={active} - hideHeaderFields={hideHeaderFields} + treeViewHideHeaderFields={treeViewHideHeaderFields} preventTreeViewOpen={preventTreeViewOpen} renderedIds={renderedIds} />; }); @@ -625,7 +625,7 @@ export class CollectionTreeView extends CollectionSubView(Document) { } else { const layoutItems: ContextMenuProps[] = []; layoutItems.push({ description: (this.props.Document.preventTreeViewOpen ? "Persist" : "Abandon") + "Treeview State", event: () => this.props.Document.preventTreeViewOpen = !this.props.Document.preventTreeViewOpen, icon: "paint-brush" }); - layoutItems.push({ description: (this.props.Document.hideHeaderFields ? "Show" : "Hide") + " Header Fields", event: () => this.props.Document.hideHeaderFields = !this.props.Document.hideHeaderFields, icon: "paint-brush" }); + layoutItems.push({ description: (this.props.Document.treeViewHideHeaderFields ? "Show" : "Hide") + " Header Fields", event: () => this.props.Document.treeViewHideHeaderFields = !this.props.Document.treeViewHideHeaderFields, icon: "paint-brush" }); layoutItems.push({ description: (this.props.Document.treeViewHideTitle ? "Show" : "Hide") + " Title", event: () => this.props.Document.treeViewHideTitle = !this.props.Document.treeViewHideTitle, icon: "paint-brush" }); ContextMenu.Instance.addItem({ description: "Treeview Options ...", subitems: layoutItems, icon: "eye" }); } @@ -633,11 +633,10 @@ export class CollectionTreeView extends CollectionSubView(Document) { description: "Buxton Layout", icon: "eye", event: () => { DocListCast(this.dataDoc[this.props.fieldKey]).map(d => { DocListCast(d.data).map((img, i) => { - const caption = (d.captions as any)[i]?.data; - if (caption instanceof ObjectField) { - Doc.GetProto(img).caption = ObjectField.MakeCopy(caption); + const caption = (d.captions as any)[i]; + if (caption) { + Doc.GetProto(img).caption = caption; } - d.captions = undefined; }); }); const { TextDocument, ImageDocument, CarouselDocument, TreeDocument } = Docs.Create; @@ -649,13 +648,12 @@ export class CollectionTreeView extends CollectionSubView(Document) { const detailView = Docs.Create.StackingDocument([ CarouselDocument([], { title: "data", _height: 350, _itemIndex: 0, backgroundColor: "#9b9b9b3F" }), textDoc, - TextDocument("", { title: "short_description", _autoHeight: true }), + TextDocument("", { title: "shortDescription", _autoHeight: true }), TreeDocument([], { title: "narratives", _height: 75, treeViewHideTitle: true }) ], { _chromeStatus: "disabled", _width: 300, _height: 300, _autoHeight: true, title: "detailView" }); textDoc.data = new RichTextField(detailedTemplate, "year company"); detailView.isTemplateDoc = makeTemplate(detailView); - const heroView = ImageDocument(fallbackImg, { title: "heroView", isTemplateDoc: true, isTemplateForField: "hero", }); // this acts like a template doc and a template field ... a little weird, but seems to work? heroView.proto!.layout = ImageBox.LayoutString("hero"); heroView.showTitle = "title"; @@ -673,7 +671,6 @@ export class CollectionTreeView extends CollectionSubView(Document) { dragFactory: detailView, removeDropProperties: new List<string>(["dropAction"]), title: "detail view", icon: "file-alt" })); - Document.childLayout = heroView; Document.childDetailed = detailView; Document._viewType = CollectionViewType.Time; @@ -732,7 +729,7 @@ export class CollectionTreeView extends CollectionSubView(Document) { { TreeView.GetChildElements(this.childDocs, this.props.Document, this.props.Document, this.props.DataDoc, this.props.fieldKey, this.props.ContainingCollectionDoc, undefined, addDoc, this.remove, moveDoc, dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, - this.outerXf, this.props.active, this.props.PanelWidth, this.props.ChromeHeight, this.props.renderDepth, () => BoolCast(this.props.Document.hideHeaderFields), + this.outerXf, this.props.active, this.props.PanelWidth, this.props.ChromeHeight, this.props.renderDepth, () => BoolCast(this.props.Document.treeViewHideHeaderFields), BoolCast(this.props.Document.preventTreeViewOpen), [], this.props.LibraryPath, ScriptCast(this.props.Document.onCheckedClick)) } </ul> @@ -764,7 +761,7 @@ Scripting.addGlobal(function readFacetData(layoutDoc: Doc, dataDoc: Doc, dataKey }); Scripting.addGlobal(function determineCheckedState(layoutDoc: Doc, facetHeader: string, facetValue: string) { - const docFilters = Cast(layoutDoc._docFilter, listSpec("string"), []); + const docFilters = Cast(layoutDoc._docFilters, listSpec("string"), []); for (let i = 0; i < docFilters.length; i += 3) { const [header, value, state] = docFilters.slice(i, i + 3); if (header === facetHeader && value === facetValue) { diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index bdd908807..de3e9737f 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import Lightbox from 'react-image-lightbox-with-rotate'; import 'react-image-lightbox-with-rotate/style.css'; // This only needs to be imported once in your app import { DateField } from '../../../new_fields/DateField'; -import { Doc, DocListCast } from '../../../new_fields/Doc'; +import { Doc, DocListCast, DataSym } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; import { listSpec } from '../../../new_fields/Schema'; import { BoolCast, Cast, StrCast, NumCast } from '../../../new_fields/Types'; @@ -37,6 +37,7 @@ import './CollectionView.scss'; import { CollectionViewBaseChrome } from './CollectionViewChromes'; import { CollectionTimeView } from './CollectionTimeView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; +import { List } from '../../../new_fields/List'; export const COLLECTION_BORDER_WIDTH = 2; const path = require('path'); library.add(faTh, faTree, faSquare, faProjectDiagram, faSignature, faThList, faFingerprint, faColumns, faEllipsisV, faImage, faEye as any, faCopy); @@ -54,8 +55,7 @@ export enum CollectionViewType { Time, Carousel, Linear, - Staff, - Timeline + Staff } export namespace CollectionViewType { @@ -116,7 +116,7 @@ export class CollectionView extends Touchable<FieldViewProps> { // chrome status is one of disabled, collapsed, or visible. this determines initial state from document // chrome status may also be view-mode, in reference to stacking view's toggle mode. it is essentially disabled mode, but prevents the toggle button from showing up on the left sidebar. const chromeStatus = this.props.Document._chromeStatus; - if (chromeStatus && (chromeStatus === "disabled" || chromeStatus === "collapsed")) { + if (chromeStatus && (chromeStatus === "disabled" || chromeStatus === "collapsed" || chromeStatus === "replaced")) { runInAction(() => this._collapsed = true); } }); @@ -133,8 +133,9 @@ export class CollectionView extends Touchable<FieldViewProps> { @action.bound addDocument(doc: Doc): boolean { - const targetDataDoc = Doc.GetProto(this.props.DataDoc || this.props.Document); - Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc); + const targetDataDoc = this.props.Document.resolvedDataDoc && !this.props.Document.isTemplateForField ? this.props.Document : Doc.GetProto(this.props.Document[DataSym]); + targetDataDoc[this.props.fieldKey] = new List<Doc>([...DocListCast(targetDataDoc[this.props.fieldKey]), doc]); // DocAddToList may write to targetdataDoc's parent ... we don't want this. should really change GetProto to GetDataDoc and test for resolvedDataDoc there + // Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc); targetDataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); Doc.GetProto(doc).lastOpened = new DateField; return true; @@ -144,7 +145,8 @@ export class CollectionView extends Touchable<FieldViewProps> { removeDocument(doc: Doc): boolean { const docView = DocumentManager.Instance.getDocumentView(doc, this.props.ContainingCollectionView); docView && SelectionManager.DeselectDoc(docView); - const value = Cast((this.props.DataDoc || this.props.Document)[this.props.fieldKey], listSpec(Doc), []); + const targetDataDoc = this.props.Document.resolvedDataDoc ? this.props.Document : this.props.Document[DataSym]; + const value = Cast(targetDataDoc[this.props.fieldKey], listSpec(Doc), []); let index = value.reduce((p, v, i) => (v instanceof Doc && v === doc) ? i : p, -1); index = index !== -1 ? index : value.reduce((p, v, i) => (v instanceof Doc && Doc.AreProtosEqual(v, doc)) ? i : p, -1); @@ -176,14 +178,14 @@ export class CollectionView extends Touchable<FieldViewProps> { } private SubViewHelper = (type: CollectionViewType, renderProps: CollectionRenderProps) => { - const props = { ...this.props, ...renderProps, chromeCollapsed: this._collapsed, ChromeHeight: this.chromeHeight, CollectionView: this, annotationsKey: "" }; + const props = { ...this.props, ...renderProps, ChromeHeight: this.chromeHeight, CollectionView: this, annotationsKey: "" }; switch (type) { case CollectionViewType.Schema: return (<CollectionSchemaView key="collview" {...props} />); case CollectionViewType.Docking: return (<CollectionDockingView key="collview" {...props} />); case CollectionViewType.Tree: return (<CollectionTreeView key="collview" {...props} />); - case CollectionViewType.Staff: return (<CollectionStaffView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); - case CollectionViewType.Multicolumn: return (<CollectionMulticolumnView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); - case CollectionViewType.Multirow: return (<CollectionMultirowView chromeCollapsed={true} key="rpwview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); + case CollectionViewType.Staff: return (<CollectionStaffView key="collview" {...props} />); + case CollectionViewType.Multicolumn: return (<CollectionMulticolumnView key="collview" {...props} />); + case CollectionViewType.Multirow: return (<CollectionMultirowView key="rpwview" {...props} />); case CollectionViewType.Linear: { return (<CollectionLinearView key="collview" {...props} />); } case CollectionViewType.Carousel: { return (<CollectionCarouselView key="collview" {...props} />); } case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (<CollectionStackingView key="collview" {...props} />); } @@ -202,7 +204,7 @@ export class CollectionView extends Touchable<FieldViewProps> { private SubView = (type: CollectionViewType, renderProps: CollectionRenderProps) => { // currently cant think of a reason for collection docking view to have a chrome. mind may change if we ever have nested docking views -syip - const chrome = this.props.Document._chromeStatus === "disabled" || type === CollectionViewType.Docking ? (null) : + const chrome = this.props.Document._chromeStatus === "disabled" || this.props.Document._chromeStatus === "replaced" || type === CollectionViewType.Docking ? (null) : <CollectionViewBaseChrome CollectionView={this} key="chrome" type={type} collapse={this.collapse} />; return [chrome, this.SubViewHelper(type, renderProps)]; } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index baf09fe5b..050ca8347 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -43,6 +43,9 @@ export interface ViewDefResult { bounds?: ViewDefBounds; } function toLabel(target: FieldResult<Field>) { + if (typeof target === "number" || Number(target)) { + return Number(target).toFixed(2).toString(); + } if (target instanceof ObjectField || target instanceof RefField) { return target[ToString](); } @@ -186,7 +189,7 @@ export function computePivotLayout( const maxColHeight = pivotAxisWidth * expander * Math.ceil(maxInColumn / numCols); const dividers = sortedPivotKeys.map((key, i) => - ({ type: "div", color: "lightGray", x: i * pivotAxisWidth * (numCols * expander + gap), y: -maxColHeight + pivotAxisWidth, width: pivotAxisWidth * numCols * expander, height: maxColHeight, payload: pivotColumnGroups.get(key)!.filters })); + ({ type: "div", color: "lightGray", x: i * pivotAxisWidth * (numCols * expander + gap) - pivotAxisWidth * (expander - 1) / 2, y: -maxColHeight + pivotAxisWidth, width: pivotAxisWidth * numCols * expander, height: maxColHeight, payload: pivotColumnGroups.get(key)!.filters })); groupNames.push(...dividers); return normalizeResults(panelDim, max_text, childPairs, docMap, poolData, viewDefsToJSX, groupNames, 0, [], childDocs.filter(c => !filterDocs.includes(c))); } @@ -211,8 +214,8 @@ export function computeTimelineLayout( const timelineFieldKey = Field.toString(pivotDoc._pivotField as Field); const curTime = toNumber(pivotDoc[fieldKey + "-timelineCur"]); const curTimeSpan = Cast(pivotDoc[fieldKey + "-timelineSpan"], "number", null); - const minTimeReq = curTime === undefined ? Cast(pivotDoc[fieldKey + "-timelineMinReq"], "number", null) : curTimeSpan && (curTime - curTimeSpan); - const maxTimeReq = curTime === undefined ? Cast(pivotDoc[fieldKey + "-timelineMaxReq"], "number", null) : curTimeSpan && (curTime + curTimeSpan); + const minTimeReq = curTimeSpan === undefined ? Cast(pivotDoc[fieldKey + "-timelineMinReq"], "number", null) : curTime && (curTime - curTimeSpan); + const maxTimeReq = curTimeSpan === undefined ? Cast(pivotDoc[fieldKey + "-timelineMaxReq"], "number", null) : curTime && (curTime + curTimeSpan); const fontSize = NumCast(pivotDoc[fieldKey + "-timelineFontSize"], panelDim[1] > 58 ? 20 : Math.max(7, panelDim[1] / 3)); const fontHeight = panelDim[1] > 58 ? 30 : panelDim[1] / 2; const findStack = (time: number, stack: number[]) => { @@ -220,11 +223,11 @@ export function computeTimelineLayout( return index === -1 ? stack.length : index; } - let minTime = Number.MAX_VALUE; - let maxTime = -Number.MAX_VALUE; + let minTime = minTimeReq === undefined ? Number.MAX_VALUE : minTimeReq; + let maxTime = maxTimeReq === undefined ? -Number.MAX_VALUE : maxTimeReq; filterDocs.map(doc => { const num = NumCast(doc[timelineFieldKey], Number(StrCast(doc[timelineFieldKey]))); - if (!(Number.isNaN(num) || (minTimeReq && num < minTimeReq) || (maxTimeReq && num > maxTimeReq))) { + if (!Number.isNaN(num) && (!minTimeReq || num >= minTimeReq) && (!maxTimeReq || num <= maxTimeReq)) { !pivotDateGroups.get(num) && pivotDateGroups.set(num, []); pivotDateGroups.get(num)!.push(doc); minTime = Math.min(num, minTime); @@ -254,10 +257,10 @@ export function computeTimelineLayout( let prevKey = Math.floor(minTime); if (sortedKeys.length && scaling * (sortedKeys[0] - prevKey) > 25) { - groupNames.push({ type: "text", text: prevKey.toString(), x: x, y: 0, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(prevKey), x: x, y: 0, height: fontHeight, fontSize, payload: undefined }); } if (!sortedKeys.length && curTime !== undefined) { - groupNames.push({ type: "text", text: curTime.toString(), x: (curTime - minTime) * scaling, zIndex: 1000, color: "orange", y: 0, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(curTime), x: (curTime - minTime) * scaling, zIndex: 1000, color: "orange", y: 0, height: fontHeight, fontSize, payload: undefined }); } const pivotAxisWidth = NumCast(pivotDoc.pivotTimeWidth, panelDim[1] / 2.5); @@ -265,7 +268,7 @@ export function computeTimelineLayout( let zind = 0; sortedKeys.forEach(key => { if (curTime !== undefined && curTime > prevKey && curTime <= key) { - groupNames.push({ type: "text", text: curTime.toString(), x: (curTime - minTime) * scaling, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: key }); + groupNames.push({ type: "text", text: toLabel(curTime), x: (curTime - minTime) * scaling, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: key }); } const keyDocs = pivotDateGroups.get(key)!; x += scaling * (key - prevKey); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx index bb9ae4326..92fa2781c 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx @@ -8,74 +8,65 @@ import { CollectionViewProps } from "../CollectionSubView"; import "./CollectionFreeFormView.scss"; import React = require("react"); import v5 = require("uuid/v5"); +import { computed } from "mobx"; +import { FieldResult } from "../../../../new_fields/Doc"; +import { List } from "../../../../new_fields/List"; @observer export class CollectionFreeFormRemoteCursors extends React.Component<CollectionViewProps> { - protected getCursors(): CursorField[] { + @computed protected get cursors(): CursorField[] { const doc = this.props.Document; - const id = CurrentUserUtils.id; - if (!id) { + let cursors: FieldResult<List<CursorField>>; + const { id } = CurrentUserUtils; + if (!id || !(cursors = Cast(doc.cursors, listSpec(CursorField)))) { return []; } - - const cursors = Cast(doc.cursors, listSpec(CursorField)); - const now = mobxUtils.now(); - // const now = Date.now(); - return (cursors || []).filter(cursor => cursor.data.metadata.id !== id && (now - cursor.data.metadata.timestamp) < 1000); + return (cursors || []).filter(({ data: { metadata } }) => metadata.id !== id && (now - metadata.timestamp) < 1000); } - private crosshairs?: HTMLCanvasElement; - drawCrosshairs = (backgroundColor: string) => { - if (this.crosshairs) { - const ctx = this.crosshairs.getContext('2d'); - if (ctx) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, 20, 20); - - ctx.fillStyle = "black"; - ctx.lineWidth = 0.5; - - ctx.beginPath(); + @computed get renderedCursors() { + return this.cursors.map(({ data: { metadata, position: { x, y } } }) => { + return ( + <div key={metadata.id} className="collectionFreeFormRemoteCursors-cont" + style={{ transform: `translate(${x - 10}px, ${y - 10}px)` }} + > + <canvas className="collectionFreeFormRemoteCursors-canvas" + ref={(el) => { + if (el) { + const ctx = el.getContext('2d'); + if (ctx) { + ctx.fillStyle = "#" + v5(metadata.id, v5.URL).substring(0, 6).toUpperCase() + "22"; + ctx.fillRect(0, 0, 20, 20); - ctx.moveTo(10, 0); - ctx.lineTo(10, 8); + ctx.fillStyle = "black"; + ctx.lineWidth = 0.5; - ctx.moveTo(10, 20); - ctx.lineTo(10, 12); + ctx.beginPath(); - ctx.moveTo(0, 10); - ctx.lineTo(8, 10); + ctx.moveTo(10, 0); + ctx.lineTo(10, 8); - ctx.moveTo(20, 10); - ctx.lineTo(12, 10); + ctx.moveTo(10, 20); + ctx.lineTo(10, 12); - ctx.stroke(); + ctx.moveTo(0, 10); + ctx.lineTo(8, 10); - // ctx.font = "10px Arial"; - // ctx.fillText(Doc.CurrentUserEmail[0].toUpperCase(), 10, 10); - } - } - } + ctx.moveTo(20, 10); + ctx.lineTo(12, 10); - get sharedCursors() { - return this.getCursors().map(c => { - const m = c.data.metadata; - const l = c.data.position; - this.drawCrosshairs("#" + v5(m.id, v5.URL).substring(0, 6).toUpperCase() + "22"); - return ( - <div key={m.id} className="collectionFreeFormRemoteCursors-cont" - style={{ transform: `translate(${l.x - 10}px, ${l.y - 10}px)` }} - > - <canvas className="collectionFreeFormRemoteCursors-canvas" - ref={(el) => { if (el) this.crosshairs = el; }} + ctx.stroke(); + } + } + }} width={20} height={20} /> <p className="collectionFreeFormRemoteCursors-symbol"> - {m.identifier[0].toUpperCase()} + {metadata.identifier[0].toUpperCase()} </p> </div> ); @@ -83,6 +74,6 @@ export class CollectionFreeFormRemoteCursors extends React.Component<CollectionV } render() { - return this.sharedCursors; + return this.renderedCursors; } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 2518a4a55..8f0486b5b 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,20 +1,22 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faEye } from "@fortawesome/free-regular-svg-icons"; import { faBraille, faChalkboard, faCompass, faCompressArrowsAlt, faExpandArrowsAlt, faFileUpload, faPaintBrush, faTable, faUpload } from "@fortawesome/free-solid-svg-icons"; -import { action, computed, observable, ObservableMap, reaction, runInAction, IReactionDisposer, trace } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, HeightSym, Opt, WidthSym, DocListCastAsync, Field } from "../../../../new_fields/Doc"; +import { computedFn } from "mobx-utils"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../../new_fields/Doc"; import { documentSchema, positionSchema } from "../../../../new_fields/documentSchemas"; import { Id } from "../../../../new_fields/FieldSymbols"; -import { InkTool, InkField, InkData } from "../../../../new_fields/InkField"; -import { createSchema, makeInterface, listSpec } from "../../../../new_fields/Schema"; +import { InkTool } from "../../../../new_fields/InkField"; +import { createSchema, listSpec, makeInterface } from "../../../../new_fields/Schema"; import { ScriptField } from "../../../../new_fields/ScriptField"; -import { BoolCast, Cast, DateCast, NumCast, StrCast, ScriptCast } from "../../../../new_fields/Types"; +import { Cast, NumCast, ScriptCast, StrCast } from "../../../../new_fields/Types"; +import { TraceMobx } from "../../../../new_fields/util"; +import { GestureUtils } from "../../../../pen-gestures/GestureUtils"; import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; -import { aggregateBounds, emptyFunction, intersectRect, returnOne, Utils } from "../../../../Utils"; +import { aggregateBounds, intersectRect, returnOne, Utils } from "../../../../Utils"; import { DocServer } from "../../../DocServer"; -import { Docs, DocUtils } from "../../../documents/Documents"; -import { DocumentType } from "../../../documents/DocumentTypes"; +import { Docs } from "../../../documents/Documents"; import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from "../../../util/DragManager"; import { HistoryUtil } from "../../../util/History"; @@ -32,15 +34,12 @@ import { FormattedTextBox } from "../../nodes/FormattedTextBox"; import { pageSchema } from "../../nodes/ImageBox"; import PDFMenu from "../../pdf/PDFMenu"; import { CollectionSubView } from "../CollectionSubView"; -import { computePivotLayout, ViewDefResult, computeTimelineLayout, PoolData, ViewDefBounds } from "./CollectionFreeFormLayoutEngines"; +import { computePivotLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult } from "./CollectionFreeFormLayoutEngines"; import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCursors"; import "./CollectionFreeFormView.scss"; import MarqueeOptionsMenu from "./MarqueeOptionsMenu"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); -import { computedFn } from "mobx-utils"; -import { TraceMobx } from "../../../../new_fields/util"; -import { GestureUtils } from "../../../../pen-gestures/GestureUtils"; library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload); @@ -698,7 +697,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } - setScaleToZoom = (doc: Doc, scale: number = 0.5) => { + setScaleToZoom = (doc: Doc, scale: number = 0.75) => { this.Document.scale = scale * Math.min(this.props.PanelWidth() / NumCast(doc._width), this.props.PanelHeight() / NumCast(doc._height)); } @@ -807,7 +806,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { doFreeformLayout(poolData: Map<string, any>) { const layoutDocs = this.childLayoutPairs.map(pair => pair.layout); const initResult = this.Document.arrangeInit && this.Document.arrangeInit.script.run({ docs: layoutDocs, collection: this.Document }, console.log); - let state = initResult && initResult.success ? initResult.result.scriptState : undefined; + const state = initResult && initResult.success ? initResult.result.scriptState : undefined; const elements = initResult && initResult.success ? this.viewDefsToJSX(initResult.result.views) : []; this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => { @@ -827,7 +826,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } @computed get filterDocs() { - const docFilters = Cast(this.props.Document._docFilter, listSpec("string"), []); + const docFilters = Cast(this.props.Document._docFilters, listSpec("string"), []); + const docRangeFilters = Cast(this.props.Document._docRangeFilters, listSpec("string"), []); const clusters: { [key: string]: { [value: string]: string } } = {}; for (let i = 0; i < docFilters.length; i += 3) { const [key, value, modifiers] = docFilters.slice(i, i + 3); @@ -853,7 +853,19 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } return true; }) : this.childDocs; - return filteredDocs; + const rangeFilteredDocs = filteredDocs.filter(d => { + for (let i = 0; i < docRangeFilters.length; i += 3) { + const key = docRangeFilters[i]; + const min = Number(docRangeFilters[i + 1]); + const max = Number(docRangeFilters[i + 2]); + const val = Cast(d[key], "number", null); + if (val !== undefined && (val < min || val > max)) { + return false; + } + } + return true; + }); + return rangeFilteredDocs; } get doLayoutComputation() { const { newPool, computedElementData } = this.doInternalLayoutComputation; diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index e16f4011e..4b0855635 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -346,8 +346,6 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque summary = (e: KeyboardEvent | React.PointerEvent | undefined) => { const bounds = this.Bounds; const selected = this.marqueeSelect(false); - const newCollection = this.getCollection(selected, false); - selected.map(d => { this.props.removeDocument(d); d.x = NumCast(d.x) - bounds.left - bounds.width / 2; @@ -355,23 +353,9 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque d.page = -1; return d; }); - newCollection._chromeStatus = "disabled"; - const summary = Docs.Create.TextDocument("", { x: bounds.left, y: bounds.top, _width: 300, _height: 100, _autoHeight: true, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); - Doc.GetProto(summary).summarizedDocs = new List<Doc>([newCollection]); - newCollection.x = bounds.left + bounds.width; - Doc.GetProto(newCollection).summaryDoc = summary; - Doc.GetProto(newCollection).title = ComputedField.MakeFunction(`summaryTitle(this);`); - if (e instanceof KeyboardEvent ? e.key === "s" : true) { // summary is wrapped in an expand/collapse container that also contains the summarized documents in a free form view. - const container = Docs.Create.FreeformDocument([summary, newCollection], { - x: bounds.left, y: bounds.top, _width: 300, _height: 200, _autoHeight: true, - _viewType: CollectionViewType.Stacking, _chromeStatus: "disabled", title: "-summary-" - }); - Doc.GetProto(summary).maximizeLocation = "inPlace"; // or "onRight" - this.props.addLiveTextDocument(container); - } else if (e instanceof KeyboardEvent ? e.key === "S" : false) { // the summary stands alone, but is linked to a collection of the summarized documents - set the OnCLick behavior to link follow to access them - Doc.GetProto(summary).maximizeLocation = "inTab"; // or "inPlace", or "onRight" - this.props.addLiveTextDocument(summary); - } + const summary = Docs.Create.TextDocument("", { x: bounds.left, y: bounds.top, _width: 200, _height: 200, _fitToBox: true, _showSidebar: true, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); + Doc.GetProto(summary)["data-annotations"] = new List<Doc>(selected); + this.props.addLiveTextDocument(summary); MarqueeOptionsMenu.Instance.fadeOut(true); } diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss index 0c74b8ddb..821c8d804 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss @@ -1,8 +1,8 @@ .collectionMulticolumnView_contents { display: flex; + overflow: hidden; width: 100%; height: 100%; - overflow: hidden; .document-wrapper { display: flex; diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss index 64f607680..a7e2c5707 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss @@ -1,8 +1,8 @@ .collectionMultirowView_contents { display: flex; + overflow: hidden; width: 100%; height: 100%; - overflow: hidden; flex-direction: column; .document-wrapper { diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index ff7c4998f..ec05443e5 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -258,6 +258,8 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) return ( <div className={"collectionMultirowView_contents"} style={{ + width: `calc(100% - ${2 * NumCast(this.props.Document._xMargin)}px)`, + height: `calc(100% - ${2 * NumCast(this.props.Document._yMargin)}px)`, marginLeft: NumCast(this.props.Document._xMargin), marginRight: NumCast(this.props.Document._xMargin), marginTop: NumCast(this.props.Document._yMargin), marginBottom: NumCast(this.props.Document._yMargin) }} ref={this.createDashEventsTarget}> diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 3bceec45f..3c27ac9a1 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -84,7 +84,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF borderRadius: StrCast(Doc.Layout(this.layoutDoc).borderRounding), outline: this.Highlight ? "orange solid 2px" : "", transform: this.transform, - transition: this.Document.isAnimating ? ".5s ease-in" : this.props.transition ? this.props.transition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.transition), + transition: this.props.transition ? this.props.transition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.transition), width: this.width, height: this.height, zIndex: this.ZInd, diff --git a/src/client/views/nodes/ContentFittingDocumentView.tsx b/src/client/views/nodes/ContentFittingDocumentView.tsx index bd1b6166f..671f5b96e 100644 --- a/src/client/views/nodes/ContentFittingDocumentView.tsx +++ b/src/client/views/nodes/ContentFittingDocumentView.tsx @@ -2,7 +2,7 @@ import React = require("react"); import { action, computed } from "mobx"; import { observer } from "mobx-react"; import "react-table/react-table.css"; -import { Doc } from "../../../new_fields/Doc"; +import { Doc, Opt } from "../../../new_fields/Doc"; import { ComputedField, ScriptField } from "../../../new_fields/ScriptField"; import { NumCast, StrCast } from "../../../new_fields/Types"; import { emptyFunction, returnEmptyString, returnOne } from "../../../Utils"; @@ -18,6 +18,7 @@ import { TraceMobx } from "../../../new_fields/util"; interface ContentFittingDocumentViewProps { Document?: Doc; DataDocument?: Doc; + LayoutDoc?: () => Opt<Doc>; LibraryPath: Doc[]; childDocs?: Doc[]; renderDepth: number; @@ -42,7 +43,7 @@ interface ContentFittingDocumentViewProps { @observer export class ContentFittingDocumentView extends React.Component<ContentFittingDocumentViewProps>{ public get displayName() { return "DocumentView(" + this.props.Document?.title + ")"; } // this makes mobx trace() statements more descriptive - private get layoutDoc() { return this.props.Document && Doc.Layout(this.props.Document); } + private get layoutDoc() { return this.props.Document && (this.props.LayoutDoc?.() || Doc.Layout(this.props.Document)); } private get nativeWidth() { return NumCast(this.layoutDoc?._nativeWidth, this.props.PanelWidth()); } private get nativeHeight() { return NumCast(this.layoutDoc?._nativeHeight, this.props.PanelHeight()); } @computed get scaling() { @@ -97,6 +98,7 @@ export class ContentFittingDocumentView extends React.Component<ContentFittingDo <DocumentView {...this.props} Document={this.props.Document} DataDoc={this.props.DataDocument} + LayoutDoc={this.props.LayoutDoc} LibraryPath={this.props.LibraryPath} fitToBox={this.props.fitToBox} onClick={this.props.onClick} diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index ac80e2ec1..11a33b70a 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -1,7 +1,6 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; import { Doc } from "../../../new_fields/Doc"; -import { ScriptField } from "../../../new_fields/ScriptField"; import { Cast, StrCast } from "../../../new_fields/Types"; import { OmitKeys, Without } from "../../../Utils"; import { HistogramBox } from "../../northstar/dash-nodes/HistogramBox"; @@ -14,13 +13,13 @@ import { LinkFollowBox } from "../linking/LinkFollowBox"; import { YoutubeBox } from "./../../apis/youtube/YoutubeBox"; import { AudioBox } from "./AudioBox"; import { ButtonBox } from "./ButtonBox"; +import { SliderBox } from "./SliderBox"; import { DocumentBox } from "./DocumentBox"; import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { FontIconBox } from "./FontIconBox"; import { FieldView, FieldViewProps } from "./FieldView"; import { FormattedTextBox } from "./FormattedTextBox"; -import { IconBox } from "./IconBox"; import { ImageBox } from "./ImageBox"; import { KeyValueBox } from "./KeyValueBox"; import { PDFBox } from "./PDFBox"; @@ -83,10 +82,10 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { return this.props.DataDoc instanceof Promise ? undefined : this.props.DataDoc; } get layoutDoc() { - if (this.props.DataDoc === undefined && typeof Doc.LayoutField(this.props.Document) !== "string") { + if (this.props.DataDoc === undefined && (this.props.LayoutDoc || typeof Doc.LayoutField(this.props.Document) !== "string")) { // if there is no dataDoc (ie, we're not rendering a template layout), but this document has a layout document (not a layout string), // then we render the layout document as a template and use this document as the data context for the template layout. - return Doc.expandTemplateLayout(Doc.Layout(this.props.Document), this.props.Document); + return Doc.expandTemplateLayout(this.props.LayoutDoc?.() || Doc.Layout(this.props.Document), this.props.Document); } return Doc.Layout(this.props.Document); } @@ -106,7 +105,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { <ObserverJsxParser blacklistedAttrs={[]} components={{ - FormattedTextBox, ImageBox, IconBox, DirectoryImportBox, FontIconBox: FontIconBox, ButtonBox, FieldView, + FormattedTextBox, ImageBox, DirectoryImportBox, FontIconBox, ButtonBox, SliderBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox, PresBox, YoutubeBox, LinkFollowBox, PresElementBox, QueryBox, ColorBox, DashWebRTCVideo, DocuLinkBox, InkingStroke, DocumentBox diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index b9045b11e..b121c6c18 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -71,7 +71,6 @@ width: 100%; height: 25; background: rgba(0, 0, 0, .4); - padding: 4px; text-align: center; text-overflow: ellipsis; white-space: pre; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 787f6b79b..84a4e2649 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1,36 +1,42 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import * as fa from '@fortawesome/free-solid-svg-icons'; -import { action, computed, runInAction, trace } from "mobx"; +import { action, computed, runInAction, observable } from "mobx"; import { observer } from "mobx-react"; import * as rp from "request-promise"; import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc"; import { Document, PositionDocument } from '../../../new_fields/documentSchemas'; import { Id } from '../../../new_fields/FieldSymbols'; +import { InkTool } from '../../../new_fields/InkField'; +import { List } from '../../../new_fields/List'; +import { RichTextField } from '../../../new_fields/RichTextField'; import { listSpec } from "../../../new_fields/Schema"; import { ScriptField } from '../../../new_fields/ScriptField'; import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { ImageField, PdfField, VideoField, AudioField } from '../../../new_fields/URLField'; +import { AudioField, ImageField, PdfField, VideoField } from '../../../new_fields/URLField'; +import { TraceMobx } from '../../../new_fields/util'; +import { GestureUtils } from '../../../pen-gestures/GestureUtils'; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { emptyFunction, returnTransparent, returnTrue, Utils, returnOne } from "../../../Utils"; +import { emptyFunction, returnOne, returnTransparent, returnTrue, Utils } from "../../../Utils"; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { DocServer } from "../../DocServer"; -import { Docs, DocUtils, DocumentOptions } from "../../documents/Documents"; +import { Docs, DocumentOptions, DocUtils } from "../../documents/Documents"; import { DocumentType } from '../../documents/DocumentTypes'; import { ClientUtils } from '../../util/ClientUtils'; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, dropActionType } from "../../util/DragManager"; +import { InteractionUtils } from '../../util/InteractionUtils'; import { Scripting } from '../../util/Scripting'; import { SelectionManager } from "../../util/SelectionManager"; import SharingManager from '../../util/SharingManager'; import { Transform } from "../../util/Transform"; import { undoBatch, UndoManager } from "../../util/UndoManager"; -import { CollectionViewType } from '../collections/CollectionView'; import { CollectionDockingView } from "../collections/CollectionDockingView"; -import { CollectionView } from "../collections/CollectionView"; +import { CollectionView, CollectionViewType } from '../collections/CollectionView'; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; import { DocComponent } from "../DocComponent"; import { EditableView } from '../EditableView'; +import { InkingControl } from '../InkingControl'; import { OverlayView } from '../OverlayView'; import { ScriptBox } from '../ScriptBox'; import { ScriptingRepl } from '../ScriptingRepl'; @@ -38,19 +44,7 @@ import { DocumentContentsView } from "./DocumentContentsView"; import "./DocumentView.scss"; import { FormattedTextBox } from './FormattedTextBox'; import React = require("react"); -import { InteractionUtils } from '../../util/InteractionUtils'; -import { InkingControl } from '../InkingControl'; -import { InkTool } from '../../../new_fields/InkField'; -import { TraceMobx } from '../../../new_fields/util'; -import { List } from '../../../new_fields/List'; -import { FormattedTextBoxComment } from './FormattedTextBoxComment'; -import { GestureUtils } from '../../../pen-gestures/GestureUtils'; -import { RadialMenu } from './RadialMenu'; -import { RadialMenuProps } from './RadialMenuItem'; -import { CollectionStackingView } from '../collections/CollectionStackingView'; -import { RichTextField } from '../../../new_fields/RichTextField'; -import { HistoryUtil } from '../../util/History'; library.add(fa.faEdit, fa.faTrash, fa.faShare, fa.faDownload, fa.faExpandArrowsAlt, fa.faCompressArrowsAlt, fa.faLayerGroup, fa.faExternalLinkAlt, fa.faAlignCenter, fa.faCaretSquareRight, fa.faSquare, fa.faConciergeBell, fa.faWindowRestore, fa.faFolder, fa.faMapPin, fa.faLink, fa.faFingerprint, fa.faCrosshairs, fa.faDesktop, fa.faUnlock, fa.faLock, fa.faLaptopCode, fa.faMale, @@ -61,6 +55,7 @@ export interface DocumentViewProps { ContainingCollectionDoc: Opt<Doc>; Document: Doc; DataDoc?: Doc; + LayoutDoc?: () => Opt<Doc>; LibraryPath: Doc[]; fitToBox?: boolean; onClick?: ScriptField; @@ -269,12 +264,10 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu SelectionManager.DeselectAll(); Doc.UnBrushDoc(this.props.Document); } else if (this.onClickHandler && this.onClickHandler.script) { + SelectionManager.DeselectAll(); this.onClickHandler.script.run({ this: this.Document.isTemplateForField && this.props.DataDoc ? this.props.DataDoc : this.props.Document, containingCollection: this.props.ContainingCollectionDoc, shiftKey: e.shiftKey }, console.log); } else if (this.Document.type === DocumentType.BUTTON) { ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", e.clientX, e.clientY); - } else if (this.props.Document.isButton === "Selector") { // this should be moved to an OnClick script - FormattedTextBoxComment.Hide(); - this.Document.links?.[0] instanceof Doc && (Doc.UserDoc().SelectedDocs = new List([Doc.LinkOtherAnchor(this.Document.links[0], this.props.Document)])); } else if (this.Document.isButton) { SelectionManager.SelectDoc(this, e.ctrlKey); // don't think this should happen if a button action is actually triggered. this.buttonClick(e.altKey, e.ctrlKey); @@ -284,29 +277,11 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } preventDefault && e.preventDefault(); } - }) + }); buttonClick = async (altKey: boolean, ctrlKey: boolean) => { - const maximizedDocs = await DocListCastAsync(this.Document.maximizedDocs); - const summarizedDocs = await DocListCastAsync(this.Document.summarizedDocs); const linkDocs = DocListCast(this.props.Document.links); - let expandedDocs: Doc[] = []; - expandedDocs = maximizedDocs ? [...maximizedDocs, ...expandedDocs] : expandedDocs; - expandedDocs = summarizedDocs ? [...summarizedDocs, ...expandedDocs] : expandedDocs; - // let expandedDocs = [ ...(maximizedDocs ? maximizedDocs : []), ...(summarizedDocs ? summarizedDocs : []),]; - if (expandedDocs.length) { - SelectionManager.DeselectAll(); - let maxLocation = StrCast(this.Document.maximizeLocation, "inPlace"); - maxLocation = this.Document.maximizeLocation = (!ctrlKey ? !altKey ? maxLocation : (maxLocation !== "inPlace" ? "inPlace" : "onRight") : (maxLocation !== "inPlace" ? "inPlace" : "inTab")); - if (maxLocation === "inPlace") { - expandedDocs.forEach(maxDoc => this.props.addDocument && this.props.addDocument(maxDoc)); - const scrpt = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).inverse().transformPoint(NumCast(this.layoutDoc.width) / 2, NumCast(this.layoutDoc.height) / 2); - DocumentManager.Instance.animateBetweenPoint(scrpt, expandedDocs); - } else { - expandedDocs.forEach(maxDoc => (!this.props.addDocTab(maxDoc, undefined, "close") && this.props.addDocTab(maxDoc, undefined, maxLocation))); - } - } - else if (linkDocs.length) { + if (linkDocs.length) { DocumentManager.Instance.FollowLink(undefined, this.props.Document, // open up target if it's not already in view ... by zooming into the button document first and setting flag to reset zoom afterwards (doc: Doc, maxLocation: string) => this.props.focus(this.props.Document, true, 1, () => this.props.addDocTab(doc, undefined, maxLocation)), @@ -563,7 +538,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const docTemplate = docLayoutTemplate || creator(fieldTemplate ? [fieldTemplate] : [], { title: customName + "(" + doc.title + ")", isTemplateDoc: true, _width: _width + 20, _height: Math.max(100, _height + 45) }); fieldTemplate && Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate)); - Doc.ApplyTemplateTo(docTemplate, dataDoc || doc, customName, undefined); + Doc.ApplyTemplateTo(docTemplate, doc, customName, undefined); } else { doc.layoutKey = customName; } @@ -617,13 +592,17 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } @undoBatch - @action - freezeNativeDimensions = (): void => { - this.layoutDoc._autoHeight = false; - this.layoutDoc.ignoreAspect = !this.layoutDoc.ignoreAspect; - if (!this.layoutDoc.ignoreAspect && !this.layoutDoc._nativeWidth) { - this.layoutDoc._nativeWidth = this.props.PanelWidth(); - this.layoutDoc._nativeHeight = this.props.PanelHeight(); + public static unfreezeNativeDimensions = action((layoutDoc: Doc): void => { + layoutDoc._nativeWidth = undefined; + layoutDoc._nativeHeight = undefined; + }); + + toggleNativeDimensions = () => { + if (this.Document._nativeWidth || this.Document._nativeHeight) { + DocumentView.unfreezeNativeDimensions(this.layoutDoc); + } + else { + Doc.freezeNativeDimensions(this.layoutDoc, this.props.PanelWidth(), this.props.PanelHeight()); } } @@ -657,7 +636,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu if (StrCast(tempDoc.title) === layout) { foundLayout = tempDoc; } - }) + }); DocumentView. makeCustomViewClicked(this.props.Document, this.props.DataDoc, Docs.Create.StackingDocument, layout, foundLayout); } else { @@ -715,7 +694,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); - onClicks.push({ description: "Toggle Detail", event: () => this.Document.onClick = ScriptField.MakeScript("toggleDetail(this)"), icon: "window-restore" }); + onClicks.push({ description: "Toggle Detail", event: () => this.Document.onClick = ScriptField.MakeScript(`toggleDetail(this, "${this.props.Document.layoutKey}")`), icon: "window-restore" }); onClicks.push({ description: this.Document.ignoreClick ? "Select" : "Do Nothing", event: () => this.Document.ignoreClick = !this.Document.ignoreClick, icon: this.Document.ignoreClick ? "unlock" : "lock" }); onClicks.push({ description: this.Document.isButton || this.Document.onClick ? "Remove Click Behavior" : "Follow Link", event: this.makeBtnClicked, icon: "concierge-bell" }); onClicks.push({ description: this.props.Document.isButton ? "Remove Select Link Behavior" : "Select Link", event: this.makeSelBtnClicked, icon: "concierge-bell" }); @@ -737,7 +716,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu layoutItems.push({ description: `${this.Document._chromeStatus !== "disabled" ? "Hide" : "Show"} Chrome`, event: () => this.Document._chromeStatus = (this.Document._chromeStatus !== "disabled" ? "disabled" : "enabled"), icon: "project-diagram" }); layoutItems.push({ description: `${this.Document._autoHeight ? "Variable Height" : "Auto Height"}`, event: () => this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight, icon: "plus" }); - layoutItems.push({ description: this.Document.ignoreAspect || !this.Document._nativeWidth || !this.Document._nativeHeight ? "Freeze" : "Unfreeze", event: this.freezeNativeDimensions, icon: "snowflake" }); + layoutItems.push({ description: !this.Document._nativeWidth || !this.Document._nativeHeight ? "Freeze" : "Unfreeze", event: this.toggleNativeDimensions, icon: "snowflake" }); layoutItems.push({ description: this.Document.lockedPosition ? "Unlock Position" : "Lock Position", event: this.toggleLockPosition, icon: BoolCast(this.Document.lockedPosition) ? "unlock" : "lock" }); layoutItems.push({ description: this.Document.lockedTransform ? "Unlock Transform" : "Lock Transform", event: this.toggleLockTransform, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); layoutItems.push({ description: "Center View", event: () => this.props.focus(this.props.Document, false), icon: "crosshairs" }); @@ -817,7 +796,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } }); const path = this.props.LibraryPath.reduce((p: string, d: Doc) => p + "/" + (Doc.AreProtosEqual(d, (Doc.UserDoc().LibraryBtn as Doc).sourcePanel as Doc) ? "" : d.title), ""); - cm.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin" }); cm.addItem({ description: `path: ${path}`, event: () => { this.props.LibraryPath.map(lp => Doc.GetProto(lp).treeViewOpen = lp.treeViewOpen = true); @@ -856,6 +834,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu ContainingCollectionDoc={this.props.ContainingCollectionDoc} Document={this.props.Document} DataDoc={this.props.DataDoc} + LayoutDoc={this.props.LayoutDoc} fitToBox={this.props.fitToBox} LibraryPath={this.props.LibraryPath} addDocument={this.props.addDocument} @@ -938,7 +917,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu this.contents : <div className="documentView-styleWrapper" > - <div className="documentView-styleContentWrapper" style={{ height: showTextTitle ? "calc(100% - 29px)" : "100%", top: showTextTitle ? "29px" : undefined }}> + <div className="documentView-styleContentWrapper" style={{ height: showTextTitle ? "calc(100% - 25px)" : "100%", top: showTextTitle ? "25px" : undefined }}> {this.contents} </div> {titleView} @@ -952,6 +931,20 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu return (this.Document.isBackground && !this.isSelected()) || (this.Document.type === DocumentType.INK && InkingControl.Instance.selectedTool !== InkTool.None); } + @observable _animate = 0; + switchViews = action((custom: boolean, view: string) => { + SelectionManager.SetIsDragging(true); + this._animate = 0.1; + setTimeout(action(() => { + this.setCustomView(custom, view); + this._animate = 1; + setTimeout(action(() => { + this._animate = 0; + SelectionManager.SetIsDragging(false); + }), 400); + }), 400); + }); + render() { if (!(this.props.Document instanceof Doc)) return (null); const colorSet = this.setsLayoutProp("backgroundColor"); @@ -972,7 +965,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} onPointerEnter={e => Doc.BrushDoc(this.props.Document)} onPointerLeave={e => Doc.UnBrushDoc(this.props.Document)} style={{ - transition: this.Document.isAnimating ? ".5s linear" : StrCast(this.Document.transition), + transformOrigin: this._animate ? "center center" : undefined, + transform: this._animate ? `scale(${this._animate})` : undefined, + transition: !this._animate ? StrCast(this.Document.transition) : this._animate < 1 ? "transform 0.5s ease-in" : "transform 0.5s ease-out", pointerEvents: this.ignorePointerEvents ? "none" : "all", color: StrCast(this.Document.color), outline: highlighting && !borderRounding ? `${highlightColors[fullDegree]} ${highlightStyles[fullDegree]} ${localScale}px` : "solid 0px", @@ -992,4 +987,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } } -Scripting.addGlobal(function toggleDetail(doc: any) { doc.layoutKey = StrCast(doc.layoutKey, "layout") === "layout" ? "layout_custom" : "layout"; });
\ No newline at end of file +Scripting.addGlobal(function toggleDetail(doc: any, layoutKey: string) { + const dv = DocumentManager.Instance.getDocumentView(doc); + if (dv?.props.Document.layoutKey === layoutKey) dv?.switchViews(false, ""); + else dv?.switchViews(true, layoutKey.replace("layout_", "")); +});
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index dbbb76f83..8250f41f3 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -5,15 +5,10 @@ import { DateField } from "../../../new_fields/DateField"; import { Doc, FieldResult, Opt } from "../../../new_fields/Doc"; import { IconField } from "../../../new_fields/IconField"; import { List } from "../../../new_fields/List"; -import { RichTextField } from "../../../new_fields/RichTextField"; import { AudioField, ImageField, VideoField } from "../../../new_fields/URLField"; import { Transform } from "../../util/Transform"; import { CollectionView } from "../collections/CollectionView"; import { AudioBox } from "./AudioBox"; -import { FormattedTextBox } from "./FormattedTextBox"; -import { IconBox } from "./IconBox"; -import { ImageBox } from "./ImageBox"; -import { PDFBox } from "./PDFBox"; import { VideoBox } from "./VideoBox"; import { ScriptField } from "../../../new_fields/ScriptField"; @@ -48,6 +43,7 @@ export interface FieldViewProps { setVideoBox?: (player: VideoBox) => void; ContentScaling: () => number; ChromeHeight?: () => number; + childLayoutTemplate?: () => Opt<Doc>; } @observer @@ -78,9 +74,6 @@ export class FieldView extends React.Component<FieldViewProps> { // else if (field instaceof PresBox) { // return <PresBox {...this.props} />; // } - else if (field instanceof IconField) { - return <IconBox {...this.props} />; - } else if (field instanceof VideoField) { return <VideoBox {...this.props} />; } diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 9370d3745..7fbee8881 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -26,7 +26,7 @@ import { DocumentType } from '../../documents/DocumentTypes'; import { DictationManager } from '../../util/DictationManager'; import { DragManager } from "../../util/DragManager"; import buildKeymap from "../../util/ProsemirrorExampleTransfer"; -import { inpRules } from "../../util/RichTextRules"; +import { RichTextRules } from "../../util/RichTextRules"; import { DashDocCommentView, FootnoteView, ImageResizeView, DashDocView, OrderedListView, schema, SummaryView, DashFieldView } from "../../util/RichTextSchema"; import { SelectionManager } from "../../util/SelectionManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; @@ -197,7 +197,8 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } updateTitle = () => { - if (StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.Document.customTitle) { + if ((this.props.Document.isTemplateForField === "data" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing + StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.Document.customTitle) { const str = this._editorView.state.doc.textContent; const titlestr = str.substr(0, Math.min(40, str.length)); this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); @@ -464,13 +465,14 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } _keymap: any = undefined; + _rules: RichTextRules | undefined; @computed get config() { this._keymap = buildKeymap(schema); - (schema as any).Document = this.props.Document; + this._rules = new RichTextRules(this.props.Document, this); return { schema, plugins: [ - inputRules(inpRules), + inputRules(this._rules.inpRules), this.richTextMenuPlugin(), history(), keymap(this._keymap), @@ -574,7 +576,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (ret.frag.size > 2 && ret.start >= 0) { let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start if (ret.frag.firstChild) { - selection = TextSelection.between(editor.state.doc.resolve(ret.start + 2), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected + selection = TextSelection.between(editor.state.doc.resolve(ret.start), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected } editor.dispatch(editor.state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); const mark = editor.state.schema.mark(this._editorView.state.schema.marks.search_highlight); @@ -769,7 +771,6 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & clipboardTextSerializer: this.clipboardTextSerializer, handlePaste: this.handlePaste, }); - this._editorView.state.schema.Document = this.props.Document; const startupText = !rtfField && this._editorView && Field.toString(this.dataDoc[fieldKey] as Field); if (startupText) { this._editorView.dispatch(this._editorView.state.tr.insertText(startupText)); @@ -811,7 +812,11 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } static _downEvent: any; + _downX = 0; + _downY = 0; onPointerDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; this.doLinkOnDeselect(); FormattedTextBox._downEvent = true; FormattedTextBoxComment.textBox = this; @@ -922,7 +927,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & // } // } - this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); + if (Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientX - this._downX) < 4) { + this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); + } if (this._recording) setTimeout(() => { this.stopDictation(true); setTimeout(() => this.recordDictation(), 500); }, 500); } @@ -954,8 +961,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } else if (Math.abs(pos.pos - pos.inside) < 2) { if (!highlightOnly) { - this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside))); + const offset = this._editorView!.state.doc.nodeAt(pos.inside)?.type === schema.nodes.ordered_list ? 1 : 0; + this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside + offset, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside + offset))); } addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } @@ -1010,14 +1018,14 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } const state = this._editorView!.state; if (!state.selection.empty && e.key === "%") { - state.schema.EnteringStyle = true; + this._rules!.EnteringStyle = true; e.preventDefault(); e.stopPropagation(); return; } - if (state.selection.empty || !state.schema.EnteringStyle) { - state.schema.EnteringStyle = false; + if (state.selection.empty || !this._rules!.EnteringStyle) { + this._rules!.EnteringStyle = false; } if (e.key === "Escape") { this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); @@ -1122,8 +1130,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & CollectionView={undefined} ScreenToLocalTransform={this.sidebarScreenToLocal} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> </CollectionFreeFormView> <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> </div>} diff --git a/src/client/views/nodes/IconBox.scss b/src/client/views/nodes/IconBox.scss deleted file mode 100644 index 488681027..000000000 --- a/src/client/views/nodes/IconBox.scss +++ /dev/null @@ -1,23 +0,0 @@ - -@import "../globalCssVariables"; -.iconBox-container { - position: inherit; - left:0; - top:0; - height: auto; - width: max-content; - // overflow: hidden; - pointer-events: all; - svg { - width: $MINIMIZED_ICON_SIZE !important; - height: $MINIMIZED_ICON_SIZE !important; - height: auto; - background: white; - } - .iconBox-label { - position: absolute; - width:max-content; - font-size: 14px; - margin-top: 3px; - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/IconBox.tsx b/src/client/views/nodes/IconBox.tsx deleted file mode 100644 index 172338eb6..000000000 --- a/src/client/views/nodes/IconBox.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React = require("react"); -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faTag, faTextHeight } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { computed, observable, runInAction } from "mobx"; -import { observer } from "mobx-react"; -import { FieldView, FieldViewProps } from './FieldView'; -import "./IconBox.scss"; -import { Cast, StrCast, BoolCast } from "../../../new_fields/Types"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; -import { IconField } from "../../../new_fields/IconField"; -import { ContextMenu } from "../ContextMenu"; -import Measure from "react-measure"; -import { MINIMIZED_ICON_SIZE } from "../../views/globalCssVariables.scss"; -import { Scripting } from "../../util/Scripting"; -import { ComputedField } from "../../../new_fields/ScriptField"; - - -library.add(faCaretUp); -library.add(faObjectGroup); -library.add(faStickyNote); -library.add(faFilePdf); -library.add(faFilm, faTag, faTextHeight); - -@observer -export class IconBox extends React.Component<FieldViewProps> { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(IconBox, fieldKey); } - - @observable _panelWidth: number = 0; - @observable _panelHeight: number = 0; - @computed get layout(): string { const field = Cast(this.props.Document[this.props.fieldKey], IconField); return field ? field.icon : "<p>Error loading icon data</p>"; } - @computed get minimizedIcon() { return IconBox.DocumentIcon(this.layout); } - - public static summaryTitleScript(inputDoc: Doc) { - const sumDoc = Cast(inputDoc.summaryDoc, Doc) as Doc; - if (sumDoc && StrCast(sumDoc.title).startsWith("-")) { - return sumDoc.title + ".expanded"; - } - return "???"; - } - public static titleScript(inputDoc: Doc) { - const maxDoc = DocListCast(inputDoc.maximizedDocs); - if (maxDoc.length === 1) { - return maxDoc[0].title + ".icon"; - } - return maxDoc.length > 1 ? "-multiple-.icon" : "???"; - } - - public static AutomaticTitle(doc: Doc) { - Doc.GetProto(doc).title = ComputedField.MakeFunction('iconTitle(this);'); - } - - public static DocumentIcon(layout: string) { - const button = layout.indexOf("PDFBox") !== -1 ? faFilePdf : - layout.indexOf("ImageBox") !== -1 ? faImage : - layout.indexOf("Formatted") !== -1 ? faStickyNote : - layout.indexOf("Video") !== -1 ? faFilm : - layout.indexOf("Collection") !== -1 ? faObjectGroup : - faCaretUp; - return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />; - } - - setLabelField = (): void => { - this.props.Document.hideLabel = !this.props.Document.hideLabel; - } - - specificContextMenu = (): void => { - const cm = ContextMenu.Instance; - cm.addItem({ description: this.props.Document.hideLabel ? "Show label with icon" : "Remove label from icon", event: this.setLabelField, icon: "tag" }); - if (!this.props.Document.hideLabel) { - cm.addItem({ description: "Use Target Title", event: () => IconBox.AutomaticTitle(this.props.Document), icon: "text-height" }); - } - } - render() { - const label = this.props.Document.hideLabel ? "" : this.props.Document.title; - return ( - <div className="iconBox-container" onContextMenu={this.specificContextMenu}> - {this.minimizedIcon} - <Measure offset onResize={(r) => runInAction(() => { - if (r.offset!.width || this.props.Document.hideLabel) { - this.props.Document.iconWidth = (r.offset!.width + Number(MINIMIZED_ICON_SIZE)); - if (this.props.Document._height === Number(MINIMIZED_ICON_SIZE)) this.props.Document._width = this.props.Document.iconWidth; - } - })}> - {({ measureRef }) => - <span ref={measureRef} className="iconBox-label">{label}</span> - } - </Measure> - </div>); - } -} -Scripting.addGlobal(function iconTitle(doc: any) { return IconBox.titleScript(doc); }); -Scripting.addGlobal(function summaryTitle(doc: any) { return IconBox.summaryTitleScript(doc); });
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 896ac1685..c0e102195 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -207,7 +207,7 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum return url.href; } else if (url.href.indexOf(window.location.origin) === -1) { return Utils.CorsProxy(url.href); - } else if (!(lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg"))) { + } else if (!/\.(png|jpg|jpeg|gif)$/.test(lower)) { return url.href;//Why is this here } const ext = path.extname(url.href); @@ -321,12 +321,12 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum const { dataDoc } = this; const { success, failure, idle, loading } = uploadIcons; runInAction(() => this.uploadIcon = loading); - const [{ clientAccessPath }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [primary] }); + const [{ accessPaths }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [primary] }); dataDoc.originalUrl = primary; let succeeded = true; let data: ImageField | undefined; try { - data = new ImageField(Utils.prepend(clientAccessPath)); + data = new ImageField(Utils.prepend(accessPaths.agnostic.client)); } catch { succeeded = false; } @@ -436,8 +436,7 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum CollectionView={undefined} ScreenToLocalTransform={this.props.ScreenToLocalTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> {this.contentFunc} </CollectionFreeFormView> </div >); diff --git a/src/client/views/nodes/KeyValueBox.scss b/src/client/views/nodes/KeyValueBox.scss index 6e8a36c6a..a26880c9e 100644 --- a/src/client/views/nodes/KeyValueBox.scss +++ b/src/client/views/nodes/KeyValueBox.scss @@ -74,7 +74,7 @@ $header-height: 30px; .keyValueBox-evenRow { position: relative; - display: inline-block; + display: flex; width:100%; height:$header-height; background: $light-color; @@ -114,7 +114,7 @@ $header-height: 30px; .keyValueBox-oddRow { position: relative; - display: inline-block; + display: flex; width:100%; height:30px; background: $light-color-secondary; diff --git a/src/client/views/nodes/PresBox.scss b/src/client/views/nodes/PresBox.scss index 7618aa7e3..6a20751cc 100644 --- a/src/client/views/nodes/PresBox.scss +++ b/src/client/views/nodes/PresBox.scss @@ -2,13 +2,11 @@ position: absolute; z-index: 2; box-shadow: #AAAAAA .2vw .2vw .4vw; - right: 0; - top: 0; bottom: 0; width: 100%; min-width: 120px; height: 100%; - min-height: 50px; + min-height: 41px; letter-spacing: 2px; overflow: hidden; transition: 0.7s opacity ease; @@ -17,6 +15,10 @@ .presBox-buttons { padding: 10px; width: 100%; + background: gray; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 5px; .presBox-button { margin-right: 2.5%; margin-left: 2.5%; diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index 6c4cbba12..d4a47c159 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -2,27 +2,25 @@ import React = require("react"); import { library } from '@fortawesome/fontawesome-svg-core'; import { faArrowLeft, faArrowRight, faEdit, faMinus, faPlay, faPlus, faStop, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, IReactionDisposer, reaction, observable, runInAction } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Doc, DocListCast, DocListCastAsync } from "../../../new_fields/Doc"; -import { listSpec, makeInterface } from "../../../new_fields/Schema"; -import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; +import { InkTool } from "../../../new_fields/InkField"; +import { listSpec } from "../../../new_fields/Schema"; +import { BoolCast, Cast, FieldValue, NumCast } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; +import { returnFalse } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; import { undoBatch } from "../../util/UndoManager"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { CollectionView, CollectionViewType } from "../collections/CollectionView"; import { ContextMenu } from "../ContextMenu"; -import { FieldView, FieldViewProps } from './FieldView'; -import "./PresBox.scss"; -import { CollectionCarouselView } from "../collections/CollectionCarouselView"; -import { returnFalse } from "../../../Utils"; import { ContextMenuProps } from "../ContextMenuItem"; -import { CollectionTimeView } from "../collections/CollectionTimeView"; -import { documentSchema } from "../../../new_fields/documentSchemas"; import { InkingControl } from "../InkingControl"; -import { InkTool } from "../../../new_fields/InkField"; +import { FieldView, FieldViewProps } from './FieldView'; +import "./PresBox.scss"; +import { PrefetchProxy } from "../../../new_fields/Proxy"; library.add(faArrowLeft); library.add(faArrowRight); @@ -37,33 +35,14 @@ library.add(faEdit); export class PresBox extends React.Component<FieldViewProps> { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresBox, fieldKey); } _childReaction: IReactionDisposer | undefined; - _slideshowReaction: IReactionDisposer | undefined; @observable _isChildActive = false; - componentDidMount() { - const userDoc = CurrentUserUtils.UserDocument; - this._slideshowReaction = reaction(() => this.props.Document._slideshow, - (slideshow) => { - if (!slideshow) { - let presTemp = Cast(userDoc.presentationTemplate, Doc); - if (presTemp instanceof Promise) { - presTemp.then(presTemp => this.props.Document.childLayout = presTemp); - } - else if (presTemp === undefined) { - presTemp = userDoc.presentationTemplate = Docs.Create.PresElementBoxDocument({ backgroundColor: "transparent", _xMargin: 5, isTemplateDoc: true, isTemplateForField: "data" }); - } - else { - this.props.Document.childLayout = presTemp; - } - } else { - this.props.Document.childLayout = undefined; - } - }, { fireImmediately: true }); + this.props.Document._forceRenderEngine = "timeline"; + this.props.Document._replacedChrome = "replaced"; this._childReaction = reaction(() => this.childDocs.slice(), (children) => children.forEach((child, i) => child.presentationIndex = i), { fireImmediately: true }); } componentWillUnmount() { this._childReaction?.(); - this._slideshowReaction?.(); } @computed get childDocs() { return DocListCast(this.props.Document[this.props.fieldKey]); } @@ -343,24 +322,27 @@ export class PresBox extends React.Component<FieldViewProps> { }); } - toggleMinimize = undoBatch(action((e: React.PointerEvent) => { - if (this.props.Document.inOverlay) { - Doc.RemoveDocFromList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); - CollectionDockingView.AddRightSplit(this.props.Document, this.props.DataDoc); - this.props.Document.inOverlay = false; - } else { - this.props.Document.x = e.clientX + 25; - this.props.Document.y = e.clientY - 25; - this.props.addDocTab && this.props.addDocTab(this.props.Document, this.props.DataDoc, "close"); - Doc.AddDocToList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); + updateMinimize = undoBatch(action((e: React.ChangeEvent, mode: number) => { + const toggle = BoolCast(this.props.Document.inOverlay) !== (mode === CollectionViewType.Invalid); + if (toggle) { + if (this.props.Document.inOverlay) { + Doc.RemoveDocFromList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); + CollectionDockingView.AddRightSplit(this.props.Document, this.props.DataDoc); + this.props.Document.inOverlay = false; + } else { + this.props.Document.x = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[0];// 500;//e.clientX + 25; + this.props.Document.y = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[1];////e.clientY - 25; + this.props.addDocTab && this.props.addDocTab(this.props.Document, this.props.DataDoc, "close"); + Doc.AddDocToList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); + } } })); specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ description: "Show as Slideshow", event: action(() => this.props.Document._slideshow = "slideshow"), icon: "asterisk" }); - funcs.push({ description: "Show as Timeline", event: action(() => this.props.Document._slideshow = "timeline"), icon: "asterisk" }); - funcs.push({ description: "Show as List", event: action(() => this.props.Document._slideshow = undefined), icon: "asterisk" }); + funcs.push({ description: "Show as Slideshow", event: action(() => this.props.Document._viewType = CollectionViewType.Carousel), icon: "asterisk" }); + funcs.push({ description: "Show as Timeline", event: action(() => this.props.Document._viewType = CollectionViewType.Time), icon: "asterisk" }); + funcs.push({ description: "Show as List", event: action(() => this.props.Document._viewType = CollectionViewType.Invalid), icon: "asterisk" }); ContextMenu.Instance.addItem({ description: "Presentation Funcs...", subitems: funcs, icon: "asterisk" }); } @@ -369,13 +351,11 @@ export class PresBox extends React.Component<FieldViewProps> { * that they will be displayed in a canvas with scale 1. */ initializeScaleViews = (docList: Doc[], viewtype: number) => { - this.props.Document._chromeStatus = "disabled"; const hgt = (viewtype === CollectionViewType.Tree) ? 50 : 46; docList.forEach((doc: Doc) => { doc.presBox = this.props.Document; doc.presBoxKey = this.props.fieldKey; doc.collapsedHeight = hgt; - doc._nativeWidth = doc._nativeHeight = undefined; const curScale = NumCast(doc.viewScale, null); if (curScale === undefined) { doc.viewScale = 1; @@ -389,42 +369,48 @@ export class PresBox extends React.Component<FieldViewProps> { } getTransform = () => { - return this.props.ScreenToLocalTransform().translate(0, -50);// listBox padding-left and pres-box-cont minHeight + return this.props.ScreenToLocalTransform().translate(-5, -65);// listBox padding-left and pres-box-cont minHeight } panelHeight = () => { return this.props.PanelHeight() - 20; } + + @undoBatch + viewChanged = action((e: React.ChangeEvent) => { + //@ts-ignore + this.props.Document._viewType = Number(e.target.selectedOptions[0].value); + this.updateMinimize(e, Number(this.props.Document._viewType)); + }); + + childLayoutTemplate = () => this.props.Document._viewType === CollectionViewType.Stacking ? Cast(Doc.UserDoc().presentationTemplate, Doc, null) : undefined; render() { - this.initializeScaleViews(this.childDocs, NumCast(this.props.Document._viewType)); - return (this.props.Document._slideshow ? - <div className="presBox-cont" onContextMenu={this.specificContextMenu} style={{ pointerEvents: this.active() ? "all" : "none" }} > - {this.props.Document.inOverlay ? (null) : - <div className="presBox-listCont" > - {this.props.Document._slideshow === "slideshow" ? - <CollectionCarouselView {...this.props} PanelHeight={this.panelHeight} chromeCollapsed={true} annotationsKey={""} CollectionView={undefined} - moveDocument={returnFalse} - addDocument={this.addDocument} removeDocument={returnFalse} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - : - <CollectionTimeView {...this.props} PanelHeight={this.panelHeight} chromeCollapsed={true} annotationsKey={""} CollectionView={undefined} - moveDocument={returnFalse} - addDocument={this.addDocument} removeDocument={returnFalse} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - } - </div>} - <button className="presBox-backward" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> - <button className="presBox-forward" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> + const mode = NumCast(this.props.Document._viewType, CollectionViewType.Invalid); + this.initializeScaleViews(this.childDocs, mode); + return <div className="presBox-cont" onContextMenu={this.specificContextMenu} style={{ minWidth: this.props.Document.inOverlay ? 240 : undefined, pointerEvents: this.active() || this.props.Document.inOverlay ? "all" : "none" }} > + <div className="presBox-buttons" style={{ display: this.props.Document._chromeStatus === "disabled" ? "none" : undefined }}> + <select style={{ minWidth: 50, width: "5%", height: "25", position: "relative", display: "inline-block" }} + className="collectionViewBaseChrome-viewPicker" + onPointerDown={e => e.stopPropagation()} + onChange={this.viewChanged} + value={mode}> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Invalid}>Min</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Stacking}>List</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Time}>Time</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Carousel}>Slides</option> + </select> + <button className="presBox-button" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> + <button className="presBox-button" title={"Reset Presentation" + this.props.Document.presStatus ? "" : " From Start"} onClick={this.startOrResetPres}> + <FontAwesomeIcon icon={this.props.Document.presStatus ? "stop" : "play"} /> + </button> + <button className="presBox-button" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> + </div> + <div className="presBox-listCont" > + {mode !== CollectionViewType.Invalid ? + <CollectionView {...this.props} PanelHeight={this.panelHeight} moveDocument={returnFalse} childLayoutTemplate={this.childLayoutTemplate} + addDocument={this.addDocument} removeDocument={returnFalse} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> + : (null) + } </div> - : <div className="presBox-cont" onContextMenu={this.specificContextMenu}> - <div className="presBox-buttons"> - <button className="presBox-button" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> - <button className="presBox-button" title={"Reset Presentation" + this.props.Document.presStatus ? "" : " From Start"} onClick={this.startOrResetPres}> - <FontAwesomeIcon icon={this.props.Document.presStatus ? "stop" : "play"} /> - </button> - <button className="presBox-button" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> - <button className="presBox-button" title={this.props.Document.inOverlay ? "Expand" : "Minimize"} onClick={this.toggleMinimize}><FontAwesomeIcon icon={"eye"} /></button> - {this.props.Document.inOverlay ? (null) : - <div className="presBox-listCont"> - <CollectionView {...this.props} whenActiveChanged={this.whenActiveChanged} PanelHeight={this.panelHeight} addDocument={this.addDocument} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - </div>} - </div></div>); + </div>; } }
\ No newline at end of file diff --git a/src/client/views/nodes/SliderBox-components.tsx b/src/client/views/nodes/SliderBox-components.tsx new file mode 100644 index 000000000..874a1108f --- /dev/null +++ b/src/client/views/nodes/SliderBox-components.tsx @@ -0,0 +1,256 @@ +import * as React from "react"; +import { SliderItem } from "react-compound-slider"; +import "./SliderBox-tooltip.css"; + +const { Component, Fragment } = React; + +// ******************************************************* +// TOOLTIP RAIL +// ******************************************************* +const railStyle: React.CSSProperties = { + position: "absolute", + width: "100%", + height: 40, + top: -13, + borderRadius: 7, + cursor: "pointer", + opacity: 0.3, + zIndex: 300, + border: "1px solid grey" +}; + +const railCenterStyle: React.CSSProperties = { + position: "absolute", + width: "100%", + height: 14, + borderRadius: 7, + cursor: "pointer", + pointerEvents: "none", + backgroundColor: "rgb(155,155,155)" +}; + +interface TooltipRailProps { + activeHandleID: string; + getRailProps: (props: object) => object; + getEventData: (e: Event) => object; +} + +export class TooltipRail extends Component<TooltipRailProps> { + state = { + value: null, + percent: null + }; + + static defaultProps = { + disabled: false + }; + + onMouseEnter = () => { + document.addEventListener("mousemove", this.onMouseMove); + } + + onMouseLeave = () => { + this.setState({ value: null, percent: null }); + document.removeEventListener("mousemove", this.onMouseMove); + } + + onMouseMove = (e: Event) => { + const { activeHandleID, getEventData } = this.props; + + if (activeHandleID) { + this.setState({ value: null, percent: null }); + } else { + this.setState(getEventData(e)); + } + } + + render() { + const { value, percent } = this.state; + const { activeHandleID, getRailProps } = this.props; + + return ( + <Fragment> + {!activeHandleID && value ? ( + <div + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-35px" + }} + > + <div className="tooltip"> + <span className="tooltiptext">Value: {value}</span> + </div> + </div> + ) : null} + <div + style={railStyle} + {...getRailProps({ + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave + })} + /> + <div style={railCenterStyle} /> + </Fragment> + ); + } +} + +// ******************************************************* +// HANDLE COMPONENT +// ******************************************************* +interface HandleProps { + key: string; + handle: SliderItem; + isActive: Boolean; + disabled?: Boolean; + domain: number[]; + getHandleProps: (id: string, config: object) => object; +} + +export class Handle extends Component<HandleProps> { + static defaultProps = { + disabled: false + }; + + state = { + mouseOver: false + }; + + onMouseEnter = () => { + this.setState({ mouseOver: true }); + } + + onMouseLeave = () => { + this.setState({ mouseOver: false }); + } + + render() { + const { + domain: [min, max], + handle: { id, value, percent }, + isActive, + disabled, + getHandleProps + } = this.props; + const { mouseOver } = this.state; + + return ( + <Fragment> + {(mouseOver || isActive) && !disabled ? ( + <div + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-35px" + }} + > + <div className="tooltip"> + <span className="tooltiptext">Value: {value}</span> + </div> + </div> + ) : null} + <div + role="slider" + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value} + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-6px", + zIndex: 400, + width: 24, + height: 24, + cursor: "pointer", + border: 0, + borderRadius: "50%", + boxShadow: "1px 1px 1px 1px rgba(0, 0, 0, 0.4)", + backgroundColor: disabled ? "#666" : "#3e1db3" + }} + {...getHandleProps(id, { + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave + })} + /> + </Fragment> + ); + } +} + +// ******************************************************* +// TRACK COMPONENT +// ******************************************************* +interface TrackProps { + source: SliderItem; + target: SliderItem; + disabled: Boolean; + getTrackProps: () => object; +} + +export function Track({ + source, + target, + getTrackProps, + disabled = false +}: TrackProps) { + return ( + <div + style={{ + position: "absolute", + height: 14, + zIndex: 1, + backgroundColor: disabled ? "#999" : "#3e1db3", + borderRadius: 7, + cursor: "pointer", + left: `${source.percent}%`, + width: `${target.percent - source.percent}%` + }} + {...getTrackProps()} + /> + ); +} + +// ******************************************************* +// TICK COMPONENT +// ******************************************************* +interface TickProps { + tick: SliderItem; + count: number; + format: (val: number) => string; +} + +const defaultFormat = (d: number) => `d`; + +export function Tick({ tick, count, format = defaultFormat }: TickProps) { + return ( + <div> + <div + style={{ + position: "absolute", + marginTop: 17, + width: 1, + height: 5, + backgroundColor: "rgb(200,200,200)", + left: `${tick.percent}%` + }} + /> + <div + style={{ + position: "absolute", + marginTop: 25, + fontSize: 10, + textAlign: "center", + marginLeft: `${-(100 / count) / 2}%`, + width: `${100 / count}%`, + left: `${tick.percent}%` + }} + > + {format(tick.value)} + </div> + </div> + ); +} diff --git a/src/client/views/nodes/SliderBox-tooltip.css b/src/client/views/nodes/SliderBox-tooltip.css new file mode 100644 index 000000000..8afde8eb5 --- /dev/null +++ b/src/client/views/nodes/SliderBox-tooltip.css @@ -0,0 +1,33 @@ +.tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted #222; + margin-left: 22px; + } + + .tooltip .tooltiptext { + width: 100px; + background-color: #222; + color: #fff; + opacity: 0.8; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + bottom: 150%; + left: 50%; + margin-left: -60px; + } + + .tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #222 transparent transparent transparent; + } +
\ No newline at end of file diff --git a/src/client/views/nodes/SliderBox.scss b/src/client/views/nodes/SliderBox.scss new file mode 100644 index 000000000..4ef277d8c --- /dev/null +++ b/src/client/views/nodes/SliderBox.scss @@ -0,0 +1,8 @@ +.sliderBox-outerDiv { + width: 100%; + height: 100%; + pointer-events: all; + border-radius: inherit; + display: flex; + flex-direction: column; +}
\ No newline at end of file diff --git a/src/client/views/nodes/SliderBox.tsx b/src/client/views/nodes/SliderBox.tsx new file mode 100644 index 000000000..844d95d11 --- /dev/null +++ b/src/client/views/nodes/SliderBox.tsx @@ -0,0 +1,130 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit } from '@fortawesome/free-regular-svg-icons'; +import { computed, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { Handles, Rail, Slider, Tracks, Ticks } from 'react-compound-slider'; +import { Doc } from '../../../new_fields/Doc'; +import { documentSchema } from '../../../new_fields/documentSchemas'; +import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; +import { ScriptField } from '../../../new_fields/ScriptField'; +import { BoolCast, FieldValue, StrCast, NumCast, Cast } from '../../../new_fields/Types'; +import { DragManager } from '../../util/DragManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { DocComponent } from '../DocComponent'; +import './SliderBox.scss'; +import { Handle, TooltipRail, Track, Tick } from './SliderBox-components'; +import { FieldView, FieldViewProps } from './FieldView'; +import { ScriptBox } from '../ScriptBox'; + + +library.add(faEdit as any); + +const SliderSchema = createSchema({ + _sliderMin: "number", + _sliderMax: "number", + _sliderMinThumb: "number", + _sliderMaxThumb: "number", +}); + +type SliderDocument = makeInterface<[typeof SliderSchema, typeof documentSchema]>; +const SliderDocument = makeInterface(SliderSchema, documentSchema); + +@observer +export class SliderBox extends DocComponent<FieldViewProps, SliderDocument>(SliderDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SliderBox, fieldKey); } + private dropDisposer?: DragManager.DragDropDisposer; + + @computed get dataDoc() { + return this.props.DataDoc && + (this.Document.isTemplateForField || BoolCast(this.props.DataDoc.isTemplateForField) || + this.props.DataDoc.layout === this.Document) ? this.props.DataDoc : Doc.GetProto(this.Document); + } + + specificContextMenu = (e: React.MouseEvent): void => { + const funcs: ContextMenuProps[] = []; + funcs.push({ description: "Edit Thumb Change Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Thumb Change ...", this.props.Document, "onThumbChange", obj.x, obj.y) }); + ContextMenu.Instance.addItem({ description: "Slider Funcs...", subitems: funcs, icon: "asterisk" }); + } + onChange = (values: readonly number[]) => runInAction(() => { + this.Document._sliderMinThumb = values[0]; + this.Document._sliderMaxThumb = values[1]; + Cast(this.Document.onThumbChanged, ScriptField, null)?.script.run({ range: values, this: this.props.Document }); + }) + + render() { + const domain = [NumCast(this.props.Document._sliderMin), NumCast(this.props.Document._sliderMax)]; + const defaultValues = [NumCast(this.props.Document._sliderMinThumb), NumCast(this.props.Document._sliderMaxThumb)]; + return ( + <div className="sliderBox-outerDiv" onContextMenu={this.specificContextMenu} onPointerDown={e => e.stopPropagation()} + style={{ boxShadow: this.Document.opacity === 0 ? undefined : StrCast(this.Document.boxShadow, "") }}> + <div className="sliderBox-mainButton" onContextMenu={this.specificContextMenu} style={{ + background: this.Document.backgroundColor, color: this.Document.color || "black", + fontSize: this.Document.fontSize, letterSpacing: this.Document.letterSpacing || "" + }} > + <Slider + mode={2} + step={1} + domain={domain} + rootStyle={{ position: "relative", width: "100%" }} + onChange={this.onChange} + values={defaultValues} + > + + <Rail>{railProps => <TooltipRail {...railProps} />}</Rail> + <Handles> + {({ handles, activeHandleID, getHandleProps }) => ( + <div className="slider-handles"> + {handles.map((handle, i) => { + const value = i === 0 ? this.Document._sliderMinThumb : this.Document._sliderMaxThumb; + return ( + <div title={String(value)}> + <Handle + key={handle.id} + handle={handle} + domain={domain} + isActive={handle.id === activeHandleID} + getHandleProps={getHandleProps} + /> + </div> + ); + })} + </div> + )} + </Handles> + <Tracks left={false} right={false}> + {({ tracks, getTrackProps }) => ( + <div className="slider-tracks"> + {tracks.map(({ id, source, target }) => ( + <Track + key={id} + source={source} + target={target} + disabled={false} + getTrackProps={getTrackProps} + /> + ))} + </div> + )} + </Tracks> + <Ticks count={5}> + {({ ticks }) => ( + <div className="slider-tracks"> + {ticks.map((tick) => ( + <Tick + key={tick.id} + tick={tick} + count={ticks.length} + format={(val: number) => val.toString()} + /> + ))} + </div> + )} + </Ticks> + </Slider> + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index d12a8d151..6695e04c3 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -354,8 +354,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum CollectionView={undefined} ScreenToLocalTransform={this.props.ScreenToLocalTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> {this.contentFunc} </CollectionFreeFormView> </div> diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index a48dc286e..7e49d957d 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -211,8 +211,7 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> CollectionView={undefined} ScreenToLocalTransform={this.props.ScreenToLocalTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> {() => [this.content]} </CollectionFreeFormView> </div >); diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index a7c1990e9..4f50be5b0 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -651,8 +651,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument CollectionView={undefined} ScreenToLocalTransform={this.overlayTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionView?.props.Document} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionView?.props.Document}> </CollectionFreeFormView> </div>; } diff --git a/src/client/views/presentationview/PresElementBox.tsx b/src/client/views/presentationview/PresElementBox.tsx index 52773d466..888707ee3 100644 --- a/src/client/views/presentationview/PresElementBox.tsx +++ b/src/client/views/presentationview/PresElementBox.tsx @@ -2,19 +2,18 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faFile as fileRegular } from '@fortawesome/free-regular-svg-icons'; import { faArrowDown, faArrowUp, faFile as fileSolid, faFileDownload, faLocationArrow, faSearch } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, reaction, IReactionDisposer } from "mobx"; +import { action, computed, IReactionDisposer, reaction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Doc } from "../../../new_fields/Doc"; import { documentSchema } from '../../../new_fields/documentSchemas'; import { Id } from "../../../new_fields/FieldSymbols"; import { createSchema, makeInterface } from '../../../new_fields/Schema'; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnFalse, emptyPath } from "../../../Utils"; -import { DocumentType } from "../../documents/DocumentTypes"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { emptyFunction, emptyPath, returnFalse } from "../../../Utils"; import { Transform } from "../../util/Transform"; import { CollectionViewType } from '../collections/CollectionView'; +import { DocExtendableComponent } from '../DocComponent'; import { ContentFittingDocumentView } from '../nodes/ContentFittingDocumentView'; -import { DocComponent, DocExtendableComponent } from '../DocComponent'; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import "./PresElementBox.scss"; import React = require("react"); @@ -50,15 +49,16 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresElementBox, fieldKey); } _heightDisposer: IReactionDisposer | undefined; - @computed get indexInPres() { return NumCast(this.originalLayout?.presentationIndex); } - @computed get presentationDoc() { return Cast(this.originalLayout?.presBox, Doc) as Doc; } - @computed get originalLayout() { return this.props.Document.expandedTemplate as Doc; } - @computed get targetDoc() { return this.originalLayout?.presentationTargetDoc as Doc; } - @computed get currentIndex() { return NumCast(this.presentationDoc?._itemIndex); } + @computed get indexInPres() { return NumCast(this.presElementDoc?.presentationIndex); } + @computed get presBoxDoc() { return Cast(this.presElementDoc?.presBox, Doc) as Doc; } + @computed get presElementDoc() { return this.props.Document.expandedTemplate as Doc; } + @computed get presLayoutDoc() { return this.props.Document; } + @computed get targetDoc() { return this.presElementDoc?.presentationTargetDoc as Doc; } + @computed get currentIndex() { return NumCast(this.presBoxDoc?._itemIndex); } componentDidMount() { - this._heightDisposer = reaction(() => [this.originalLayout.embedOpen, this.originalLayout.collapsedHeight], - params => this.originalLayout._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); + this._heightDisposer = reaction(() => [this.presElementDoc.embedOpen, this.presElementDoc.collapsedHeight], + params => this.presLayoutDoc._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); } componentWillUnmount() { this._heightDisposer?.(); @@ -71,13 +71,13 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onHideDocumentUntilPressClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.hideTillShownButton = !this.originalLayout.hideTillShownButton; - if (!this.originalLayout.hideTillShownButton) { + this.presElementDoc.hideTillShownButton = !this.presElementDoc.hideTillShownButton; + if (!this.presElementDoc.hideTillShownButton) { if (this.indexInPres >= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - if (this.presentationDoc.presStatus && this.indexInPres > this.currentIndex && this.targetDoc) { + if (this.presBoxDoc.presStatus && this.indexInPres > this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 0; } } @@ -91,14 +91,14 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onHideDocumentAfterPresentedClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.hideAfterButton = !this.originalLayout.hideAfterButton; - if (!this.originalLayout.hideAfterButton) { + this.presElementDoc.hideAfterButton = !this.presElementDoc.hideAfterButton; + if (!this.presElementDoc.hideAfterButton) { if (this.indexInPres <= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - if (this.originalLayout.fadeButton) this.originalLayout.fadeButton = false; - if (this.presentationDoc.presStatus && this.indexInPres < this.currentIndex && this.targetDoc) { + if (this.presElementDoc.fadeButton) this.presElementDoc.fadeButton = false; + if (this.presBoxDoc.presStatus && this.indexInPres < this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 0; } } @@ -112,14 +112,14 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onFadeDocumentAfterPresentedClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.fadeButton = !this.originalLayout.fadeButton; - if (!this.originalLayout.fadeButton) { + this.presElementDoc.fadeButton = !this.presElementDoc.fadeButton; + if (!this.presElementDoc.fadeButton) { if (this.indexInPres <= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - this.originalLayout.hideAfterButton = false; - if (this.presentationDoc.presStatus && (this.indexInPres < this.currentIndex) && this.targetDoc) { + this.presElementDoc.hideAfterButton = false; + if (this.presBoxDoc.presStatus && (this.indexInPres < this.currentIndex) && this.targetDoc) { this.targetDoc.opacity = 0.5; } } @@ -131,11 +131,11 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onNavigateDocumentClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.navButton = !this.originalLayout.navButton; - if (this.originalLayout.navButton) { - this.originalLayout.showButton = false; + this.presElementDoc.navButton = !this.presElementDoc.navButton; + if (this.presElementDoc.navButton) { + this.presElementDoc.showButton = false; if (this.currentIndex === this.indexInPres) { - this.props.focus(this.originalLayout); + this.props.focus(this.presElementDoc); } } } @@ -147,13 +147,13 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD onZoomDocumentClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.showButton = !this.originalLayout.showButton; - if (!this.originalLayout.showButton) { - this.originalLayout.viewScale = 1; + this.presElementDoc.showButton = !this.presElementDoc.showButton; + if (!this.presElementDoc.showButton) { + this.presElementDoc.viewScale = 1; } else { - this.originalLayout.navButton = false; + this.presElementDoc.navButton = false; if (this.currentIndex === this.indexInPres) { - this.props.focus(this.originalLayout); + this.props.focus(this.presElementDoc); } } } @@ -162,14 +162,14 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD */ ScreenToLocalListTransform = (xCord: number, yCord: number) => [xCord, yCord]; - embedHeight = () => this.props.PanelHeight() - NumCast(this.originalLayout.collapsedHeight); + embedHeight = () => this.props.PanelHeight() - NumCast(this.presElementDoc.collapsedHeight); embedWidth = () => this.props.PanelWidth() - 20; /** * The function that is responsible for rendering the a preview or not for this * presentation element. */ renderEmbeddedInline = () => { - return !this.originalLayout.embedOpen || !this.targetDoc ? (null) : + return !this.presElementDoc.embedOpen || !this.targetDoc ? (null) : <div className="presElementBox-embedded" style={{ height: this.embedHeight() }}> <ContentFittingDocumentView Document={this.targetDoc} @@ -196,24 +196,24 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD const treecontainer = this.props.ContainingCollectionDoc?._viewType === CollectionViewType.Tree; const className = "presElementBox-item" + (this.currentIndex === this.indexInPres ? " presElementBox-selected" : ""); const pbi = "presElementBox-interaction"; - return !this.originalLayout ? (null) : ( + return !this.presElementDoc ? (null) : ( <div className={className} key={this.props.Document[Id] + this.indexInPres} style={{ outlineWidth: Doc.IsBrushed(this.targetDoc) ? `1px` : "0px", }} - onClick={e => { this.props.focus(this.originalLayout); e.stopPropagation(); }}> + onClick={e => { this.props.focus(this.presElementDoc); e.stopPropagation(); }}> {treecontainer ? (null) : <> <strong className="presElementBox-name"> {`${this.indexInPres + 1}. ${this.targetDoc?.title}`} </strong> - <button className="presElementBox-closeIcon" onPointerDown={e => e.stopPropagation()} onClick={e => this.props.removeDocument && this.props.removeDocument(this.originalLayout)}>X</button> + <button className="presElementBox-closeIcon" onPointerDown={e => e.stopPropagation()} onClick={e => this.props.removeDocument && this.props.removeDocument(this.presElementDoc)}>X</button> <br /> </>} - <button title="Zoom" className={pbi + (this.originalLayout.showButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> - <button title="Navigate" className={pbi + (this.originalLayout.navButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> - <button title="Hide Before" className={pbi + (this.originalLayout.hideTillShownButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> - <button title="Fade After" className={pbi + (this.originalLayout.fadeButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> - <button title="Hide After" className={pbi + (this.originalLayout.hideAfterButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> - <button title="Group With Up" className={pbi + (this.originalLayout.groupButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.originalLayout.groupButton = !this.originalLayout.groupButton; }}><FontAwesomeIcon icon={"arrow-up"} /></button> - <button title="Expand Inline" className={pbi + (this.originalLayout.embedOpen ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.originalLayout.embedOpen = !this.originalLayout.embedOpen; }}><FontAwesomeIcon icon={"arrow-down"} /></button> + <button title="Zoom" className={pbi + (this.presElementDoc.showButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> + <button title="Navigate" className={pbi + (this.presElementDoc.navButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> + <button title="Hide Before" className={pbi + (this.presElementDoc.hideTillShownButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> + <button title="Fade After" className={pbi + (this.presElementDoc.fadeButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> + <button title="Hide After" className={pbi + (this.presElementDoc.hideAfterButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> + <button title="Group With Up" className={pbi + (this.presElementDoc.groupButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.presElementDoc.groupButton = !this.presElementDoc.groupButton; }}><FontAwesomeIcon icon={"arrow-up"} /></button> + <button title="Expand Inline" className={pbi + (this.presElementDoc.embedOpen ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.presElementDoc.embedOpen = !this.presElementDoc.embedOpen; }}><FontAwesomeIcon icon={"arrow-down"} /></button> <br style={{ lineHeight: 0.1 }} /> {this.renderEmbeddedInline()} diff --git a/src/client/views/webcam/WebCamLogic.js b/src/client/views/webcam/WebCamLogic.js index d5330af9a..f542fb983 100644 --- a/src/client/views/webcam/WebCamLogic.js +++ b/src/client/views/webcam/WebCamLogic.js @@ -104,9 +104,9 @@ export function initialize(roomName, handlerUI) { navigator.mediaDevices.getUserMedia({ - audio: true, - video: true - }) + audio: true, + video: true + }) .then(gotStream) .catch(function (e) { alert('getUserMedia() error: ' + e.name); @@ -154,7 +154,7 @@ export function initialize(roomName, handlerUI) { if (location.hostname !== 'localhost') { requestTurn( - 'https://computeengineondemand.appspot.com/turn?username=41784574&key=4080218913' + `${window.location.origin}/corsProxy/${encodeURIComponent("https://computeengineondemand.appspot.com/turn?username=41784574&key=4080218913")}` ); } diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts index b1c1fda05..447dbe3b0 100644 --- a/src/new_fields/Doc.ts +++ b/src/new_fields/Doc.ts @@ -14,7 +14,7 @@ import { ComputedField, ScriptField } from "./ScriptField"; import { BoolCast, Cast, FieldValue, NumCast, StrCast, ToConstructor } from "./Types"; import { deleteProperty, getField, getter, makeEditable, makeReadOnly, setter, updateFunction } from "./util"; import { intersectRect } from "../Utils"; -import { UndoManager } from "../client/util/UndoManager"; +import { UndoManager, undoBatch } from "../client/util/UndoManager"; import { computedFn } from "mobx-utils"; import { RichTextField } from "./RichTextField"; import { Script } from "vm"; @@ -178,12 +178,18 @@ export class Doc extends RefField { private [SelfProxy]: any; public [WidthSym] = () => NumCast(this[SelfProxy]._width); public [HeightSym] = () => NumCast(this[SelfProxy]._height); - public get [DataSym]() { return Cast(this[SelfProxy].resolvedDataDoc, Doc, null) || this[SelfProxy]; } + public get [DataSym]() { return Cast(Doc.Layout(this[SelfProxy]).resolvedDataDoc, Doc, null) || this[SelfProxy]; } public get [LayoutSym]() { return this[SelfProxy].__LAYOUT__; } @computed get __LAYOUT__() { const templateLayoutDoc = Cast(Doc.LayoutField(this[SelfProxy]), Doc, null); if (templateLayoutDoc) { - const renderFieldKey = (templateLayoutDoc[StrCast(templateLayoutDoc.layoutKey, "layout")] as string).split("'")[1]; + let renderFieldKey: any; + const layoutField = templateLayoutDoc[StrCast(templateLayoutDoc.layoutKey, "layout")]; + if (typeof layoutField === "string") { + renderFieldKey = layoutField.split("'")[1]; + } else { + return Cast(layoutField, Doc, null); + } return Cast(this[SelfProxy][renderFieldKey + "-layout[" + templateLayoutDoc[Id] + "]"], Doc, null) || templateLayoutDoc; } return undefined; @@ -488,6 +494,7 @@ export namespace Doc { setTimeout(action(() => { if (!targetDoc[expandedLayoutFieldKey]) { const newLayoutDoc = Doc.MakeDelegate(templateLayoutDoc, undefined, "[" + templateLayoutDoc.title + "]"); + newLayoutDoc.lockedPosition = true; newLayoutDoc.expandedTemplate = targetDoc; targetDoc[expandedLayoutFieldKey] = newLayoutDoc; const dataDoc = Doc.GetDataDoc(targetDoc); @@ -608,8 +615,12 @@ export namespace Doc { } if (!Doc.AreProtosEqual(target[targetKey] as Doc, templateDoc)) { - titleTarget && (Doc.GetProto(target).title = titleTarget); - Doc.GetProto(target)[targetKey] = new PrefetchProxy(templateDoc); + if (target.resolvedDataDoc) { + target[targetKey] = new PrefetchProxy(templateDoc); + } else { + titleTarget && (Doc.GetProto(target).title = titleTarget); + Doc.GetProto(target)[targetKey] = new PrefetchProxy(templateDoc); + } } target.layoutKey = targetKey; return target; @@ -690,8 +701,11 @@ export namespace Doc { } // the document containing the view layout information - will be the Document itself unless the Document has - // a layout field. In that case, all layout information comes from there unless overriden by Document - export function Layout(doc: Doc): Doc { return doc[LayoutSym] || doc; } + // a layout field or 'layout' is given. + export function Layout(doc: Doc, layout?: Doc): Doc { + const overrideLayout = layout && Cast(doc["data-layout[" + layout[Id] + "]"], Doc, null); + return overrideLayout || doc[LayoutSym] || doc; + } export function SetLayout(doc: Doc, layout: Doc | string) { doc[StrCast(doc.layoutKey, "layout")] = layout; } export function LayoutField(doc: Doc) { return doc[StrCast(doc.layoutKey, "layout")]; } export function LayoutFieldKey(doc: Doc): string { return StrCast(Doc.Layout(doc).layout).split("'")[1]; } @@ -801,22 +815,57 @@ export namespace Doc { const prevLayout = StrCast(doc.layoutKey).split("_")[1]; const deiconify = prevLayout === "icon" && StrCast(doc.deiconifyLayout) ? "layout_" + StrCast(doc.deiconifyLayout) : ""; doc.deiconifyLayout = undefined; - if (StrCast(doc.title).endsWith("_" + prevLayout)) doc.title = StrCast(doc.title).replace("_" + prevLayout, ""); + if (StrCast(doc.title).endsWith("_" + prevLayout) && deiconify) doc.title = StrCast(doc.title).replace("_" + prevLayout, deiconify); + else doc.title = undefined; doc.layoutKey = deiconify || "layout"; } - export function setDocFilter(container: Doc, key: string, value: any, modifiers?: string) { - const docFilters = Cast(container._docFilter, listSpec("string"), []); + export function setDocFilterRange(target: Doc, key: string, range?: number[]) { + const docRangeFilters = Cast(target._docRangeFilters, listSpec("string"), []); + for (let i = 0; i < docRangeFilters.length; i += 3) { + if (docRangeFilters[i] === key) { + docRangeFilters.splice(i, 3); + break; + } + } + if (range !== undefined) { + docRangeFilters.push(key); + docRangeFilters.push(range[0].toString()); + docRangeFilters.push(range[1].toString()); + target._docRangeFilters = new List<string>(docRangeFilters); + } + } + export function setDocFilter(container: Doc, key: string, value: any, modifiers?: string | number) { + const docFilters = Cast(container._docFilters, listSpec("string"), []); for (let i = 0; i < docFilters.length; i += 3) { if (docFilters[i] === key && docFilters[i + 1] === value) { docFilters.splice(i, 3); break; } } - if (modifiers !== undefined) { + if (typeof modifiers === "string") { docFilters.push(key); docFilters.push(value); docFilters.push(modifiers); - container._docFilter = new List<string>(docFilters); + container._docFilters = new List<string>(docFilters); + } + } + export function readDocRangeFilter(doc: Doc, key: string) { + const docRangeFilters = Cast(doc._docRangeFilters, listSpec("string"), []); + for (let i = 0; i < docRangeFilters.length; i += 3) { + if (docRangeFilters[i] === key) { + return [Number(docRangeFilters[i + 1]), Number(docRangeFilters[i + 2])]; + } + } + } + + @undoBatch + @action + export function freezeNativeDimensions(layoutDoc: Doc, width: number, height: number): void { + layoutDoc._autoHeight = false; + layoutDoc.ignoreAspect = false; + if (!layoutDoc.ignoreAspect && !layoutDoc._nativeWidth) { + layoutDoc._nativeWidth = NumCast(layoutDoc._width, width); + layoutDoc._nativeHeight = NumCast(layoutDoc._height, height); } } } @@ -843,4 +892,5 @@ Scripting.addGlobal(function selectedDocs(container: Doc, excludeCollections: bo const docs = DocListCast(Doc.UserDoc().SelectedDocs).filter(d => !Doc.AreProtosEqual(d, container) && !d.annotationOn && d.type !== DocumentType.DOCUMENT && d.type !== DocumentType.KVP && (!excludeCollections || !Cast(d.data, listSpec(Doc), null))); return docs.length ? new List(docs) : prevValue; }); -Scripting.addGlobal(function setDocFilter(container: Doc, key: string, value: any, modifiers?: string) { Doc.setDocFilter(container, key, value, modifiers); });
\ No newline at end of file +Scripting.addGlobal(function setDocFilter(container: Doc, key: string, value: any, modifiers?: string) { Doc.setDocFilter(container, key, value, modifiers); }); +Scripting.addGlobal(function setDocFilterRange(container: Doc, key: string, range: number[]) { Doc.setDocFilterRange(container, key, range); });
\ No newline at end of file diff --git a/src/new_fields/RichTextUtils.ts b/src/new_fields/RichTextUtils.ts index c50f8cc48..7c1fc39d8 100644 --- a/src/new_fields/RichTextUtils.ts +++ b/src/new_fields/RichTextUtils.ts @@ -1,5 +1,5 @@ import { EditorState, Transaction, TextSelection } from "prosemirror-state"; -import { Node, Fragment, Mark, MarkType } 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"; @@ -17,6 +17,7 @@ import { Id } from "./FieldSymbols"; import { DocumentView } from "../client/views/nodes/DocumentView"; import { AssertionError } from "assert"; import { Networking } from "../client/Network"; +import { extname } from "path"; export namespace RichTextUtils { @@ -113,6 +114,7 @@ export namespace RichTextUtils { width: number; title: string; url: string; + agnostic: string; } const parseInlineObjects = async (document: docs_v1.Schema$Document): Promise<Map<string, ImageTemplate>> => { @@ -123,9 +125,7 @@ export namespace RichTextUtils { const objects = Object.keys(inlineObjects).map(objectId => inlineObjects[objectId]); const mediaItems: MediaItem[] = objects.map(object => { const embeddedObject = object.inlineObjectProperties!.embeddedObject!; - const baseUrl = embeddedObject.imageProperties!.contentUri!; - const filename = `upload_${Utils.GenerateGuid()}.png`; - return { baseUrl, filename }; + return { baseUrl: embeddedObject.imageProperties!.contentUri! }; }); const uploads = await Networking.PostToServer("/googlePhotosMediaDownload", { mediaItems }); @@ -136,16 +136,17 @@ export namespace RichTextUtils { for (let i = 0; i < objects.length; i++) { const object = objects[i]; - const { fileNames } = uploads[i]; + const { accessPaths } = uploads[i]; + const { agnostic, _m } = accessPaths; const embeddedObject = object.inlineObjectProperties!.embeddedObject!; const size = embeddedObject.size!; const width = size.width!.magnitude!; - const url = Utils.fileUrl(fileNames.clean); inlineObjectMap.set(object.objectId!, { title: embeddedObject.title || `Imported Image from ${document.title}`, width, - url + url: Utils.prepend(_m.client), + agnostic: Utils.prepend(agnostic.client) }); } } @@ -156,7 +157,6 @@ export namespace RichTextUtils { interface MediaItem { baseUrl: string; - filename: string; } export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId, textNote: Doc): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => { @@ -268,19 +268,19 @@ export namespace RichTextUtils { }; const imageNode = (schema: any, image: ImageTemplate, textNote: Doc) => { - const { url: src, width } = image; + const { url: src, width, agnostic } = image; let docid: string; - const guid = Utils.GenerateDeterministicGuid(src); + const guid = Utils.GenerateDeterministicGuid(agnostic); const backingDocId = StrCast(textNote[guid]); if (!backingDocId) { - const backingDoc = Docs.Create.ImageDocument(src, { _width: 300, _height: 300 }); + const backingDoc = Docs.Create.ImageDocument(agnostic, { _width: 300, _height: 300 }); DocumentView.makeCustomViewClicked(backingDoc, undefined, Docs.Create.FreeformDocument); docid = backingDoc[Id]; textNote[guid] = docid; } else { docid = backingDocId; } - return schema.node("image", { src, width, docid, float: null, location: "onRight" }); + return schema.node("image", { src, agnostic, width, docid, float: null, location: "onRight" }); }; const textNode = (schema: any, run: docs_v1.Schema$TextRun) => { @@ -436,7 +436,7 @@ export namespace RichTextUtils { const width = attrs.width; requests.push(await EncodeImage({ startIndex: position + nodeSize - 1, - uri: attrs.src, + uri: attrs.agnostic, width: Number(typeof width === "string" ? width.replace("px", "") : width) })); } @@ -499,15 +499,18 @@ export namespace RichTextUtils { }; }; - const EncodeImage = async (information: ImageInformation) => { - const source = [Docs.Create.ImageDocument(information.uri)]; + const EncodeImage = async ({ uri, width, startIndex }: ImageInformation) => { + if (!uri) { + return {}; + } + const source = [Docs.Create.ImageDocument(uri)]; const baseUrls = await GooglePhotos.Transactions.UploadThenFetch(source); if (baseUrls) { return { insertInlineImage: { uri: baseUrls[0], - objectSize: { width: { magnitude: information.width, unit: "PT" } }, - location: { index: information.startIndex } + objectSize: { width: { magnitude: width, unit: "PT" } }, + location: { index: startIndex } } }; } diff --git a/src/new_fields/documentSchemas.ts b/src/new_fields/documentSchemas.ts index cb35c0681..962ef2f4b 100644 --- a/src/new_fields/documentSchemas.ts +++ b/src/new_fields/documentSchemas.ts @@ -10,22 +10,24 @@ export const documentSchema = createSchema({ title: "string", // document title (can be on either data document or layout) dropAction: "string", // override specifying what should happen when this document is dropped (can be "alias" or "copy") childDropAction: "string", // specify the override for what should happen when the child of a collection is dragged from it and dropped (can be "alias" or "copy") - _nativeWidth: "number", // native width of document which determines how much document contents are scaled when the document's width is set - _nativeHeight: "number", // " - _width: "number", // width of document in its container's coordinate system - _height: "number", // " + _nativeWidth: "number", // native width of document which determines how much document contents are scaled when the document's width is set + _nativeHeight: "number", // " + _width: "number", // width of document in its container's coordinate system + _height: "number", // " _freeformLayoutEngine: "string",// the string ID for the layout engine to use to layout freeform view documents - _LODdisable: "boolean", // whether to disbale LOD switching for CollectionFreeFormViews + _LODdisable: "boolean", // whether to disbale LOD switching for CollectionFreeFormViews _pivotField: "string", // specifies which field should be used as the timeline/pivot axis + _replacedChrome: "string", // what the default chrome is replaced with. Currently only supports the value of 'replaced' for PresBox's. + _chromeStatus: "string", // determines the state of the collection chrome. values allowed are 'replaced', 'enabled', 'disabled', 'collapsed' color: "string", // foreground color of document backgroundColor: "string", // background color of document opacity: "number", // opacity of document - creationDate: DateField, // when the document was created + creationDate: DateField, // when the document was created links: listSpec(Doc), // computed (readonly) list of links associated with this document removeDropProperties: listSpec("string"), // properties that should be removed from the alias/copy/etc of this document when it is dropped onClick: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) - onPointerDown: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) - onPointerUp: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) + onPointerDown: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) + onPointerUp: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) onDragStart: ScriptField, // script to run when document is dragged (without being selected). the script should return the Doc to be dropped. dragFactory: Doc, // the document that serves as the "template" for the onDragStart script. ie, to drag out copies of the dragFactory document. ignoreAspect: "boolean", // whether aspect ratio should be ignored when laying out or manipulating the document @@ -36,9 +38,7 @@ export const documentSchema = createSchema({ treeViewOpen: "boolean", // flag denoting whether the documents sub-tree (contents) is visible or hidden treeViewExpandedView: "string", // name of field whose contents are being displayed as the document's subtree preventTreeViewOpen: "boolean", // ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) - currentTimecode: "number", // current play back time of a temporal document (video / audio) - summarizedDocs: listSpec(Doc), // documents that are summarized by this document (and which will typically be opened by clicking this document) - maximizedDocs: listSpec(Doc), // documents to maximize when clicking this document (generally this document will be an icon) + currentTimecode: "number", // current play back time of a temporal document (video / audio) maximizeLocation: "string", // flag for where to place content when following a click interaction (e.g., onRight, inPlace, inTab) lockedPosition: "boolean", // whether the document can be moved (dragged) lockedTransform: "boolean", // whether the document can be panned/zoomed @@ -51,7 +51,6 @@ export const documentSchema = createSchema({ showTitleHover: "string", // the showTitle should be shown only on hover isButton: "boolean", // whether document functions as a button (overiding native interactions of its content) ignoreClick: "boolean", // whether documents ignores input clicks (but does not ignore manipulation and other events) - isAnimating: "string", // whether the document is in the midst of animating between two layouts (used by icons to de/iconify documents). value is undefined|"min"|"max" scrollToLinkID: "string", // id of link being traversed. allows this doc to scroll/highlight/etc its link anchor. scrollToLinkID should be set to undefined by this doc after it sets up its scroll,etc. strokeWidth: "number", fontSize: "string", diff --git a/src/scraping/buxton/final/BuxtonImporter.ts b/src/scraping/buxton/final/BuxtonImporter.ts new file mode 100644 index 000000000..098671942 --- /dev/null +++ b/src/scraping/buxton/final/BuxtonImporter.ts @@ -0,0 +1,377 @@ +import { readdirSync, writeFile, mkdirSync } from "fs"; +import * as path from "path"; +import { red, cyan, yellow } from "colors"; +import { Utils } from "../../../Utils"; +import rimraf = require("rimraf"); +import { DashUploadUtils } from "../../../server/DashUploadUtils"; +const StreamZip = require('node-stream-zip'); +const createImageSizeStream = require("image-size-stream"); +import { parseXml } from "libxmljs"; +import { strictEqual } from "assert"; + +interface DocumentContents { + body: string; + imageData: ImageData[]; + hyperlinks: string[]; + captions: string[]; + embeddedFileNames: string[]; +} + +export interface DeviceDocument { + title: string; + shortDescription: string; + longDescription: string; + company: string; + year: number; + originalPrice?: number; + degreesOfFreedom?: number; + dimensions?: string; + primaryKey: string; + secondaryKey: string; + attribute: string; + __images: ImageData[]; + hyperlinks: string[]; + captions: string[]; + embeddedFileNames: string[]; +} + +export interface AnalysisResult { + device?: DeviceDocument; + errors?: { [key: string]: string }; +} + +type Transformer<T> = (raw: string) => { transformed?: T, error?: string }; + +export interface ImportResults { + deviceCount: number, + errorCount: number +} + +type ResultCallback = (result: AnalysisResult) => void; +type TerminatorCallback = (result: ImportResults) => void; + +interface Processor<T> { + exp: RegExp; + matchIndex?: number; + transformer?: Transformer<T>; + required?: boolean; +} + +interface ImageData { + url: string; + nativeWidth: number; + nativeHeight: number; +} + +namespace Utilities { + + export function numberValue(raw: string) { + const transformed = Number(raw); + if (isNaN(transformed)) { + return { error: `${raw} cannot be parsed to a numeric value.` }; + } + return { transformed }; + } + + export function collectUniqueTokens(raw: string) { + const pieces = raw.replace(/,|\s+and\s+/g, " ").split(/\s+/).filter(piece => piece.length); + const unique = new Set(pieces.map(token => token.toLowerCase().trim())); + return { transformed: Array.from(unique).map(capitalize).sort() }; + } + + export function correctSentences(raw: string) { + raw = raw.replace(/\./g, ". ").replace(/\:/g, ": ").replace(/\,/g, ", ").replace(/\?/g, "? ").trimRight(); + raw = raw.replace(/\s{2,}/g, " "); + return { transformed: raw }; + } + + export function capitalize(word: string): string { + const clean = word.trim(); + if (!clean.length) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1); + } + + export async function readAndParseXml(zip: any, relativePath: string) { + console.log(`Text streaming ${relativePath}`); + const contents = await new Promise<string>((resolve, reject) => { + let body = ""; + zip.stream(relativePath, (error: any, stream: any) => { + if (error) { + reject(error); + } + stream.on('data', (chunk: any) => body += chunk.toString()); + stream.on('end', () => resolve(body)); + }); + }); + + return parseXml(contents); + } + +} + +const RegexMap = new Map<keyof DeviceDocument, Processor<any>>([ + ["title", { + exp: /contact\s+(.*)Short Description:/ + }], + ["company", { + exp: /Company:\s+([^\|]*)\s+\|/, + transformer: (raw: string) => ({ transformed: raw.replace(/\./g, "") }) + }], + ["year", { + exp: /Year:\s+([^\|]*)\s+\|/, + transformer: (raw: string) => Utilities.numberValue(/[0-9]{4}/.exec(raw)![0]) + }], + ["primaryKey", { + exp: /Primary:\s+(.*)(Secondary|Additional):/, + transformer: raw => ({ transformed: Utilities.collectUniqueTokens(raw).transformed[0] }) + }], + ["secondaryKey", { + exp: /(Secondary|Additional):\s+(.*)Attributes?:/, + transformer: raw => ({ transformed: Utilities.collectUniqueTokens(raw).transformed[0] }), + matchIndex: 2 + }], + ["attribute", { + exp: /Attributes?:\s+(.*)Links/, + transformer: raw => ({ transformed: Utilities.collectUniqueTokens(raw).transformed[0] }), + }], + ["originalPrice", { + exp: /Original Price \(USD\)\:\s+(\$[0-9\,]+\.[0-9]+|NFS)/, + transformer: (raw: string) => { + raw = raw.replace(/\,/g, ""); + if (raw === "NFS") { + return { transformed: -1 }; + } + return Utilities.numberValue(raw.slice(1)); + }, + required: false + }], + ["degreesOfFreedom", { + exp: /Degrees of Freedom:\s+([0-9]+)/, + transformer: Utilities.numberValue, + required: false + }], + ["dimensions", { + exp: /Dimensions\s+\(L x W x H\):\s+([0-9\.]+\s+x\s+[0-9\.]+\s+x\s+[0-9\.]+\s\([A-Za-z]+\))/, + transformer: (raw: string) => { + const [length, width, group] = raw.split(" x "); + const [height, unit] = group.split(" "); + return { + transformed: { + dim_length: Number(length), + dim_width: Number(width), + dim_height: Number(height), + dim_unit: unit.replace(/[\(\)]+/g, "") + } + }; + }, + required: false + }], + ["shortDescription", { + exp: /Short Description:\s+(.*)Bill Buxton[’']s Notes/, + transformer: Utilities.correctSentences + }], + ["longDescription", { + exp: /Bill Buxton[’']s Notes(.*)Device Details/, + transformer: Utilities.correctSentences + }], +]); + +const sourceDir = path.resolve(__dirname, "source"); +const outDir = path.resolve(__dirname, "json"); +const imageDir = path.resolve(__dirname, "../../../server/public/files/images/buxton"); +const successOut = "buxton.json"; +const failOut = "incomplete.json"; +const deviceKeys = Array.from(RegexMap.keys()); + +export default async function executeImport(emitter: ResultCallback, terminator: TerminatorCallback) { + try { + const contents = readdirSync(sourceDir); + const wordDocuments = contents.filter(file => /.*\.docx?$/.test(file)).map(file => `${sourceDir}/${file}`); + [outDir, imageDir].forEach(dir => { + rimraf.sync(dir); + mkdirSync(dir); + }); + return parseFiles(wordDocuments, emitter, terminator); + } catch (e) { + const message = [ + "Unable to find a source directory.", + "Please ensure that the following directory exists and is populated with Word documents:", + `${sourceDir}` + ].join('\n'); + console.log(red(message)); + return { error: message }; + } +} + +async function parseFiles(wordDocuments: string[], emitter: ResultCallback, terminator: TerminatorCallback): Promise<DeviceDocument[]> { + const results: AnalysisResult[] = []; + for (const filePath of wordDocuments) { + const fileName = path.basename(filePath).replace("Bill_Notes_", ""); + console.log(cyan(`\nExtracting contents from ${fileName}...`)); + const result = analyze(fileName, await extractFileContents(filePath)); + emitter(result); + results.push(result); + } + + const masterDevices: DeviceDocument[] = []; + const masterErrors: { [key: string]: string }[] = []; + results.forEach(({ device, errors }) => { + if (device) { + masterDevices.push(device); + } else if (errors) { + masterErrors.push(errors); + } + }); + + const total = wordDocuments.length; + if (masterDevices.length + masterErrors.length !== total) { + throw new Error(`Encountered a ${masterDevices.length} to ${masterErrors.length} mismatch in device / error split!`); + } + + console.log(); + await writeOutputFile(successOut, masterDevices, total, true); + await writeOutputFile(failOut, masterErrors, total, false); + console.log(); + + terminator({ deviceCount: masterDevices.length, errorCount: masterErrors.length }); + + return masterDevices; +} + +const tableCellXPath = '//*[name()="w:tbl"]/*[name()="w:tr"]/*[name()="w:tc"]'; +const hyperlinkXPath = '//*[name()="Relationship" and contains(@Type, "hyperlink")]'; + +async function extractFileContents(pathToDocument: string): Promise<DocumentContents> { + console.log('Extracting text...'); + const zip = new StreamZip({ file: pathToDocument, storeEntries: true }); + await new Promise<void>(resolve => zip.on('ready', resolve)); + + // extract the body of the document and, specifically, its captions + const document = await Utilities.readAndParseXml(zip, "word/document.xml"); + const body = document.root()?.text() ?? "No body found. Check the import script's XML parser."; + const captions: string[] = []; + const embeddedFileNames: string[] = []; + const captionTargets = document.find(tableCellXPath).map(node => node.text()); + + const { length } = captionTargets; + strictEqual(length > 3, true, "No captions written."); + strictEqual(length % 3 === 0, true, "Improper caption formatting."); + + for (let i = 3; i < captionTargets.length; i += 3) { + const row = captionTargets.slice(i, i + 3); + captions.push(row[1]); + embeddedFileNames.push(row[2]); + } + + // extract all hyperlinks embedded in the document + const rels = await Utilities.readAndParseXml(zip, "word/_rels/document.xml.rels"); + const hyperlinks = rels.find(hyperlinkXPath).map(el => el.attrs()[2].value()); + console.log("Text extracted."); + + console.log("Beginning image extraction..."); + const imageData = await writeImages(zip); + console.log(`Extracted ${imageData.length} images.`); + + zip.close(); + + return { body, imageData, captions, embeddedFileNames, hyperlinks }; +} + +const imageEntry = /^word\/media\/\w+\.(jpeg|jpg|png|gif)/; + +interface Dimensions { + width: number; + height: number; + type: string; +} + +async function writeImages(zip: any): Promise<ImageData[]> { + const allEntries = Object.values<any>(zip.entries()).map(({ name }) => name); + const imageEntries = allEntries.filter(name => imageEntry.test(name)); + + const imageUrls: ImageData[] = []; + for (const mediaPath of imageEntries) { + const streamImage = () => new Promise<any>((resolve, reject) => { + zip.stream(mediaPath, (error: any, stream: any) => error ? reject(error) : resolve(stream)); + }); + + const { width, height, type } = await new Promise<Dimensions>(async resolve => { + const sizeStream = createImageSizeStream().on('size', (dimensions: Dimensions) => { + readStream.destroy(); + resolve(dimensions) + }); + const readStream = await streamImage(); + readStream.pipe(sizeStream); + }); + if (Math.abs(width - height) < 10) { + continue; + } + + const ext = `.${type}`.toLowerCase(); + const generatedFileName = `upload_${Utils.GenerateGuid()}${ext}`; + + await DashUploadUtils.outputResizedImages(streamImage, imageDir, generatedFileName, ext); + + imageUrls.push({ + url: `/files/images/buxton/${generatedFileName}`, + nativeWidth: width, + nativeHeight: height + }); + } + + return imageUrls; +} + +function analyze(fileName: string, contents: DocumentContents): AnalysisResult { + const { body, imageData, captions, hyperlinks, embeddedFileNames } = contents; + const device: any = { + hyperlinks, + captions, + embeddedFileNames, + __images: imageData + }; + const errors: { [key: string]: string } = { fileName }; + + for (const key of deviceKeys) { + const { exp, transformer, matchIndex, required } = RegexMap.get(key)!; + const matches = exp.exec(body); + + let captured: string; + if (matches && (captured = matches[matchIndex ?? 1])) { + captured = captured.replace(/\s{2,}/g, " "); + if (transformer) { + const { error, transformed } = transformer(captured); + if (error) { + errors[key] = `__ERR__${key.toUpperCase()}__TRANSFORM__: ${error}`; + continue; + } + captured = transformed; + } + + device[key] = captured; + } else if (required ?? true) { + errors[key] = `ERR__${key.toUpperCase()}__: outer match ${matches === null ? "wasn't" : "was"} captured.`; + continue; + } + } + + const errorKeys = Object.keys(errors); + if (errorKeys.length > 1) { + console.log(red(`@ ${cyan(fileName.toUpperCase())}...`)); + errorKeys.forEach(key => key !== "filename" && console.log(red(errors[key]))); + return { errors }; + } + + return { device }; +} + +async function writeOutputFile(relativePath: string, data: any[], total: number, success: boolean) { + console.log(yellow(`Encountered ${data.length} ${success ? "valid" : "invalid"} documents out of ${total} candidates. Writing ${relativePath}...`)); + return new Promise<void>((resolve, reject) => { + const destination = path.resolve(outDir, relativePath); + const contents = JSON.stringify(data, undefined, 4); + writeFile(destination, contents, err => err ? reject(err) : resolve()); + }); +}
\ No newline at end of file diff --git a/src/scraping/buxton/json/buxton.json b/src/scraping/buxton/json/buxton.json deleted file mode 100644 index 8371f2cf2..000000000 --- a/src/scraping/buxton/json/buxton.json +++ /dev/null @@ -1,116 +0,0 @@ -[ - { - "title": "3Dconnexion CadMan 3D Motion Controller", - "company": "3Dconnexion", - "year": 2003, - "primaryKey": [ - "Joystick" - ], - "secondaryKey": [ - "Isometric", - "Joystick" - ], - "originalPrice": 399, - "degreesOfFreedom": 6, - "dimensions": { - "length": 175, - "width": 122, - "height": 43, - "unit": "mm" - }, - "shortDescription": "The CadMan is a 6 degree of freedom (DOF) joystick controller. It represented a significant step towards making this class of is controller affordable. It was mainly directed at 3D modelling and animation and was a “next generation” of the Magellan controller, which is also in the collection.", - "longDescription": "The CadMan is a 6 degree of freedom (DOF) joystick controller. It represented a significant step towards making this class of is controller more affordable. It was mainly directed at 3D modelling and animation and was a “next generation” of the Magellan/SpaceMouse controller, which is also in the collection. Like the Magellan, this is an isometric rate-control joystick. That is, it rests in a neutral central position, not sending and signal. When a force is applied to it, it emits a signal indicating the direction and strength of that force. This signal can then be mapped to a parameter of a selected object, such as a sphere, and – for example – cause that sphere to rotate for as long as, and as fast as, and in the direction determined by, the duration, force, and direction of the applied force. When released, it springs back to neutral position. Note that the force does not need to be directed along a single DOF. In fact, a core feature of the device is that one can simultaneously and independently apply force that asserts control over more than one DOF, and furthermore, vary those forces dynamically. As an aid to understanding, let me walk through some of the underlying concepts at play here by using a more familiar device: a computer mouse. If you move a mouse in a forward/backward direction, the mouse pointer on the screen moves between the screen’s top and bottom. If you think of the screen as a piece of graph paper, that corresponds to moving along the “Y” axis. That is one degree of freedom. On the other hand, you could move the mouse left and right, which causes the mouse to move between the left and right side of the screen. That would correspond to moving along the graph paper’s “X” axis – a second degree of freedom. Yet, you can also move the mouse diagonally. This is an example of independently controlling two degrees of freedom. Now imagine that if you lifted your mouse off your desktop, that your computer could dynamically sense its height as you did so. This would constitute a “flying mouse” (the literal translation of the German word for a “Bat”, which Canadian colleague, Colin Ware, applied to just such a mouse which he built in 1988). If you moved your Bat vertically up and down, perpendicular to the desktop, you would be controlling movement along the “Z” axis - a third degree of freedom. Having already seen that we can move a mouse diagonally, we have established that we need not be constrained to only moving along a single axis. That extends to the movement of our Bat and movement along the “Z” axis. We can control our hand movement in dependently in any or all directions in 3D space. But how does one reconcile the fact that we call the CadMan a “3D controller, and yet also describe it as having 6 degrees of freedom? After all, the example this far demonstrates that our Bat, as described thus far, has freedom on movement in 3 Dimensions. While true, we can extend our example to prove that that freedom to move in 3D is also highly constrained. To demonstrate this, move your hand in 3D space on and above your desktop. However, do so keeping your palm flat, parallel to the desktop with your fingers pointing directly forward. In so doing, you are still moving in 3D. Now, while moving, twist your wrist, while moving the hand, such that your palm is alternatively exposed to the left and right side. This constitutes rotation around the “Y” axis. A fourth DOF. Now add a waving motion to your hand, as if it were a paper airplane diving up and down, while also rocking left and right. But keep your fingers pointing forward. You have now added a fifth DOF, rotation around the “X” axis. Finally, add a twist to your wrist so that your fingers are no longer constrained to pointing forward. This is the sixth degree of freedom, rotation around the “Z” axis. Now don’t be fooled, this exercise could continue. We are not restricted to even six DOF. Imagine doing the above, but where the movement and rotations are measured relative to the Bat’s position and orientation, rather than to the holding/controlling hand, per se. One could imagine the Bat having a scroll wheel, like the one on most mice today. Furthermore, while flying your Bat around in 3D, that wheel could easily be rolled in either forward or backward, and thereby control the size of whatever was being controlled. Hence, with one hand we could assert simultaneous and independent control over 7 DOF in 3D space. This exercise has two intended take-aways. The first is a better working understanding between the notion of Degree of Freedom (DOF) and Dimension in space. Hopefully, the confusion frequently encountered when 3D and 6DOF are used in close context, can now be eliminated. Second, is that, with appropriate sensing, the human hand is capable of exercising control over far more degrees of freedom that six. And if we use the two hands together, the potential number of DOF that one can control goes even further. Finally, it is important to add one more take-away – one which both emerges from, and is frequently encountered when discussing, the previous two. That is, do not equate exercising simultaneous control over a high number of DOF with consciously doing the same number of different things all at once. The example that used to be thrown at me when I started talking about coordinated simultaneously bi-manual action went along the lines of, “Psychology tells us that we cannot do multiple things at once, for example, simultaneously tapping your head and rubbing your stomach. ”Well, first, I can tap my head with one hand while rubbing my stomach with the other. But that is not the point. The whole essence of skill – motor-sensory and cognitive – is “chunking” or task integration. When one appears to be doing many different things at once, if they are skilled, they are consciously doing only one thing. Playing a chord on the piano, for example, or skiing down the hill. Likewise, in flying your imaginary BAT in the previous exercise with the scroll wheel, were you doing 7 things at once, or one thing with 7 DOF? And if you had a Bat in each hand, does that mean you are now doing 14 things at once, or are you doing one thing with 14 DOF? Let me provide a different way of answering this question: if you have ever played air guitar, or “conducted” the orchestra that you are listening to on the radio, you are exercising control over more than 14 DOF. And you are doing exactly what I just said, “playing air guitar” or “conducting an orchestra”. One thing – at the conscious level, which is what matters – despite almost any one thing being able to be deconstructed into hundreds of sub-tasks. As I said the essence of skill: aggregation, or chunking. What is most important for both tool designers and users to be mindful of, is the overwhelming influence that our choice and design of tools impacts the degree to which such integration or chunking can take place. The degree to which the tool matches both the skills that we have already acquired through a lifetime of living in the everyday world, and the demands of the intended task, the more seamless that task can be performed, the more “natural” it will feel, and the less learning will be required. In my experience, it brought particular value when used bimanually, in combination with a mouse, where the preferred hand performed conventional pointing, selection and dragging tasks, while the non-preferred hand could manipulate the parameters of the thing being selected. First variation of the since the 2001 formation of 3Dconnextion. The CadMan came in 5 colours: smoke, orange, red, blue and green. See the notes for the LogiCad3D Magellan for more details on this class of device. It is the “parent” of the CadMan, and despite the change in company name, it comes from the same team." - }, - { - "title": "Adesso ACK-540UB USB Mini-Touch Keyboard with Touchpad", - "company": "Adesso", - "year": 2005, - "primaryKey": [ - "Keyboard" - ], - "secondaryKey": [ - "Pad", - "Touch" - ], - "originalPrice": 59.95, - "degreesOfFreedom": 2, - "dimensions": { - "length": 287, - "width": 140, - "height": 35.5, - "unit": "mm" - }, - "shortDescription": "The Mini-Touch Keyboard is a surprisingly rare device: a laptop-style, small-footprint keyboard with a centrally mounted touch-pad. .", - "longDescription": "First released in 2003 with a PS/2 connector (ACK-540PW & ACK-540PB). USB version released in 2006 in either black (ACK-540UB) or white (ACK-540UW). Marketed under different brands, including SolidTek: http: //www. tigerdirect. com/applications/searchtools/item-details. asp? EdpNo=1472243https: //acecaddigital. com/index. php/products/keyboards/mini-keyboards/kb-540 Deltaco: https: //www. digitalimpuls. no/logitech/116652/deltaco-minitastatur-med-touchpad-usb" - }, - { - "title": "Contour Design UniTrap ", - "company": "Contour Design", - "year": 1999, - "primaryKey": [ - "Re-skin" - ], - "secondaryKey": [ - "Mouse" - ], - "originalPrice": 14.99, - "degreesOfFreedom": 2, - "dimensions": { - "length": 130.5, - "width": 75.7, - "height": 43, - "unit": "mm" - }, - "shortDescription": "This is a plastic shell within which the round Apple iMac G3 “Hockey Puck” mouse can be fit. While the G3 Mouse worked well mechanically, when gripped its round shape gave few cues as to its orientation. Hence, if you moved your hand up, the screen pointer may well have moved diagonally. By reskinning it with the inexpensive Contour UniTrap, the problem went away without the need to buy a whole new mouse.", - "longDescription": "Also add back pointers from devices re-skinned" - }, - { - "title": "Depraz Swiss Mouse", - "company": "Depraz", - "year": 1980, - "primaryKey": [ - "Mouse" - ], - "secondaryKey": [ - "Ball", - "Chord", - "Keyboard", - "Mouse" - ], - "originalPrice": 295, - "degreesOfFreedom": 2, - "dimensions": { - "length": 50.8, - "width": 76.2, - "height": 114.3, - "unit": "mm" - }, - "shortDescription": "This mouse is one of the first commercially available mice to be sold publicly. It is known as the Swiss mouse, and yes, the roller mechanism was designed by a Swiss watchmaker. Coincidentally, the company that made it, Depraz, is based in Apples, Switzerland. Their success in selling this mouse is what caused Logitech to switch from a software development shop to one of the world’s leading suppliers of mice and other input devices.", - "longDescription": "DePraz began manufacturing in 1980, but following design built in 1979. Logitech started selling it in 1982. It was one of the first mass produced mice, one of the first available ball mice, as well as to have an optical shaft encoder – thereby improving linearity. An interesting fact, given its Swiss heritage, is that its designer, André Guignard, was trained as a Swiss watch maker. Unlike most modern mice, the DePraz, or “Swiss” mouse had a quasi-hemispherical shape. Hence, it was held in a so-called “power-grip”, much as one would grip a horizontally held ball – the thumb and small finger applying pressure on each side, with added support from the weight/friction of the palm on the back of the mouse. In this posture, the three middle fingers naturally positioning themselves over the three buttons mounted at the lower edge of the front. Largely freed of grip pressure, by grace of thumb and little finger, the middle fingers had essentially freedom of motion to independently operate the buttons. Each having a dedicated finger, the buttons could be easily pushed independently or in any combination. Like the three valves on a trumpet, this ability to “chord” extended the three physical buttons to have the power of seven. The down-side of this “turtle shell” form factor is that it placed the hand in a posture in which mouse movement relied more of the larger muscle groups of the arm to wrist, rather than wrist to fingers – the latter being the approach taken in most subsequent mice. The original Swiss Mouse was developed at École Polytechnique Fédérale de Lausanne by a project led by Jean-Daniel Nicoud, who was also responsible for the development of its optical shaft encoder. To augment their revenue stream, Logitech, then a software and hardware consulting company for the publishing industry, acquired marketing rights for North America. Mouse revenue quickly overshadowed that from software. In 1983, Logitech acquired DePraz, named the Swiss Mouse the “P4”, and grew to become one of the largest input device manufacturer in the world. One curious coincidence is that they were founded in the town of Apples, Switzerland." - }, - { - "title": "One Laptop Per Child (OLPC) XO-1", - "company": "One Laptop Per Child (OLPC)", - "year": 2007, - "primaryKey": [ - "Computer" - ], - "secondaryKey": [ - "Keyboard", - "Laptop", - "Pad", - "Slate", - "Touch" - ], - "originalPrice": 199, - "degreesOfFreedom": 2, - "dimensions": { - "length": 242, - "width": 228, - "height": 30, - "unit": "mm" - }, - "shortDescription": "The OLPC XO-1 is very innovative device that nevertheless raises serious issues about technology and social responsibility. It is included in the collection primarily as a warning against technological hubris, and the fact that no technologies are neutral from a social-cultural perspective.", - "longDescription": "IntroductionI have this computer in my collection as a reminder of the delicate relationship between object and purpose, and how no matter how well one does on the former, it will likely have no impact on making a wanting concept achieve the stated (and even valid) purpose any better. I include it in the collection as a cautionary tale of how the object may help sell a concept, regardless how ill-conceived – even to those who should know better, had they applied the most basic critical thinking. For consumers, investors and designers, its story serves as a cautionary reminder to the importance of cultivating and retaining a critical mind and questioning perspective, regardless of how intrinsically seductive or well-intentioned a technology may be. From the perspective of hardware and software, what the One Laptop Per Child (OLPC) project was able to accomplish is impressive. In general, the team delivered a computer that could be produced at a remarkably low price – even if about double that which was targeted. Specifically, the display, for example, is innovative, and stands out due to its ability to work both in the bright sun (reflective) as well as in poorly lit spaces (emissive) – something that goes beyond pretty much anything else that is available on today’s (2017) slate computers or e-readers. In short, some excellent work went into this machine, something that is even more impressive, given the nature of the organization from which it emerged. The industrial design was equally impressive. Undertaken by Yves Behar’s FuseprojectUltimately, however, the machine was a means to an end, not the end itself. Rather than a device, the actual mission of the OLPC project was: … to empower the world's poorest children through education. Yet, as described by in their materials, the computer was intended to play a key role in this: With access to this type of tool [the computer], children are engaged in their own education, and learn, share, and create together. They become connected to each other, to the world and to a brighter future. Hence, making a suitable computer suitable to that purpose and the conditions where it would be used, at a price point that would enable broad distribution, was a key part of the project. The Underlying Belief System of the OLPC ProjectSince they are key to the thinking behind the OLPC project, I believe if fair to frame my discussion around the following four questions: Will giving computers to kids in the developing world improve their education? Will having a thus better-educated youth help bring a society out of poverty? Can that educational improvement be accomplished by giving the computers to the kids, with no special training for teachers? Should this be attempted on a global scale without any advance field trials or pilot studies? From the perspective of the OLPC project, the answer to every one of these questions is an unequivocal “yes”. In fact, as we shall see, any suggestion to the contrary is typically answered by condescension and/or mockery. The answers appear to be viewed as self-evident and not worth even questioning. Those who have not subscribed to this doctrine might call such a viewpoint hubris. What staggers me is how the project got so far without the basic assumptions being more broadly questioned, much less such questions being seriously addressed by the proponents. How did seemingly otherwise people commit to the project, through their labour or financial investment, given the apparently naïve and utopian approach that it took? Does the desire to do good cloud judgment that much? Are we that dazzled by a cool technology or big hairy audacious goal? Or by a charismatic personality? To explain my concern, and what this artifact represents to me, let me just touch on the four assumptions on which the project was founded. Will giving computers to kids in the developing world improve education? The literature on this question is, at best, mixed. What is clear is that one cannot make any assumption that such improvements will occur, regardless of whether one is talking about the developing world or suburban USA. For example, in January 2011, The World Bank published the following study: Can Computers Help Students Learn? From Evidence to Policy, January 2011, Number 4, The World Bank. A public-private partnership in Colombia, called Computers for Education, was created in 2002 to increase the availability of computers in public schools for use in education. Since starting, the program has installed more than 73, 000 computers in over 6, 300 public schools in more than 1, 000 municipalities. By 2008, over 2 million students and 83, 000 teachers had taken part. This document reports on a two-year study to determine the impact of the program on student performance. Students in schools that received the computers and teacher training did not do measurably better on tests than students in the control group. Nor was there a positive effect on other measures of learning. Researchers did not find any difference in test scores when they looked at specific components of math and language studies, such as algebra and geometry, and grammar and paraphrase ability in Spanish. But report also notes that results of such studies are mixed: Studies on the relationship between using computers in the classroom and improved test scores in developing countries give mixed results: A review of Israel’s Tomorrow-98 program in the mid-1990s, which put computers in schools across the country, did not find any impact on math and Hebrew language scores. But in India, a study of a computer-assisted learning program showed a significant positive impact on math scores. One thing researchers agree on, more work is needed in this field. Before moving on, a search of the literature will show that these results are consistent with those that were available in the literature at the time that the project was started. The point that I am making is not that the OLPC project could not be made to work; rather, that it was wrong to assume that it would do so without spending at least as much time designing the process to bring that about, as was expended designing the computer itself. Risk is fine, and something that can be mitigated. But diving in under the assumption that it would just work is not calculated risk, it is gambling - with other people’s lives, education and money. Will a better educated population help bring a society out of poverty? I am largely going to punt on this question. The fact is, I would be hard pressed to argue against education. But let us grant that improving education in the developing world is a good thing. The appropriate question is: is the approach of the OLPC project a reasonable or responsible way to disburse the limited resources that are available to address the educational challenges of the developing world? At the very least, I would suggest that this is a topic worthy of debate. An a priori assumption that giving computers is the right solution is akin to the, “If you build it they will come” approach seen in the movie, Field of Dreams. The problem here is that this is not a movie. There are real lives and futures that are at stake here – lives of those who cannot afford to see the movie, much less have precious resources spent on projects that are not well thought through. Can that improvement be accomplished by just giving the computers to the kids without training teachers? Remarkably, the OLPC Project’s answer is an explicit, “Yes”. In a TED talk filmed in December 2007, the founder of the OLPC initiative, Nicholas Negroponte states: “When people tell me, you know, who’s going to teach the teachers to teach the kids, I say to myself, “What planet do you come from? ” Okay, there’s not a person in this room [the TED Conference], I don’t care how techy you are, there’s not a person in this room that doesn’t give their laptop or cell phone to a kid to help them debug it. Okay, we all need help, even those of us who are very seasoned. ”Let us leave aside the naïvete of this statement stemming from the lack of distinction between ability to use applications and devices versus the ability to create and shape them. A failure of logic remains in that those unseasoned kids are part of “us”, as in “we all need help”. Where do the kids go for help? To other kids? What if they don’t know? Often they won’t. After all, the question may well have to do with a concept in calculus, rather than how to use the computer. What then? No answer is offered. Rather, those who dare raise the serious and legitimate concerns regarding teacher preparation are mockingly dismissed as coming from another planet! Well, perhaps they are. But in that case, there should at least be some debate as to who lives on which planet. Is it the people raising the question or the one dismissing the concern that lives in the real world of responsible thought and action? Can this all be accomplished without any advance field trials? Should one just immediately commit to international deployment of the program? As recently as September 2009, Negroponte took part in a panel discussion where he spoke on this matter. He states: I'd like you to imagine that I told you \"I have a technology that is going to change the quality of life. \" And then I tell you \"Really the right thing to do is to set up a pilot project to test my technology. And then the second thing to do is, once the pilot has been running for some period of time, is to go and measure very carefully the benefits of that technology. \"And then I am to tell you that what we are going to is very scientifically evaluate this technology, with control groups - giving it to some, giving it to others. And this all is very reasonable until I tell you the technology is electricity. And you say \"Wait, you don't have to do that!\"But you don't have to do that with laptops and learning either. And the fact that somebody in the room would say the impact is unclear is to me amazing - unbelievably amazing. There's not a person in this room who hasn't bought a laptop for their child, if they could afford it. And you don't know somebody who hasn't done it, if they can afford it. So there's only one question on the table and that's, “How to afford it? ” That's the only question. There is no other question - it's just the economics. And so, when One Laptop Per Child started, I didn't have the picture quite as clear as that, but we did focus on trying to get the price down. We did focus on those things. Unfortunately, Negroponte demonstrates his lack of understanding of both the history of electricity and education in this example. His historical mistake is this: yes, it was pretty obvious that electricity could bring many benefits to society. But what happened when Edison did exactly what Negroponte advocates? He almost lost his company due to his complete (but mistaken) conviction that DC, rather the AC was the correct technology to pursue. As with electricity, yes, it is rather obvious that education could bring significant benefits to the developing world. But in order to avoid making the same kind of expensive mistake that Edison did, perhaps one might want to do one’s best to make sure that the chosen technology is the AC, rather than DC, of education. A little more research, and a little less hubris might have put the investments in Edison and the OLPC to much better use. But the larger question is this: in what way is it responsible for the wealthy western world to advocate an untested and expensive (in every sense) technological solution on the poorest nations in the world? If history has taught us anything, it has taught us that just because our intentions are good, the same is not necessarily true for consequences of our actions. Later in his presentation, Negroponte states: … our problems are swimming against very naïve views of education. With this, I have to agree. It is just whose views on education are naïve, and how can such views emerge from MIT, no less, much less pass with so little critical scrutiny by the public, the press, participants, and funders? In an interview with Paul Marks, published in the New Scientist in December 2008, we see the how the techno-centric aspect of the project plays into the ostensible human centric purpose of the project. Negroponte’s retort regarding some of the initial skepticism that the project provoked was this: “When we first said we could build a laptop for $100 it was viewed as unrealistic and so 'anti-market' and so 'anti' the current laptops which at the time were around $1000 each, \" Negroponte said. \"It was viewed as pure bravado - but look what happened: the netbook market has developed in our wake. \" The project's demands for cheaper components such as keyboards, and processors nudged the industry into finding ways to cut costs, he says. \"What started off as a revolution became a culture. \"Surprise, yes, computers get smaller, faster, and cheaper over the course of time, and yes, one can even grant that the OLPC project may have accelerated that inevitable move. And, I have already stated my admiration and respect for the quality of the technology that was developed. But in the context of the overall objectives of the project, the best that one can say is, “Congratulations on meeting a milestone. ” However, by the same token, one might also legitimately question if starting with the hardware was not an instance of putting the cart before the horse. Yes, it is obviously necessary to have portable computers in the first place, before one can introduce them into the classroom, home, and donate them to children in the developing world. But it is also the case that small portable computers were already in existence and at the time that the project was initiated. While a factor of ten more expensive than the eventual target price, they were both available and adequate to support limited preliminary testing of the underlying premises of the project in an affordable manner. That is, before launching into a major - albeit well-intentioned – hardware development project, it may have been prudent to have tested the underlying premises of its motivation. Here we have to return to the raison d’être of the initiative: … to empower the world's poorest children through educationHence, the extent to which this is achieved from a given investment must be the primary metric of success, as well as the driving force of the project. Yet, that is clearly not what happened. Driven by a blind Edisonian belief in their un-tested premise, the project’s investments were overwhelmingly on the side of technology rather than pedagogy. Perhaps the nature and extent of the naïve (but well-meaning) utopian dream underlying the project is captured in the last part of the interview, above: Negroponte believes that empowering children and their parents with the educational resources offered by computers and the Internet will lead to informed decisions that improve democracy. Indeed, it has led to some gentle ribbing between himself and his brother: John Negroponte - currently deputy secretary of state in the outgoing Bush administration and the first ever director of national intelligence at the National Security Agency. \"I often joke with John that he can bring democracy his way - and I'll bring it mine, \" he says. Apparently providing inexpensive laptops to children in the developing world is not only going to raise educational standards, eradicate poverty, it is also going to bring democracy! All that, with no mention of the numerous poor non-democratic countries that have literacy levels equal to or higher than the USA (Cuba might be one reasonable example). The words naïve technological-utopianism come to mind. I began by admitting that I was conflicted in terms of this project. From the purely technological perspective, there is much to admire in the project’s accomplishments. Sadly, that was not the project’s primary objective. What appears to be missing throughout is an inability to distinguish between the technology and the purpose to which is was intended to serve. My concern in this regard is reflected in a paper by Warschauer & Ames(2010). The analysis reveals that provision of individual laptops is a utopian vision for the children in the poorest countries, whose educational and social futures could be more effectively improved if the same investments were instead made on more sustainable and proven interventions. Middle- and high-income countries may have a stronger rationale for providing individual laptops to children, but will still want to eschew OLPC’s technocentric vision. In summary, OLPC represents the latest in a long line of technologically utopian development schemes that have unsuccessfully attempted to solve complex social problems with overly simplistic solutions. There is a delicate relationship between technology and society, culture, ethics, and values. What this case study reflects is the fact that technologies are not neutral. They never are. Hence, technological initiatives must be accompanied by appropriate social, cultural and ethical considerations – especially in projects such as this where the technologies are being introduced into particularly vulnerable societies. That did not happen here, The fact that this project got the support that it did, and has gone as far as it has, given the way it was approached, is why this reminder – in the form of this device – is included in the collection. And if anyone ever wonders why I am so vocal about the need for public discourse around technology, one need look no further than the OLPC project." - } -]
\ No newline at end of file diff --git a/src/scraping/buxton/json/incomplete.json b/src/scraping/buxton/json/incomplete.json deleted file mode 100644 index 4b05a2a86..000000000 --- a/src/scraping/buxton/json/incomplete.json +++ /dev/null @@ -1,468 +0,0 @@ -[ - { - "filename": "3DMag.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "3DPlus.docx", - "year": "ERR__YEAR__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "3DSpace.docx", - "year": "ERR__YEAR__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "3Dconnexion_SpaceNavigator.docx", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "3MErgo.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "ADB2.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "AWrock.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Abaton.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Active.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "AlphaSmart_Pro.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "Apple_ADB_Mouse.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured." - }, - { - "filename": "Apple_Mac_Portable-Katy’s MacBook Air-2.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Apple_Mac_Portable-Katy’s MacBook Air.docx", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Apple_Scroll_Mouse.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Apple_iPhone.docx", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Brailler.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Brewster_Stereoscope.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "CasioTC500.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Citizen_LC_909.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Citizen_LC_913.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured." - }, - { - "filename": "Citizen_LCl_914.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "CoolPix.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Cross.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Dymo_MK-6.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Emotiv.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Explorer.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Falcon.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Freeboard.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match was captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "FujitsuPalm.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "FujitsuTouch.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "GRiD1550-Katy’s MacBook Air-2.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "GRiD1550-Katy’s MacBook Air.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "GRiD1550.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Genius_Ring_Mouse.docx", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "HTC_Touch.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured." - }, - { - "filename": "Helios-Klimax.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Honeywell_T86.docx", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "IBMTrack.docx", - "year": "ERR__YEAR__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "IBM_Convertable-Katy’s MacBook Air-2.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "IBM_Convertable-Katy’s MacBook Air.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "IBM_Convertable.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "IBM_PS2_Mouse.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "IBM_Simon.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value." - }, - { - "filename": "IDEO.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Joyboard.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Kensington_SB_TB-Mouse.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Leatherman_Tread.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "M1.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured." - }, - { - "filename": "MS-1_Stereoscope.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "MWB_Braille_Writer.docx", - "company": "ERR__COMPANY__: outer match wasn't captured.", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "MaltronLH.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "Marine_Band_Harmonica.docx", - "company": "ERR__COMPANY__: outer match was captured.", - "year": "ERR__YEAR__: outer match was captured.", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Matrox.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "Metaphor_Kbd.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Metaphor_Mouse.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Motorola_DynaTAC.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "NewO.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Newton120.docx", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "Nikon_Coolpix-100.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured." - }, - { - "filename": "Numonics_Mgr_Mouse.docx", - "company": "ERR__COMPANY__: outer match was captured.", - "year": "ERR__YEAR__: outer match was captured.", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "PadMouse.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "PowerTrack.docx", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "ProAgio.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Pulsar_time_Computer.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "Ring.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "SafeType_Kbd.docx", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "Samsung_SPH-A500.docx", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "SurfMouse.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured." - }, - { - "filename": "TPARCtab.docx", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "Thumbelina.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured." - }, - { - "filename": "adecm.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "eMate.docx", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "gravis.docx", - "year": "ERR__YEAR__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "iGesture.docx", - "year": "__ERR__YEAR__TRANSFORM__: NaN cannot be parsed to a numeric value.", - "primaryKey": "ERR__PRIMARYKEY__: outer match was captured.", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "iGrip.docx", - "shortDescription": "ERR__SHORTDESCRIPTION__: outer match was captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - }, - { - "filename": "iLiad.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "degreesOfFreedom": "ERR__DEGREESOFFREEDOM__: outer match wasn't captured." - }, - { - "filename": "round.docx", - "secondaryKey": "ERR__SECONDARYKEY__: outer match wasn't captured.", - "originalPrice": "ERR__ORIGINALPRICE__: outer match wasn't captured.", - "dimensions": "ERR__DIMENSIONS__: outer match wasn't captured.", - "longDescription": "ERR__LONGDESCRIPTION__: outer match was captured." - } -]
\ No newline at end of file diff --git a/src/scraping/buxton/node_scraper.ts b/src/scraping/buxton/node_scraper.ts deleted file mode 100644 index ab6c9dcb2..000000000 --- a/src/scraping/buxton/node_scraper.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { readdirSync, writeFile, existsSync, mkdirSync } from "fs"; -import * as path from "path"; -import { red, cyan, yellow, green } from "colors"; -import { Opt } from "../../new_fields/Doc"; -const StreamZip = require('node-stream-zip'); - -export interface DeviceDocument { - title: string; - shortDescription: string; - longDescription: string; - company: string; - year: number; - originalPrice: number; - degreesOfFreedom: number; - dimensions: string; - primaryKey: string; - secondaryKey: string; -} - -interface AnalysisResult { - device?: DeviceDocument; - errors?: any; -} - -type Converter<T> = (raw: string) => { transformed?: T, error?: string }; - -interface Processor<T> { - exp: RegExp; - matchIndex?: number; - transformer?: Converter<T>; -} - -const RegexMap = new Map<keyof DeviceDocument, Processor<any>>([ - ["title", { - exp: /contact\s+(.*)Short Description:/ - }], - ["company", { - exp: /Company:\s+([^\|]*)\s+\|/, - transformer: (raw: string) => ({ transformed: raw.replace(/\./g, "") }) - }], - ["year", { - exp: /Year:\s+([^\|]*)\s+\|/, - transformer: numberValue - }], - ["primaryKey", { - exp: /Primary:\s+(.*)(Secondary|Additional):/, - transformer: collectUniqueTokens - }], - ["secondaryKey", { - exp: /(Secondary|Additional):\s+([^\{\}]*)Links/, - transformer: collectUniqueTokens, - matchIndex: 2 - }], - ["originalPrice", { - exp: /Original Price \(USD\)\:\s+\$([0-9\.]+)/, - transformer: numberValue - }], - ["degreesOfFreedom", { - exp: /Degrees of Freedom:\s+([0-9]+)/, - transformer: numberValue - }], - ["dimensions", { - exp: /Dimensions\s+\(L x W x H\):\s+([0-9\.]+\s+x\s+[0-9\.]+\s+x\s+[0-9\.]+\s\([A-Za-z]+\))/, - transformer: (raw: string) => { - const [length, width, group] = raw.split(" x "); - const [height, unit] = group.split(" "); - return { - transformed: { - length: Number(length), - width: Number(width), - height: Number(height), - unit: unit.replace(/[\(\)]+/g, "") - } - }; - } - }], - ["shortDescription", { - exp: /Short Description:\s+(.*)Bill Buxton[’']s Notes/, - transformer: correctSentences - }], - ["longDescription", { - exp: /Bill Buxton[’']s Notes(.*)Device Details/, - transformer: correctSentences - }], -]); - -function numberValue(raw: string) { - const transformed = Number(raw); - if (isNaN(transformed)) { - return { error: `${transformed} cannot be parsed to a numeric value.` }; - } - return { transformed }; -} - -function collectUniqueTokens(raw: string) { - return { transformed: Array.from(new Set(raw.replace(/,|\s+and\s+/g, " ").split(/\s+/).map(token => token.toLowerCase().trim()))).map(capitalize).sort() }; -} - -function correctSentences(raw: string) { - raw = raw.replace(/\./g, ". ").replace(/\:/g, ": ").replace(/\,/g, ", ").replace(/\?/g, "? ").trimRight(); - raw = raw.replace(/\s{2,}/g, " "); - return { transformed: raw }; -} - -const outDir = path.resolve(__dirname, "json"); -const successOut = "buxton.json"; -const failOut = "incomplete.json"; -const deviceKeys = Array.from(RegexMap.keys()); - -function printEntries(zip: any) { - const { entriesCount } = zip; - console.log(`Recognized ${entriesCount} entr${entriesCount === 1 ? "y" : "ies"}.`); - for (const entry of Object.values<any>(zip.entries())) { - const desc = entry.isDirectory ? 'directory' : `${entry.size} bytes`; - console.log(`${entry.name}: ${desc}`); - } -} - -async function wordToPlainText(pathToDocument: string): Promise<string> { - const zip = new StreamZip({ file: pathToDocument, storeEntries: true }); - const contents = await new Promise<string>((resolve, reject) => { - zip.on('ready', () => { - let body = ""; - zip.stream("word/document.xml", (error: any, stream: any) => { - if (error) { - reject(error); - } - stream.on('data', (chunk: any) => body += chunk.toString()); - stream.on('end', () => { - resolve(body); - zip.close(); - }); - }); - }); - }); - let body = ""; - const components = contents.toString().split('<w:t'); - for (const component of components) { - const tags = component.split('>'); - const content = tags[1].replace(/<.*$/, ""); - body += content; - } - return body; -} - -function tryGetValidCapture(matches: RegExpExecArray | null, matchIndex: number): Opt<string> { - let captured: string; - if (!matches || !(captured = matches[matchIndex])) { - return undefined; - } - const lower = captured.toLowerCase(); - if (/to come/.test(lower)) { - return undefined; - } - if (lower.includes("xxx")) { - return undefined; - } - if (!captured.toLowerCase().replace(/[….\s]+/g, "").length) { - return undefined; - } - return captured; -} - -function capitalize(word: string): string { - const clean = word.trim(); - if (!clean.length) { - return word; - } - return word.charAt(0).toUpperCase() + word.slice(1); -} - -function analyze(path: string, body: string): AnalysisResult { - const device: any = {}; - - const segments = path.split("/"); - const filename = segments[segments.length - 1].replace("Bill_Notes_", ""); - - const errors: any = { filename }; - - for (const key of deviceKeys) { - const { exp, transformer, matchIndex } = RegexMap.get(key)!; - const matches = exp.exec(body); - - let captured = tryGetValidCapture(matches, matchIndex ?? 1); - if (!captured) { - errors[key] = `ERR__${key.toUpperCase()}__: outer match ${matches === null ? "wasn't" : "was"} captured.`; - continue; - } - - captured = captured.replace(/\s{2,}/g, " "); - if (transformer) { - const { error, transformed } = transformer(captured); - if (error) { - errors[key] = `__ERR__${key.toUpperCase()}__TRANSFORM__: ${error}`; - continue; - } - captured = transformed; - } - - device[key] = captured; - } - - const errorKeys = Object.keys(errors); - if (errorKeys.length > 1) { - console.log(red(`\n@ ${cyan(filename.toUpperCase())}...`)); - errorKeys.forEach(key => key !== "filename" && console.log(red(errors[key]))); - return { errors }; - } - - return { device }; -} - -async function parseFiles(): Promise<DeviceDocument[]> { - const sourceDirectory = path.resolve(`${__dirname}/source`); - const candidates = readdirSync(sourceDirectory).filter(file => file.endsWith(".doc") || file.endsWith(".docx")).map(file => `${sourceDirectory}/${file}`); - const imported = await Promise.all(candidates.map(async path => ({ path, body: await wordToPlainText(path) }))); - // const imported = [{ path: candidates[10], body: await extract(candidates[10]) }]; - const data = imported.map(({ path, body }) => analyze(path, body)); - const masterDevices: DeviceDocument[] = []; - const masterErrors: any[] = []; - data.forEach(({ device, errors }) => { - if (device) { - masterDevices.push(device); - } else { - masterErrors.push(errors); - } - }); - const total = candidates.length; - if (masterDevices.length + masterErrors.length !== total) { - throw new Error(`Encountered a ${masterDevices.length} to ${masterErrors.length} mismatch in device / error split!`); - } - console.log(); - await writeOutputFile(successOut, masterDevices, total, true); - await writeOutputFile(failOut, masterErrors, total, false); - console.log(); - - return masterDevices; -} - -async function writeOutputFile(relativePath: string, data: any[], total: number, success: boolean) { - console.log(yellow(`Encountered ${data.length} ${success ? "valid" : "invalid"} documents out of ${total} candidates. Writing ${relativePath}...`)); - return new Promise<void>((resolve, reject) => { - const destination = path.resolve(outDir, relativePath); - const contents = JSON.stringify(data, undefined, 4); - writeFile(destination, contents, err => err ? reject(err) : resolve()); - }); -} - -export async function main() { - if (!existsSync(outDir)) { - mkdirSync(outDir); - } - return parseFiles(); -} - -main();
\ No newline at end of file diff --git a/src/server/ApiManagers/DownloadManager.ts b/src/server/ApiManagers/DownloadManager.ts index fad5e6789..01d2dfcad 100644 --- a/src/server/ApiManagers/DownloadManager.ts +++ b/src/server/ApiManagers/DownloadManager.ts @@ -254,7 +254,7 @@ async function writeHierarchyRecursive(file: Archiver.Archiver, hierarchy: Hiera // and dropped in the browser and thus hosted remotely) so we upload it // to our server and point the zip file to it, so it can bundle up the bytes const information = await DashUploadUtils.UploadImage(result); - path = information instanceof Error ? "" : information.serverAccessPaths[SizeSuffix.Original]; + path = information instanceof Error ? "" : information.accessPaths[SizeSuffix.Original].server; } // write the file specified by the path to the directory in the // zip file given by the prefix. diff --git a/src/server/ApiManagers/GooglePhotosManager.ts b/src/server/ApiManagers/GooglePhotosManager.ts index 1727cc5a6..3236d1ee2 100644 --- a/src/server/ApiManagers/GooglePhotosManager.ts +++ b/src/server/ApiManagers/GooglePhotosManager.ts @@ -21,7 +21,6 @@ interface GooglePhotosUploadFailure { } interface MediaItem { baseUrl: string; - filename: string; } interface NewMediaItem { description: string; @@ -83,12 +82,12 @@ export default class GooglePhotosManager extends ApiManager { method: Method.POST, subscription: "/googlePhotosMediaDownload", secureHandler: async ({ req, res }) => { - const contents: { mediaItems: MediaItem[] } = req.body; + const { mediaItems } = req.body as { mediaItems: MediaItem[] }; let failed = 0; - if (contents) { + if (mediaItems) { const completed: Opt<DashUploadUtils.ImageUploadInformation>[] = []; - for (const item of contents.mediaItems) { - const results = await DashUploadUtils.InspectImage(item.baseUrl); + for (const { baseUrl } of mediaItems) { + const results = await DashUploadUtils.InspectImage(baseUrl); if (results instanceof Error) { failed++; continue; @@ -96,7 +95,7 @@ export default class GooglePhotosManager extends ApiManager { const { contentSize, ...attributes } = results; const found: Opt<DashUploadUtils.ImageUploadInformation> = await Database.Auxiliary.QueryUploadHistory(contentSize); if (!found) { - const upload = await DashUploadUtils.UploadInspectedImage({ contentSize, ...attributes }, item.filename, prefix).catch(error => _error(res, downloadError, error)); + const upload = await DashUploadUtils.UploadInspectedImage({ contentSize, ...attributes }, undefined, prefix, false).catch(error => _error(res, downloadError, error)); if (upload) { completed.push(upload); await Database.Auxiliary.LogUpload(upload); diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts index f1629b8f0..bcaa6598f 100644 --- a/src/server/ApiManagers/SessionManager.ts +++ b/src/server/ApiManagers/SessionManager.ts @@ -53,6 +53,15 @@ export default class SessionManager extends ApiManager { }) }); + register({ + method: Method.GET, + subscription: this.secureSubscriber("delete"), + secureHandler: this.authorizedAction(async ({ res }) => { + const { error } = await sessionAgent.serverWorker.emit("delete"); + res.send(error ? error.message : "Your request was successful: the server successfully deleted the database. Return to /home."); + }) + }); + } }
\ No newline at end of file diff --git a/src/server/ApiManagers/UserManager.ts b/src/server/ApiManagers/UserManager.ts index b0d868918..d9d346cc1 100644 --- a/src/server/ApiManagers/UserManager.ts +++ b/src/server/ApiManagers/UserManager.ts @@ -34,7 +34,7 @@ export default class UserManager extends ApiManager { register({ method: Method.GET, subscription: "/getCurrentUser", - secureHandler: ({ res, user }) => res.send(JSON.stringify(user)), + secureHandler: ({ res, user: { _id, email } }) => res.send(JSON.stringify({ id: _id, email })), publicHandler: ({ res }) => res.send(JSON.stringify({ id: "__guest__", email: "" })) }); diff --git a/src/server/ApiManagers/UtilManager.ts b/src/server/ApiManagers/UtilManager.ts index dbf274e93..8adc3da81 100644 --- a/src/server/ApiManagers/UtilManager.ts +++ b/src/server/ApiManagers/UtilManager.ts @@ -1,10 +1,9 @@ import ApiManager, { Registration } from "./ApiManager"; import { Method } from "../RouteManager"; import { exec } from 'child_process'; -import { command_line } from "../ActionUtilities"; import RouteSubscriber from "../RouteSubscriber"; import { red } from "colors"; -import { main } from "../../scraping/buxton/node_scraper"; +import executeImport from "../../scraping/buxton/final/BuxtonImporter"; export default class UtilManager extends ApiManager { @@ -42,31 +41,6 @@ export default class UtilManager extends ApiManager { register({ method: Method.GET, - subscription: "/buxton", - secureHandler: async ({ res }) => { - const cwd = './src/scraping/buxton'; - - const onResolved = (stdout: string) => { console.log(stdout); res.redirect("/"); }; - const onRejected = (err: any) => { console.error(err.message); res.send(err); }; - const tryPython3 = (reason: any) => { - console.log("Initial scraper failed for the following reason:"); - console.log(red(reason.Error)); - console.log("Falling back to python3..."); - return command_line('python3 scraper.py', cwd).then(onResolved, onRejected); - }; - - return command_line('python scraper.py', cwd).then(onResolved, tryPython3); - }, - }); - - register({ - method: Method.GET, - subscription: "/newBuxton", - secureHandler: async ({ res }) => res.send(await main()) - }); - - register({ - method: Method.GET, subscription: "/version", secureHandler: ({ res }) => { return new Promise<void>(resolve => { diff --git a/src/server/DashSession/DashSessionAgent.ts b/src/server/DashSession/DashSessionAgent.ts index d61e9aac1..1ed98cdbe 100644 --- a/src/server/DashSession/DashSessionAgent.ts +++ b/src/server/DashSession/DashSessionAgent.ts @@ -8,8 +8,11 @@ import { launchServer, onWindows } from ".."; import { readdirSync, statSync, createWriteStream, readFileSync, unlinkSync } from "fs"; import * as Archiver from "archiver"; import { resolve } from "path"; -import { AppliedSessionAgent, MessageHandler, ExitHandler, Monitor, ServerWorker } from "resilient-server-session"; import rimraf = require("rimraf"); +import { AppliedSessionAgent, ExitHandler } from "./Session/agents/applied_session_agent"; +import { ServerWorker } from "./Session/agents/server_worker"; +import { Monitor } from "./Session/agents/monitor"; +import { MessageHandler } from "./Session/agents/promisified_ipc_manager"; /** * If we're the monitor (master) thread, we should launch the monitor logic for the session. @@ -34,6 +37,7 @@ export class DashSessionAgent extends AppliedSessionAgent { monitor.addReplCommand("debug", [/\S+\@\S+/], async ([to]) => this.dispatchZippedDebugBackup(to)); monitor.on("backup", this.backup); monitor.on("debug", async ({ to }) => this.dispatchZippedDebugBackup(to)); + monitor.on("delete", WebSocket.deleteFields); monitor.coreHooks.onCrashDetected(this.dispatchCrashReport); return sessionKey; } diff --git a/src/server/DashSession/Session/agents/applied_session_agent.ts b/src/server/DashSession/Session/agents/applied_session_agent.ts new file mode 100644 index 000000000..46c9e22ed --- /dev/null +++ b/src/server/DashSession/Session/agents/applied_session_agent.ts @@ -0,0 +1,58 @@ +import { isMaster } from "cluster"; +import { Monitor } from "./monitor"; +import { ServerWorker } from "./server_worker"; +import { Utilities } from "../utilities/utilities"; + +export type ExitHandler = (reason: Error | boolean) => void | Promise<void>; + +export abstract class AppliedSessionAgent { + + // the following two methods allow the developer to create a custom + // session and use the built in customization options for each thread + protected abstract async initializeMonitor(monitor: Monitor): Promise<string>; + protected abstract async initializeServerWorker(): Promise<ServerWorker>; + + private launched = false; + + public killSession = (reason: string, graceful = true, errorCode = 0) => { + const target = isMaster ? this.sessionMonitor : this.serverWorker; + target.killSession(reason, graceful, errorCode); + } + + private sessionMonitorRef: Monitor | undefined; + public get sessionMonitor(): Monitor { + if (!isMaster) { + this.serverWorker.emit("kill", { + graceful: false, + reason: "Cannot access the session monitor directly from the server worker thread.", + errorCode: 1 + }); + throw new Error(); + } + return this.sessionMonitorRef!; + } + + private serverWorkerRef: ServerWorker | undefined; + public get serverWorker(): ServerWorker { + if (isMaster) { + throw new Error("Cannot access the server worker directly from the session monitor thread"); + } + return this.serverWorkerRef!; + } + + public async launch(): Promise<void> { + if (!this.launched) { + this.launched = true; + if (isMaster) { + this.sessionMonitorRef = Monitor.Create() + const sessionKey = await this.initializeMonitor(this.sessionMonitorRef); + this.sessionMonitorRef.finalize(sessionKey); + } else { + this.serverWorkerRef = await this.initializeServerWorker(); + } + } else { + throw new Error("Cannot launch a session thread more than once per process."); + } + } + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/agents/monitor.ts b/src/server/DashSession/Session/agents/monitor.ts new file mode 100644 index 000000000..6f8d25614 --- /dev/null +++ b/src/server/DashSession/Session/agents/monitor.ts @@ -0,0 +1,298 @@ +import { ExitHandler } from "./applied_session_agent"; +import { Configuration, configurationSchema, defaultConfig, Identifiers, colorMapping } from "../utilities/session_config"; +import Repl, { ReplAction } from "../utilities/repl"; +import { isWorker, setupMaster, on, Worker, fork } from "cluster"; +import { manage, MessageHandler } from "./promisified_ipc_manager"; +import { red, cyan, white, yellow, blue } from "colors"; +import { exec, ExecOptions } from "child_process"; +import { validate, ValidationError } from "jsonschema"; +import { Utilities } from "../utilities/utilities"; +import { readFileSync } from "fs"; +import IPCMessageReceiver from "./process_message_router"; +import { ServerWorker } from "./server_worker"; + +/** + * Validates and reads the configuration file, accordingly builds a child process factory + * and spawns off an initial process that will respawn as predecessors die. + */ +export class Monitor extends IPCMessageReceiver { + private static count = 0; + private finalized = false; + private exitHandlers: ExitHandler[] = []; + private readonly config: Configuration; + private activeWorker: Worker | undefined; + private key: string | undefined; + // private repl: Repl; + + public static Create() { + if (isWorker) { + ServerWorker.IPCManager.emit("kill", { + reason: "cannot create a monitor on the worker process.", + graceful: false, + errorCode: 1 + }); + process.exit(1); + } else if (++Monitor.count > 1) { + console.error(red("cannot create more than one monitor.")); + process.exit(1); + } else { + return new Monitor(); + } + } + + private constructor() { + super(); + console.log(this.timestamp(), cyan("initializing session...")); + this.configureInternalHandlers(); + this.config = this.loadAndValidateConfiguration(); + this.initializeClusterFunctions(); + // this.repl = this.initializeRepl(); + } + + protected configureInternalHandlers = () => { + // handle exceptions in the master thread - there shouldn't be many of these + // the IPC (inter process communication) channel closed exception can't seem + // to be caught in a try catch, and is inconsequential, so it is ignored + process.on("uncaughtException", ({ message, stack }): void => { + if (message !== "Channel closed") { + this.mainLog(red(message)); + if (stack) { + this.mainLog(`uncaught exception\n${red(stack)}`); + } + } + }); + + this.on("kill", ({ reason, graceful, errorCode }) => this.killSession(reason, graceful, errorCode)); + this.on("lifecycle", ({ event }) => console.log(this.timestamp(), `${this.config.identifiers.worker.text} lifecycle phase (${event})`)); + } + + private initializeClusterFunctions = () => { + // determines whether or not we see the compilation / initialization / runtime output of each child server process + const output = this.config.showServerOutput ? "inherit" : "ignore"; + setupMaster({ stdio: ["ignore", output, output, "ipc"] }); + + // a helpful cluster event called on the master thread each time a child process exits + on("exit", ({ process: { pid } }, code, signal) => { + const prompt = `server worker with process id ${pid} has exited with code ${code}${signal === null ? "" : `, having encountered signal ${signal}`}.`; + this.mainLog(cyan(prompt)); + // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one + this.spawn(); + }); + } + + public finalize = (sessionKey: string): void => { + if (this.finalized) { + throw new Error("Session monitor is already finalized"); + } + this.finalized = true; + this.key = sessionKey; + this.spawn(); + } + + public readonly coreHooks = Object.freeze({ + onCrashDetected: (listener: MessageHandler<{ error: Error }>) => this.on(Monitor.IntrinsicEvents.CrashDetected, listener), + onServerRunning: (listener: MessageHandler<{ isFirstTime: boolean }>) => this.on(Monitor.IntrinsicEvents.ServerRunning, listener) + }); + + /** + * Kill this session and its active child + * server process, either gracefully (may wait + * indefinitely, but at least allows active networking + * requests to complete) or immediately. + */ + public killSession = async (reason: string, graceful = true, errorCode = 0) => { + this.mainLog(cyan(`exiting session ${graceful ? "clean" : "immediate"}ly`)); + this.mainLog(`session exit reason: ${(red(reason))}`); + await this.executeExitHandlers(true); + await this.killActiveWorker(graceful, true); + process.exit(errorCode); + } + + /** + * Execute the list of functions registered to be called + * whenever the process exits. + */ + public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler); + + /** + * Extend the default repl by adding in custom commands + * that can invoke application logic external to this module + */ + public addReplCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => { + // this.repl.registerCommand(basename, argPatterns, action); + } + + public exec = (command: string, options?: ExecOptions) => { + return new Promise<void>(resolve => { + exec(command, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { + if (error) { + this.execLog(red(`unable to execute ${white(command)}`)); + error.message.split("\n").forEach(line => line.length && this.execLog(red(`(error) ${line}`))); + } else { + let outLines: string[], errorLines: string[]; + if ((outLines = stdout.split("\n").filter(line => line.length)).length) { + outLines.forEach(line => line.length && this.execLog(cyan(`(stdout) ${line}`))); + } + if ((errorLines = stderr.split("\n").filter(line => line.length)).length) { + errorLines.forEach(line => line.length && this.execLog(yellow(`(stderr) ${line}`))); + } + } + resolve(); + }); + }); + } + + /** + * Generates a blue UTC string associated with the time + * of invocation. + */ + private timestamp = () => blue(`[${new Date().toUTCString()}]`); + + /** + * A formatted, identified and timestamped log in color + */ + public mainLog = (...optionalParams: any[]) => { + console.log(this.timestamp(), this.config.identifiers.master.text, ...optionalParams); + } + + /** + * A formatted, identified and timestamped log in color for non- + */ + private execLog = (...optionalParams: any[]) => { + console.log(this.timestamp(), this.config.identifiers.exec.text, ...optionalParams); + } + + /** + * Reads in configuration .json file only once, in the master thread + * and pass down any variables the pertinent to the child processes as environment variables. + */ + private loadAndValidateConfiguration = (): Configuration => { + let config: Configuration; + try { + console.log(this.timestamp(), cyan("validating configuration...")); + config = JSON.parse(readFileSync('./session.config.json', 'utf8')); + const options = { + throwError: true, + allowUnknownAttributes: false + }; + // ensure all necessary and no excess information is specified by the configuration file + validate(config, configurationSchema, options); + config = Utilities.preciseAssign({}, defaultConfig, config); + } catch (error) { + if (error instanceof ValidationError) { + console.log(red("\nSession configuration failed.")); + console.log("The given session.config.json configuration file is invalid."); + console.log(`${error.instance}: ${error.stack}`); + process.exit(0); + } else if (error.code === "ENOENT" && error.path === "./session.config.json") { + console.log(cyan("Loading default session parameters...")); + console.log("Consider including a session.config.json configuration file in your project root for customization."); + config = Utilities.preciseAssign({}, defaultConfig); + } else { + console.log(red("\nSession configuration failed.")); + console.log("The following unknown error occurred during configuration."); + console.log(error.stack); + process.exit(0); + } + } finally { + const { identifiers } = config!; + Object.keys(identifiers).forEach(key => { + const resolved = key as keyof Identifiers; + const { text, color } = identifiers[resolved]; + identifiers[resolved].text = (colorMapping.get(color) || white)(`${text}:`); + }); + return config!; + } + } + + /** + * Builds the repl that allows the following commands to be typed into stdin of the master thread. + */ + private initializeRepl = (): Repl => { + const repl = new Repl({ identifier: () => `${this.timestamp()} ${this.config.identifiers.master.text}` }); + const boolean = /true|false/; + const number = /\d+/; + const letters = /[a-zA-Z]+/; + repl.registerCommand("exit", [/clean|force/], args => this.killSession("manual exit requested by repl", args[0] === "clean", 0)); + repl.registerCommand("restart", [/clean|force/], args => this.killActiveWorker(args[0] === "clean")); + repl.registerCommand("set", [letters, "port", number, boolean], args => this.setPort(args[0], Number(args[2]), args[3] === "true")); + repl.registerCommand("set", [/polling/, number, boolean], args => { + const newPollingIntervalSeconds = Math.floor(Number(args[1])); + if (newPollingIntervalSeconds < 0) { + this.mainLog(red("the polling interval must be a non-negative integer")); + } else { + if (newPollingIntervalSeconds !== this.config.polling.intervalSeconds) { + this.config.polling.intervalSeconds = newPollingIntervalSeconds; + if (args[2] === "true") { + Monitor.IPCManager.emit("updatePollingInterval", { newPollingIntervalSeconds }); + } + } + } + }); + return repl; + } + + private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason))); + + /** + * Attempts to kill the active worker gracefully, unless otherwise specified. + */ + private killActiveWorker = async (graceful = true, isSessionEnd = false): Promise<void> => { + if (this.activeWorker && !this.activeWorker.isDead()) { + if (graceful) { + Monitor.IPCManager.emit("manualExit", { isSessionEnd }); + } else { + await ServerWorker.IPCManager.destroy(); + this.activeWorker.process.kill(); + } + } + } + + /** + * Allows the caller to set the port at which the target (be it the server, + * the websocket, some other custom port) is listening. If an immediate restart + * is specified, this monitor will kill the active child and re-launch the server + * at the port. Otherwise, the updated port won't be used until / unless the child + * dies on its own and triggers a restart. + */ + private setPort = (port: "server" | "socket" | string, value: number, immediateRestart: boolean): void => { + if (value > 1023 && value < 65536) { + this.config.ports[port] = value; + if (immediateRestart) { + this.killActiveWorker(); + } + } else { + this.mainLog(red(`${port} is an invalid port number`)); + } + } + + /** + * Kills the current active worker and proceeds to spawn a new worker, + * feeding in configuration information as environment variables. + */ + private spawn = async (): Promise<void> => { + await this.killActiveWorker(); + const { config: { polling, ports }, key } = this; + this.activeWorker = fork({ + pollingRoute: polling.route, + pollingFailureTolerance: polling.failureTolerance, + serverPort: ports.server, + socketPort: ports.socket, + pollingIntervalSeconds: polling.intervalSeconds, + session_key: key + }); + Monitor.IPCManager = manage(this.activeWorker.process, this.handlers); + this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker?.process.pid}`)); + } + +} + +export namespace Monitor { + + export enum IntrinsicEvents { + KeyGenerated = "key_generated", + CrashDetected = "crash_detected", + ServerRunning = "server_running" + } + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/agents/process_message_router.ts b/src/server/DashSession/Session/agents/process_message_router.ts new file mode 100644 index 000000000..6cc8aa941 --- /dev/null +++ b/src/server/DashSession/Session/agents/process_message_router.ts @@ -0,0 +1,41 @@ +import { MessageHandler, PromisifiedIPCManager, HandlerMap } from "./promisified_ipc_manager"; + +export default abstract class IPCMessageReceiver { + + protected static IPCManager: PromisifiedIPCManager; + protected handlers: HandlerMap = {}; + + protected abstract configureInternalHandlers: () => void; + + /** + * Add a listener at this message. When the monitor process + * receives a message, it will invoke all registered functions. + */ + public on = (name: string, handler: MessageHandler) => { + const handlers = this.handlers[name]; + if (!handlers) { + this.handlers[name] = [handler]; + } else { + handlers.push(handler); + } + } + + /** + * Unregister a given listener at this message. + */ + public off = (name: string, handler: MessageHandler) => { + const handlers = this.handlers[name]; + if (handlers) { + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + } + + /** + * Unregister all listeners at this message. + */ + public clearMessageListeners = (...names: string[]) => names.map(name => delete this.handlers[name]); + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/agents/promisified_ipc_manager.ts b/src/server/DashSession/Session/agents/promisified_ipc_manager.ts new file mode 100644 index 000000000..9f0db8330 --- /dev/null +++ b/src/server/DashSession/Session/agents/promisified_ipc_manager.ts @@ -0,0 +1,173 @@ +import { Utilities } from "../utilities/utilities"; +import { ChildProcess } from "child_process"; + +/** + * Convenience constructor + * @param target the process / worker to which to attach the specialized listeners + */ +export function manage(target: IPCTarget, handlers?: HandlerMap) { + return new PromisifiedIPCManager(target, handlers); +} + +/** + * Captures the logic to execute upon receiving a message + * of a certain name. + */ +export type HandlerMap = { [name: string]: MessageHandler[] }; + +/** + * This will always literally be a child process. But, though setting + * up a manager in the parent will indeed see the target as the ChildProcess, + * setting up a manager in the child will just see itself as a regular NodeJS.Process. + */ +export type IPCTarget = NodeJS.Process | ChildProcess; + +/** + * Specifies a general message format for this API + */ +export type Message<T = any> = { + name: string; + args?: T; +}; +export type MessageHandler<T = any> = (args: T) => (any | Promise<any>); + +/** + * When a message is emitted, it is embedded with private metadata + * to facilitate the resolution of promises, etc. + */ +interface InternalMessage extends Message { metadata: Metadata } +interface Metadata { isResponse: boolean; id: string } +type InternalMessageHandler = (message: InternalMessage) => (any | Promise<any>); + +/** + * Allows for the transmission of the error's key features over IPC. + */ +export interface ErrorLike { + name?: string; + message?: string; + stack?: string; +} + +/** + * The arguments returned in a message sent from the target upon completion. + */ +export interface Response<T = any> { + results?: T[]; + error?: ErrorLike; +} + +const destroyEvent = "__destroy__"; + +/** + * This is a wrapper utility class that allows the caller process + * to emit an event and return a promise that resolves when it and all + * other processes listening to its emission of this event have completed. + */ +export class PromisifiedIPCManager { + private readonly target: IPCTarget; + private pendingMessages: { [id: string]: string } = {}; + private isDestroyed = false; + private get callerIsTarget() { + return process.pid === this.target.pid; + } + + constructor(target: IPCTarget, handlers?: HandlerMap) { + this.target = target; + if (handlers) { + handlers[destroyEvent] = [this.destroyHelper]; + this.target.addListener("message", this.generateInternalHandler(handlers)); + } + } + + /** + * This routine uniquely identifies each message, then adds a general + * message listener that waits for a response with the same id before resolving + * the promise. + */ + public emit = async <T = any>(name: string, args?: any): Promise<Response<T>> => { + if (this.isDestroyed) { + const error = { name: "FailedDispatch", message: "Cannot use a destroyed IPC manager to emit a message." }; + return { error }; + } + return new Promise<Response<T>>(resolve => { + const messageId = Utilities.guid(); + const responseHandler: InternalMessageHandler = ({ metadata: { id, isResponse }, args }) => { + if (isResponse && id === messageId) { + this.target.removeListener("message", responseHandler); + resolve(args); + } + }; + this.target.addListener("message", responseHandler); + const message = { name, args, metadata: { id: messageId, isResponse: false } }; + if (!(this.target.send && this.target.send(message))) { + const error: ErrorLike = { name: "FailedDispatch", message: "Either the target's send method was undefined or the act of sending failed." }; + resolve({ error }); + this.target.removeListener("message", responseHandler); + } + }); + } + + /** + * Invoked from either the parent or the child process, this allows + * any unresolved promises to continue in the target process, but dispatches a dummy + * completion response for each of the pending messages, allowing their + * promises in the caller to resolve. + */ + public destroy = () => { + return new Promise<void>(async resolve => { + if (this.callerIsTarget) { + this.destroyHelper(); + } else { + await this.emit(destroyEvent); + } + resolve(); + }); + } + + /** + * Dispatches the dummy responses and sets the isDestroyed flag to true. + */ + private destroyHelper = () => { + const { pendingMessages } = this; + this.isDestroyed = true; + Object.keys(pendingMessages).forEach(id => { + const error: ErrorLike = { name: "ManagerDestroyed", message: "The IPC manager was destroyed before the response could be returned." }; + const message: InternalMessage = { name: pendingMessages[id], args: { error }, metadata: { id, isResponse: true } }; + this.target.send?.(message) + }); + this.pendingMessages = {}; + } + + /** + * This routine receives a uniquely identified message. If the message is itself a response, + * it is ignored to avoid infinite mutual responses. Otherwise, the routine awaits its completion using whatever + * router the caller has installed, and then sends a response containing the original message id, + * which will ultimately invoke the responseHandler of the original emission and resolve the + * sender's promise. + */ + private generateInternalHandler = (handlers: HandlerMap): MessageHandler => async (message: InternalMessage) => { + const { name, args, metadata } = message; + if (name && metadata && !metadata.isResponse) { + const { id } = metadata; + this.pendingMessages[id] = name; + let error: Error | undefined; + let results: any[] | undefined; + try { + const registered = handlers[name]; + if (registered) { + results = await Promise.all(registered.map(handler => handler(args))); + } + } catch (e) { + error = e; + } + if (!this.isDestroyed && this.target.send) { + const metadata = { id, isResponse: true }; + const response: Response = { results , error }; + const message = { name, args: response , metadata }; + delete this.pendingMessages[id]; + this.target.send(message); + } + } + } + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/agents/server_worker.ts b/src/server/DashSession/Session/agents/server_worker.ts new file mode 100644 index 000000000..976d27226 --- /dev/null +++ b/src/server/DashSession/Session/agents/server_worker.ts @@ -0,0 +1,160 @@ +import { ExitHandler } from "./applied_session_agent"; +import { isMaster } from "cluster"; +import { manage } from "./promisified_ipc_manager"; +import IPCMessageReceiver from "./process_message_router"; +import { red, green, white, yellow } from "colors"; +import { get } from "request-promise"; +import { Monitor } from "./monitor"; + +/** + * Effectively, each worker repairs the connection to the server by reintroducing a consistent state + * if its predecessor has died. It itself also polls the server heartbeat, and exits with a notification + * email if the server encounters an uncaught exception or if the server cannot be reached. + */ +export class ServerWorker extends IPCMessageReceiver { + private static count = 0; + private shouldServerBeResponsive = false; + private exitHandlers: ExitHandler[] = []; + private pollingFailureCount = 0; + private pollingIntervalSeconds: number; + private pollingFailureTolerance: number; + private pollTarget: string; + private serverPort: number; + private isInitialized = false; + + public static Create(work: Function) { + if (isMaster) { + console.error(red("cannot create a worker on the monitor process.")); + process.exit(1); + } else if (++ServerWorker.count > 1) { + ServerWorker.IPCManager.emit("kill", { + reason: "cannot create more than one worker on a given worker process.", + graceful: false, + errorCode: 1 + }); + process.exit(1); + } else { + return new ServerWorker(work); + } + } + + /** + * Allows developers to invoke application specific logic + * by hooking into the exiting of the server process. + */ + public addExitHandler = (handler: ExitHandler) => this.exitHandlers.push(handler); + + /** + * Kill the session monitor (parent process) from this + * server worker (child process). This will also kill + * this process (child process). + */ + public killSession = (reason: string, graceful = true, errorCode = 0) => this.emit<never>("kill", { reason, graceful, errorCode }); + + /** + * A convenience wrapper to tell the session monitor (parent process) + * to carry out the action with the specified message and arguments. + */ + public emit = async <T = any>(name: string, args?: any) => ServerWorker.IPCManager.emit<T>(name, args); + + private constructor(work: Function) { + super(); + this.configureInternalHandlers(); + ServerWorker.IPCManager = manage(process, this.handlers); + this.lifecycleNotification(green(`initializing process... ${white(`[${process.execPath} ${process.execArgv.join(" ")}]`)}`)); + + const { pollingRoute, serverPort, pollingIntervalSeconds, pollingFailureTolerance } = process.env; + this.serverPort = Number(serverPort); + this.pollingIntervalSeconds = Number(pollingIntervalSeconds); + this.pollingFailureTolerance = Number(pollingFailureTolerance); + this.pollTarget = `http://localhost:${serverPort}${pollingRoute}`; + + work(); + this.pollServer(); + } + + /** + * Set up message and uncaught exception handlers for this + * server process. + */ + protected configureInternalHandlers = () => { + // updates the local values of variables to the those sent from master + this.on("updatePollingInterval", ({ newPollingIntervalSeconds }) => this.pollingIntervalSeconds = newPollingIntervalSeconds); + this.on("manualExit", async ({ isSessionEnd }) => { + await ServerWorker.IPCManager.destroy(); + await this.executeExitHandlers(isSessionEnd); + process.exit(0); + }); + + // one reason to exit, as the process might be in an inconsistent state after such an exception + process.on('uncaughtException', this.proactiveUnplannedExit); + process.on('unhandledRejection', reason => { + const appropriateError = reason instanceof Error ? reason : new Error(`unhandled rejection: ${reason}`); + this.proactiveUnplannedExit(appropriateError); + }); + } + + /** + * Execute the list of functions registered to be called + * whenever the process exits. + */ + private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason))); + + /** + * Notify master thread (which will log update in the console) of initialization via IPC. + */ + public lifecycleNotification = (event: string) => this.emit("lifecycle", { event }); + + /** + * Called whenever the process has a reason to terminate, either through an uncaught exception + * in the process (potentially inconsistent state) or the server cannot be reached. + */ + private proactiveUnplannedExit = async (error: Error): Promise<void> => { + this.shouldServerBeResponsive = false; + // communicates via IPC to the master thread that it should dispatch a crash notification email + this.emit(Monitor.IntrinsicEvents.CrashDetected, { error }); + await this.executeExitHandlers(error); + // notify master thread (which will log update in the console) of crash event via IPC + this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`)); + this.lifecycleNotification(red(error.message)); + await ServerWorker.IPCManager.destroy(); + process.exit(1); + } + + /** + * This monitors the health of the server by submitting a get request to whatever port / route specified + * by the configuration every n seconds, where n is also given by the configuration. + */ + private pollServer = async (): Promise<void> => { + await new Promise<void>(resolve => { + setTimeout(async () => { + try { + await get(this.pollTarget); + if (!this.shouldServerBeResponsive) { + // notify monitor thread that the server is up and running + this.lifecycleNotification(green(`listening on ${this.serverPort}...`)); + this.emit(Monitor.IntrinsicEvents.ServerRunning, { isFirstTime: !this.isInitialized }); + this.isInitialized = true; + } + this.shouldServerBeResponsive = true; + } catch (error) { + // if we expect the server to be unavailable, i.e. during compilation, + // the listening variable is false, activeExit will return early and the child + // process will continue + if (this.shouldServerBeResponsive) { + if (++this.pollingFailureCount > this.pollingFailureTolerance) { + this.proactiveUnplannedExit(error); + } else { + this.lifecycleNotification(yellow(`the server has encountered ${this.pollingFailureCount} of ${this.pollingFailureTolerance} tolerable failures`)); + } + } + } finally { + resolve(); + } + }, 1000 * this.pollingIntervalSeconds); + }); + // controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed + this.pollServer(); + } + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/utilities/repl.ts b/src/server/DashSession/Session/utilities/repl.ts new file mode 100644 index 000000000..643141286 --- /dev/null +++ b/src/server/DashSession/Session/utilities/repl.ts @@ -0,0 +1,128 @@ +import { createInterface, Interface } from "readline"; +import { red, green, white } from "colors"; + +export interface Configuration { + identifier: () => string | string; + onInvalid?: (command: string, validCommand: boolean) => string | string; + onValid?: (success?: string) => string | string; + isCaseSensitive?: boolean; +} + +export type ReplAction = (parsedArgs: Array<string>) => any | Promise<any>; +export interface Registration { + argPatterns: RegExp[]; + action: ReplAction; +} + +export default class Repl { + private identifier: () => string | string; + private onInvalid: ((command: string, validCommand: boolean) => string) | string; + private onValid: ((success: string) => string) | string; + private isCaseSensitive: boolean; + private commandMap = new Map<string, Registration[]>(); + public interface: Interface; + private busy = false; + private keys: string | undefined; + + constructor({ identifier: prompt, onInvalid, onValid, isCaseSensitive }: Configuration) { + this.identifier = prompt; + this.onInvalid = onInvalid || this.usage; + this.onValid = onValid || this.success; + this.isCaseSensitive = isCaseSensitive ?? true; + this.interface = createInterface(process.stdin, process.stdout).on('line', this.considerInput); + } + + private resolvedIdentifier = () => typeof this.identifier === "string" ? this.identifier : this.identifier(); + + private usage = (command: string, validCommand: boolean) => { + if (validCommand) { + const formatted = white(command); + const patterns = green(this.commandMap.get(command)!.map(({ argPatterns }) => `${formatted} ${argPatterns.join(" ")}`).join('\n')); + return `${this.resolvedIdentifier()}\nthe given arguments do not match any registered patterns for ${formatted}\nthe list of valid argument patterns is given by:\n${patterns}`; + } else { + const resolved = this.keys; + if (resolved) { + return resolved; + } + const members: string[] = []; + const keys = this.commandMap.keys(); + let next: IteratorResult<string>; + while (!(next = keys.next()).done) { + members.push(next.value); + } + return `${this.resolvedIdentifier()} commands: { ${members.sort().join(", ")} }`; + } + } + + private success = (command: string) => `${this.resolvedIdentifier()} completed local execution of ${white(command)}`; + + public registerCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => { + const existing = this.commandMap.get(basename); + const converted = argPatterns.map(input => input instanceof RegExp ? input : new RegExp(input)); + const registration = { argPatterns: converted, action }; + if (existing) { + existing.push(registration); + } else { + this.commandMap.set(basename, [registration]); + } + } + + private invalid = (command: string, validCommand: boolean) => { + console.log(red(typeof this.onInvalid === "string" ? this.onInvalid : this.onInvalid(command, validCommand))); + this.busy = false; + } + + private valid = (command: string) => { + console.log(green(typeof this.onValid === "string" ? this.onValid : this.onValid(command))); + this.busy = false; + } + + private considerInput = async (line: string) => { + if (this.busy) { + console.log(red("Busy")); + return; + } + this.busy = true; + line = line.trim(); + if (this.isCaseSensitive) { + line = line.toLowerCase(); + } + const [command, ...args] = line.split(/\s+/g); + if (!command) { + return this.invalid(command, false); + } + const registered = this.commandMap.get(command); + if (registered) { + const { length } = args; + const candidates = registered.filter(({ argPatterns: { length: count } }) => count === length); + for (const { argPatterns, action } of candidates) { + const parsed: string[] = []; + let matched = true; + if (length) { + for (let i = 0; i < length; i++) { + let matches: RegExpExecArray | null; + if ((matches = argPatterns[i].exec(args[i])) === null) { + matched = false; + break; + } + parsed.push(matches[0]); + } + } + if (!length || matched) { + const result = action(parsed); + const resolve = () => this.valid(`${command} ${parsed.join(" ")}`); + if (result instanceof Promise) { + result.then(resolve); + } else { + resolve(); + } + return; + } + } + this.invalid(command, true); + } else { + this.invalid(command, false); + } + } + +}
\ No newline at end of file diff --git a/src/server/DashSession/Session/utilities/session_config.ts b/src/server/DashSession/Session/utilities/session_config.ts new file mode 100644 index 000000000..b0e65dde4 --- /dev/null +++ b/src/server/DashSession/Session/utilities/session_config.ts @@ -0,0 +1,129 @@ +import { Schema } from "jsonschema"; +import { yellow, red, cyan, green, blue, magenta, Color, grey, gray, white, black } from "colors"; + +const colorPattern = /black|red|green|yellow|blue|magenta|cyan|white|gray|grey/; + +const identifierProperties: Schema = { + type: "object", + properties: { + text: { + type: "string", + minLength: 1 + }, + color: { + type: "string", + pattern: colorPattern + } + } +}; + +const portProperties: Schema = { + type: "number", + minimum: 1024, + maximum: 65535 +}; + +export const configurationSchema: Schema = { + id: "/configuration", + type: "object", + properties: { + showServerOutput: { type: "boolean" }, + ports: { + type: "object", + properties: { + server: portProperties, + socket: portProperties + }, + required: ["server"], + additionalProperties: true + }, + identifiers: { + type: "object", + properties: { + master: identifierProperties, + worker: identifierProperties, + exec: identifierProperties + } + }, + polling: { + type: "object", + additionalProperties: false, + properties: { + intervalSeconds: { + type: "number", + minimum: 1, + maximum: 86400 + }, + route: { + type: "string", + pattern: /\/[a-zA-Z]*/g + }, + failureTolerance: { + type: "number", + minimum: 0, + } + } + }, + } +}; + +type ColorLabel = "yellow" | "red" | "cyan" | "green" | "blue" | "magenta" | "grey" | "gray" | "white" | "black"; + +export const colorMapping: Map<ColorLabel, Color> = new Map([ + ["yellow", yellow], + ["red", red], + ["cyan", cyan], + ["green", green], + ["blue", blue], + ["magenta", magenta], + ["grey", grey], + ["gray", gray], + ["white", white], + ["black", black] +]); + +interface Identifier { + text: string; + color: ColorLabel; +} + +export interface Identifiers { + master: Identifier; + worker: Identifier; + exec: Identifier; +} + +export interface Configuration { + showServerOutput: boolean; + identifiers: Identifiers; + ports: { [description: string]: number }; + polling: { + route: string; + intervalSeconds: number; + failureTolerance: number; + }; +} + +export const defaultConfig: Configuration = { + showServerOutput: false, + identifiers: { + master: { + text: "__monitor__", + color: "yellow" + }, + worker: { + text: "__server__", + color: "magenta" + }, + exec: { + text: "__exec__", + color: "green" + } + }, + ports: { server: 3000 }, + polling: { + route: "/", + intervalSeconds: 30, + failureTolerance: 0 + } +};
\ No newline at end of file diff --git a/src/server/DashSession/Session/utilities/utilities.ts b/src/server/DashSession/Session/utilities/utilities.ts new file mode 100644 index 000000000..eb8de9d7e --- /dev/null +++ b/src/server/DashSession/Session/utilities/utilities.ts @@ -0,0 +1,37 @@ +import { v4 } from "uuid"; + +export namespace Utilities { + + export function guid() { + return v4(); + } + + /** + * At any arbitrary layer of nesting within the configuration objects, any single value that + * is not specified by the configuration is given the default counterpart. If, within an object, + * one peer is given by configuration and two are not, the one is preserved while the two are given + * the default value. + * @returns the composition of all of the assigned objects, much like Object.assign(), but with more + * granularity in the overwriting of nested objects + */ + export function preciseAssign(target: any, ...sources: any[]): any { + for (const source of sources) { + preciseAssignHelper(target, source); + } + return target; + } + + export function preciseAssignHelper(target: any, source: any) { + Array.from(new Set([...Object.keys(target), ...Object.keys(source)])).map(property => { + let targetValue: any, sourceValue: any; + if (sourceValue = source[property]) { + if (typeof sourceValue === "object" && typeof (targetValue = target[property]) === "object") { + preciseAssignHelper(targetValue, sourceValue); + } else { + target[property] = sourceValue; + } + } + }); + } + +}
\ No newline at end of file diff --git a/src/server/DashUploadUtils.ts b/src/server/DashUploadUtils.ts index 27c4bf854..9ccc860f1 100644 --- a/src/server/DashUploadUtils.ts +++ b/src/server/DashUploadUtils.ts @@ -14,6 +14,7 @@ import { ParsedPDF } from "../server/PdfTypes"; const parse = require('pdf-parse'); import { Directory, serverPathToFile, clientPathToFile, pathToDirectory } from './ApiManagers/UploadManager'; import { red } from 'colors'; +import { Writable } from 'stream'; const requestImageSize = require("../client/util/request-image-size"); export enum SizeSuffix { @@ -60,13 +61,16 @@ export namespace DashUploadUtils { const type = "content-type"; export interface ImageUploadInformation { - clientAccessPath: string; - serverAccessPaths: { [key: string]: string }; + accessPaths: AccessPathInfo; exifData: EnrichedExifData; contentSize?: number; contentType?: string; } + export interface AccessPathInfo { + [suffix: string]: { client: string, server: string }; + } + const { imageFormats, videoFormats, applicationFormats } = AcceptibleMedia; export async function upload(file: File): Promise<any> { @@ -79,7 +83,7 @@ export namespace DashUploadUtils { switch (category) { case "image": if (imageFormats.includes(format)) { - const results = await UploadImage(path, basename(path), format); + const results = await UploadImage(path, basename(path)); return { ...results, name, type }; } case "video": @@ -93,7 +97,7 @@ export namespace DashUploadUtils { } console.log(red(`Ignoring unsupported file (${name}) with upload type (${type}).`)); - return { clientAccessPath: undefined }; + return { accessPaths: {} }; } async function UploadPdf(absolutePath: string) { @@ -108,8 +112,6 @@ export namespace DashUploadUtils { return MoveParsedFile(absolutePath, Directory.pdfs); } - const generate = (prefix: string, extension: string) => `${prefix}upload_${Utils.GenerateGuid()}.${extension}`; - /** * Uploads an image specified by the @param source to Dash's /public/files/ * directory, and returns information generated during that upload @@ -127,12 +129,12 @@ export namespace DashUploadUtils { * 3) the size of the image, in bytes (4432130) * 4) the content type of the image, i.e. image/(jpeg | png | ...) */ - export const UploadImage = async (source: string, filename?: string, format?: string, prefix: string = ""): Promise<ImageUploadInformation | Error> => { + export const UploadImage = async (source: string, filename?: string, prefix: string = ""): Promise<ImageUploadInformation | Error> => { const metadata = await InspectImage(source); if (metadata instanceof Error) { return metadata; } - return UploadInspectedImage(metadata, filename || metadata.filename, format, prefix); + return UploadInspectedImage(metadata, filename || metadata.filename, prefix); }; export interface InspectionResults { @@ -162,6 +164,11 @@ export namespace DashUploadUtils { type: string; } + export interface ImageResizer { + resizer?: sharp.Sharp; + suffix: SizeSuffix; + } + /** * Based on the url's classification as local or remote, gleans * as much information as possible about the specified image @@ -209,53 +216,47 @@ export namespace DashUploadUtils { }; }; - export async function MoveParsedFile(absolutePath: string, destination: Directory): Promise<{ clientAccessPath: Opt<string> }> { - return new Promise<{ clientAccessPath: Opt<string> }>(resolve => { + export async function MoveParsedFile(absolutePath: string, destination: Directory): Promise<Opt<{ accessPaths: AccessPathInfo }>> { + return new Promise(resolve => { const filename = basename(absolutePath); const destinationPath = serverPathToFile(destination, filename); rename(absolutePath, destinationPath, error => { - resolve({ clientAccessPath: error ? undefined : clientPathToFile(destination, filename) }); + resolve(error ? undefined : { + accessPaths: { + agnostic: getAccessPaths(destination, filename) + } + }); }); }); } - export const UploadInspectedImage = async (metadata: InspectionResults, filename?: string, format?: string, prefix = ""): Promise<ImageUploadInformation> => { + function getAccessPaths(directory: Directory, fileName: string) { + return { + client: clientPathToFile(directory, fileName), + server: serverPathToFile(directory, fileName) + }; + } + + export const UploadInspectedImage = async (metadata: InspectionResults, filename?: string, prefix = "", cleanUp = true): Promise<ImageUploadInformation> => { const { requestable, source, ...remaining } = metadata; - const extension = remaining.contentType.toLowerCase().split("/")[1]; //format || sanitizeExtension(requestable || resolved); - const resolved = filename || generate(prefix, extension); + const extension = `.${remaining.contentType.split("/")[1].toLowerCase()}`; + const resolved = filename || `${prefix}upload_${Utils.GenerateGuid()}${extension}`; + const { images } = Directory; const information: ImageUploadInformation = { - clientAccessPath: clientPathToFile(Directory.images, resolved), - serverAccessPaths: {}, + accessPaths: { + agnostic: getAccessPaths(images, resolved) + }, ...remaining }; - const { pngs, jpgs } = AcceptibleMedia; - return new Promise<ImageUploadInformation>(async (resolve, reject) => { - const resizers = [ - { resizer: sharp().rotate(), suffix: SizeSuffix.Original }, - ...Object.values(Sizes).map(size => ({ - resizer: sharp().resize(size.width, undefined, { withoutEnlargement: true }).rotate(), - suffix: size.suffix - })) - ]; - if (pngs.includes(extension)) { - resizers.forEach(element => element.resizer = element.resizer.png()); - } else if (jpgs.includes(extension)) { - resizers.forEach(element => element.resizer = element.resizer.jpeg()); - } - for (const { resizer, suffix } of resizers) { - await new Promise<void>(resolve => { - const filename = InjectSize(resolved, suffix); - information.serverAccessPaths[suffix] = serverPathToFile(Directory.images, filename); - request(requestable).pipe(resizer).pipe(createWriteStream(serverPathToFile(Directory.images, filename))) - .on('close', resolve) - .on('error', reject); - }); - } - if (isLocal().test(source)) { - unlinkSync(source); - } - resolve(information); - }); + const outputPath = pathToDirectory(Directory.images); + const writtenFiles = await outputResizedImages(() => request(requestable), outputPath, resolved, extension); + for (const suffix of Object.keys(writtenFiles)) { + information.accessPaths[suffix] = getAccessPaths(images, writtenFiles[suffix]); + } + if (isLocal().test(source) && cleanUp) { + unlinkSync(source); + } + return information; }; const parseExifData = async (source: string): Promise<EnrichedExifData> => { @@ -271,4 +272,59 @@ export namespace DashUploadUtils { }); }; + const { pngs, jpgs } = AcceptibleMedia; + const pngOptions = { + compressionLevel: 9, + adaptiveFiltering: true, + force: true + }; + + export interface ReadStreamLike { + pipe: (dest: Writable) => Writable; + } + + export async function outputResizedImages(readStreamSource: () => ReadStreamLike | Promise<ReadStreamLike>, outputPath: string, fileName: string, ext: string) { + const writtenFiles: { [suffix: string]: string } = {}; + for (const { resizer, suffix } of resizers(ext)) { + const resolved = writtenFiles[suffix] = InjectSize(fileName, suffix); + await new Promise<void>(async (resolve, reject) => { + const writeStream = createWriteStream(path.resolve(outputPath, resolved)); + let readStream: ReadStreamLike; + const source = readStreamSource(); + if (source instanceof Promise) { + readStream = await source; + } else { + readStream = source; + } + if (resizer) { + readStream = readStream.pipe(resizer.withMetadata()); + } + const out = readStream.pipe(writeStream); + out.on("close", resolve); + out.on("error", reject); + }); + } + return writtenFiles; + } + + function resizers(ext: string): DashUploadUtils.ImageResizer[] { + return [ + { suffix: SizeSuffix.Original }, + ...Object.values(DashUploadUtils.Sizes).map(size => { + let initial: sharp.Sharp | undefined = sharp().resize(size.width, undefined, { withoutEnlargement: true }); + if (pngs.includes(ext)) { + initial = initial.png(pngOptions); + } else if (jpgs.includes(ext)) { + initial = initial.jpeg(); + } else { + initial = undefined; + } + return { + resizer: initial, + suffix: size.suffix + }; + }) + ]; + } + }
\ No newline at end of file diff --git a/src/server/Message.ts b/src/server/Message.ts index 79b6fa1e0..2a03e2311 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -1,4 +1,5 @@ import { Utils } from "../Utils"; +import { AnalysisResult, ImportResults } from "../scraping/buxton/final/BuxtonImporter"; export class Message<T> { private _name: string; @@ -56,6 +57,9 @@ export namespace MessageStore { export const GetDocument = new Message<string>("Get Document"); export const DeleteAll = new Message<any>("Delete All"); export const ConnectionTerminated = new Message<string>("Connection Terminated"); + export const BeginBuxtonImport = new Message<string>("Begin Buxton Import"); + export const BuxtonDocumentResult = new Message<AnalysisResult>("Buxton Document Result"); + export const BuxtonImportComplete = new Message<ImportResults>("Buxton Import Complete"); export const GetRefField = new Message<string>("Get Ref Field"); export const GetRefFields = new Message<string[]>("Get Ref Fields"); @@ -65,5 +69,4 @@ export namespace MessageStore { export const DeleteField = new Message<string>("Delete field"); export const DeleteFields = new Message<string[]>("Delete fields"); - } diff --git a/src/server/Websocket/Websocket.ts b/src/server/Websocket/Websocket.ts index ba7ca8f35..6ee571395 100644 --- a/src/server/Websocket/Websocket.ts +++ b/src/server/Websocket/Websocket.ts @@ -11,7 +11,7 @@ import { logPort } from "../ActionUtilities"; import { timeMap } from "../ApiManagers/UserManager"; import { green } from "colors"; import { networkInterfaces, type } from "os"; -import { object } from "serializr"; +import executeImport from "../../scraping/buxton/final/BuxtonImporter"; export namespace WebSocket { @@ -106,6 +106,12 @@ export namespace WebSocket { Utils.AddServerHandler(socket, MessageStore.DeleteFields, ids => DeleteFields(socket, ids)); Utils.AddServerHandlerCallback(socket, MessageStore.GetRefField, GetRefField); Utils.AddServerHandlerCallback(socket, MessageStore.GetRefFields, GetRefFields); + Utils.AddServerHandler(socket, MessageStore.BeginBuxtonImport, () => { + executeImport( + deviceOrError => Utils.Emit(socket, MessageStore.BuxtonDocumentResult, deviceOrError), + results => Utils.Emit(socket, MessageStore.BuxtonImportComplete, results) + ); + }); disconnect = () => { socket.broadcast.emit("connection_terminated", Date.now()); diff --git a/src/server/apis/google/GooglePhotosUploadUtils.ts b/src/server/apis/google/GooglePhotosUploadUtils.ts index 8ae63caa3..d305eed0a 100644 --- a/src/server/apis/google/GooglePhotosUploadUtils.ts +++ b/src/server/apis/google/GooglePhotosUploadUtils.ts @@ -84,6 +84,7 @@ export namespace GooglePhotosUploadUtils { if (!DashUploadUtils.validateExtension(url)) { return undefined; } + const body = await request(url, { encoding: null }); // returns a readable stream with the unencoded binary image data const parameters = { method: 'POST', uri: prepend('uploads'), @@ -92,7 +93,7 @@ export namespace GooglePhotosUploadUtils { 'X-Goog-Upload-File-Name': filename || path.basename(url), 'X-Goog-Upload-Protocol': 'raw' }, - body: await request(url, { encoding: null }) // returns a readable stream with the unencoded binary image data + body }; return new Promise((resolve, reject) => request(parameters, (error, _response, body) => { if (error) { diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts index db20d10f2..ce4f94d83 100644 --- a/src/server/authentication/models/current_user_utils.ts +++ b/src/server/authentication/models/current_user_utils.ts @@ -48,7 +48,7 @@ export class CurrentUserUtils { // setup the "creator" buttons for the sidebar-- eg. the default set of draggable document creation tools static setupCreatorButtons(doc: Doc, buttons?: string[]) { const notes = CurrentUserUtils.setupNoteTypes(doc); - const emptyPresentation = Docs.Create.PresDocument(new List<Doc>(), { title: "Presentation", _viewType: CollectionViewType.Stacking, showTitle: "title", boxShadow: "0 0" }); + const emptyPresentation = Docs.Create.PresDocument(new List<Doc>(), { title: "Presentation", _viewType: CollectionViewType.Stacking, _LODdisable: true, _chromeStatus: "replaced", showTitle: "title", boxShadow: "0 0" }); const emptyCollection = Docs.Create.FreeformDocument([], { _nativeWidth: undefined, _nativeHeight: undefined, _LODdisable: true, _width: 150, _height: 100, title: "freeform" }); doc.noteTypes = Docs.Create.TreeDocument(notes, { title: "Note Types", _height: 75 }); doc.activePen = doc; @@ -58,8 +58,8 @@ export class CurrentUserUtils { { title: "todo item", icon: "check", ignoreClick: true, drag: 'getCopy(this.dragFactory, true)', dragFactory: notes[notes.length - 1] }, { title: "web page", icon: "globe-asia", ignoreClick: true, drag: 'Docs.Create.WebDocument("https://en.wikipedia.org/wiki/Hedgehog", {_width: 300, _height: 300, title: "New Webpage" })' }, { title: "cat image", icon: "cat", ignoreClick: true, drag: 'Docs.Create.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { _width: 200, title: "an image of a cat" })' }, + { title: "buxton", icon: "cloud-upload-alt", ignoreClick: true, drag: "Docs.Create.Buxton()" }, { title: "webcam", icon: "video", ignoreClick: true, drag: 'Docs.Create.WebCamDocument("", { width: 400, height: 400, title: "a test cam" })' }, - { title: "buxton", icon: "faObjectGroup", ignoreClick: true, drag: "Docs.Create.Buxton()" }, { title: "record", icon: "microphone", ignoreClick: true, drag: `Docs.Create.AudioDocument("${nullAudio}", { _width: 200, title: "ready to record audio" })` }, { title: "clickable button", icon: "bolt", ignoreClick: true, drag: 'Docs.Create.ButtonDocument({ _width: 150, _height: 50, title: "Button" })' }, { title: "presentation", icon: "tv", click: 'openOnRight(Doc.UserDoc().curPresentation = getCopy(this.dragFactory, true))', drag: `Doc.UserDoc().curPresentation = getCopy(this.dragFactory,true)`, dragFactory: emptyPresentation }, @@ -234,12 +234,12 @@ export class CurrentUserUtils { /// sets up the default list of buttons to be shown in the expanding button menu at the bottom of the Dash window static setupExpandingButtons(doc: Doc) { - const slideTemplate = Docs.Create.StackingDocument( + const slideTemplate = Docs.Create.MultirowDocument( [ - Docs.Create.MulticolumnDocument([], { title: "images", _height: 200, _xMargin: 10, _yMargin: 10 }), + Docs.Create.MulticolumnDocument([], { title: "images", _height: 200 }), Docs.Create.TextDocument("", { title: "contents", _height: 100 }) ], - { _width: 400, _height: 300, title: "slide", _chromeStatus: "disabled", backgroundColor: "lightGray", _autoHeight: true }); + { _width: 400, _height: 300, title: "slideView", _chromeStatus: "disabled", _xMargin: 3, _yMargin: 3, backgroundColor: "lightGray", _autoHeight: true }); slideTemplate.isTemplateDoc = makeTemplate(slideTemplate); const iconDoc = Docs.Create.TextDocument("", { title: "icon", _width: 150, _height: 30, isTemplateDoc: true, onClick: ScriptField.MakeScript("setNativeView(this)") }); @@ -270,7 +270,8 @@ export class CurrentUserUtils { // the initial presentation Doc to use static setupDefaultPresentation(doc: Doc) { - doc.curPresentation = Docs.Create.PresDocument(new List<Doc>(), { title: "Presentation", _viewType: CollectionViewType.Stacking, showTitle: "title", boxShadow: "0 0" }); + doc.presentationTemplate = new PrefetchProxy(Docs.Create.PresElementBoxDocument({ backgroundColor: "transparent", _xMargin: 5, _height: 46, isTemplateDoc: true, isTemplateForField: "data" })); + doc.curPresentation = Docs.Create.PresDocument(new List<Doc>(), { title: "Presentation", _viewType: CollectionViewType.Stacking, _LODdisable: true, _chromeStatus: "replaced", showTitle: "title", boxShadow: "0 0" }); } static setupMobileUploads(doc: Doc) { diff --git a/src/server/index.ts b/src/server/index.ts index 2101de1d2..88f5fa3bf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,7 +24,7 @@ import { Logger } from "./ProcessFactory"; import { yellow } from "colors"; import { DashSessionAgent } from "./DashSession/DashSessionAgent"; import SessionManager from "./ApiManagers/SessionManager"; -import { AppliedSessionAgent } from "resilient-server-session"; +import { AppliedSessionAgent } from "./DashSession/Session/agents/applied_session_agent"; export const onWindows = process.platform === "win32"; export let sessionAgent: AppliedSessionAgent; |