aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorTyler Schicke <tschicke@gmail.com>2020-01-09 21:33:18 -0800
committerTyler Schicke <tschicke@gmail.com>2020-01-09 21:33:18 -0800
commit2295ab2500487da6b030d871b8d81724a4ddada3 (patch)
treecc4bff122cd18066521affb382c0e93cde9b33ce /src
parent786d25a4f8db1db8795f04a17fba392636e5f891 (diff)
parenta8aa0facfaa23298398c15aa906bc6d69c538564 (diff)
Merge branch 'master' of github.com:browngraphicslab/Dash-Web into no_db
Diffstat (limited to 'src')
-rw-r--r--src/Utils.ts1
-rw-r--r--src/client/DocServer.ts20
-rw-r--r--src/client/cognitive_services/CognitiveServices.ts9
-rw-r--r--src/client/documents/Documents.ts2
-rw-r--r--src/client/util/InteractionUtils.ts40
-rw-r--r--src/client/util/ProseMirrorEditorView.tsx74
-rw-r--r--src/client/util/RichTextMenu.scss121
-rw-r--r--src/client/util/RichTextMenu.tsx854
-rw-r--r--src/client/util/SelectionManager.ts2
-rw-r--r--src/client/util/TooltipTextMenu.tsx16
-rw-r--r--src/client/views/AntimodeMenu.scss15
-rw-r--r--src/client/views/AntimodeMenu.tsx34
-rw-r--r--src/client/views/DocumentDecorations.tsx4
-rw-r--r--src/client/views/InkingStroke.tsx10
-rw-r--r--src/client/views/MainView.tsx4
-rw-r--r--src/client/views/Touchable.tsx74
-rw-r--r--src/client/views/collections/CollectionDockingView.tsx23
-rw-r--r--src/client/views/collections/ParentDocumentSelector.tsx10
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx12
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx142
-rw-r--r--src/client/views/nodes/DocumentView.tsx201
-rw-r--r--src/client/views/nodes/FormattedTextBox.scss1
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx39
-rw-r--r--src/client/views/pdf/PDFMenu.tsx2
-rw-r--r--src/client/views/search/SearchBox.scss3
-rw-r--r--src/client/views/search/SearchBox.tsx3
-rw-r--r--src/client/views/search/SearchItem.scss107
-rw-r--r--src/client/views/search/SearchItem.tsx87
-rw-r--r--src/new_fields/InkField.ts7
-rw-r--r--src/pen-gestures/GestureUtils.ts28
-rw-r--r--src/pen-gestures/ndollar.ts533
-rw-r--r--src/server/ActionUtilities.ts6
-rw-r--r--src/server/ApiManagers/SearchManager.ts26
-rw-r--r--src/server/ApiManagers/SessionManager.ts62
-rw-r--r--src/server/DashSession.ts174
-rw-r--r--src/server/RouteManager.ts3
-rw-r--r--src/server/Search.ts8
-rw-r--r--src/server/Session/session.ts867
-rw-r--r--src/server/Session/session_config_schema.ts72
-rw-r--r--src/server/index.ts20
-rw-r--r--src/server/remote_debug_instructions.txt16
-rw-r--r--src/server/repl.ts13
42 files changed, 3050 insertions, 695 deletions
diff --git a/src/Utils.ts b/src/Utils.ts
index 2b15ad0f2..04fe6750b 100644
--- a/src/Utils.ts
+++ b/src/Utils.ts
@@ -4,7 +4,6 @@ import { Socket } from 'socket.io';
import { Message } from './server/Message';
export namespace Utils {
-
export const DRAG_THRESHOLD = 4;
export function GenerateGuid(): string {
diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts
index dbe8e58b9..d793b56af 100644
--- a/src/client/DocServer.ts
+++ b/src/client/DocServer.ts
@@ -64,6 +64,24 @@ export namespace DocServer {
}
}
+ const instructions = "This page will automatically refresh after this alert is closed. Expect to reconnect after about 30 seconds.";
+ function alertUser(connectionTerminationReason: string) {
+ switch (connectionTerminationReason) {
+ case "crash":
+ alert(`Dash has temporarily crashed. Administrators have been notified and the server is restarting itself. ${instructions}`);
+ break;
+ case "temporary":
+ alert(`An administrator has chosen to restart the server. ${instructions}`);
+ break;
+ case "exit":
+ alert("An administrator has chosen to kill the server. Do not expect to reconnect until administrators start the server.");
+ break;
+ default:
+ console.log(`Received an unknown ConnectionTerminated message: ${connectionTerminationReason}`);
+ }
+ window.location.reload();
+ }
+
export function init(protocol: string, hostname: string, port: number, identifier: string) {
_cache = {};
GUID = identifier;
@@ -82,7 +100,7 @@ export namespace DocServer {
Utils.AddServerHandler(_socket, MessageStore.UpdateField, respondToUpdate);
Utils.AddServerHandler(_socket, MessageStore.DeleteField, respondToDelete);
Utils.AddServerHandler(_socket, MessageStore.DeleteFields, respondToDelete);
- Utils.AddServerHandler(_socket, MessageStore.ConnectionTerminated, () => alert("Your connection to the server has been terminated."));
+ Utils.AddServerHandler(_socket, MessageStore.ConnectionTerminated, alertUser);
}
function errorFunc(): never {
diff --git a/src/client/cognitive_services/CognitiveServices.ts b/src/client/cognitive_services/CognitiveServices.ts
index 02eff3b25..57296c961 100644
--- a/src/client/cognitive_services/CognitiveServices.ts
+++ b/src/client/cognitive_services/CognitiveServices.ts
@@ -137,7 +137,7 @@ export namespace CognitiveServices {
let id = 0;
const strokes: AzureStrokeData[] = inkData.map(points => ({
id: id++,
- points: points.map(({ x, y }) => `${x},${y}`).join(","),
+ points: points.map(({ X: x, Y: y }) => `${x},${y}`).join(","),
language: "en-US"
}));
return JSON.stringify({
@@ -153,7 +153,7 @@ export namespace CognitiveServices {
const serverAddress = "https://api.cognitive.microsoft.com";
const endpoint = serverAddress + "/inkrecognizer/v1.0-preview/recognize";
- const promisified = (resolve: any, reject: any) => {
+ return new Promise<string>((resolve, reject) => {
xhttp.onreadystatechange = function () {
if (this.readyState === 4) {
const result = xhttp.responseText;
@@ -171,11 +171,8 @@ export namespace CognitiveServices {
xhttp.setRequestHeader('Ocp-Apim-Subscription-Key', apiKey);
xhttp.setRequestHeader('Content-Type', 'application/json');
xhttp.send(body);
- };
-
- return new Promise<any>(promisified);
+ });
},
-
};
export namespace Appliers {
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 304eccc02..e149963b9 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -429,7 +429,7 @@ export namespace Docs {
return InstanceFromProto(Prototypes.get(DocumentType.TEXT), "", options);
}
- export function InkDocument(color: string, tool: number, strokeWidth: number, points: { x: number, y: number }[], options: DocumentOptions = {}) {
+ export function InkDocument(color: string, tool: number, strokeWidth: number, points: { X: number, Y: number }[], options: DocumentOptions = {}) {
const doc = InstanceFromProto(Prototypes.get(DocumentType.INK), new InkField(points), options);
doc.color = color;
doc.strokeWidth = strokeWidth;
diff --git a/src/client/util/InteractionUtils.ts b/src/client/util/InteractionUtils.ts
index 0c3de66ed..2e4e8c7ca 100644
--- a/src/client/util/InteractionUtils.ts
+++ b/src/client/util/InteractionUtils.ts
@@ -8,6 +8,17 @@ export namespace InteractionUtils {
const REACT_POINTER_PEN_BUTTON = 0;
const ERASER_BUTTON = 5;
+ export function GetMyTargetTouches(e: TouchEvent | React.TouchEvent, prevPoints: Map<number, React.Touch>): React.Touch[] {
+ const myTouches = new Array<React.Touch>();
+ for (let i = 0; i < e.targetTouches.length; i++) {
+ const pt = e.targetTouches.item(i);
+ if (pt && prevPoints.has(pt.identifier)) {
+ myTouches.push(pt);
+ }
+ }
+ return myTouches;
+ }
+
export function IsType(e: PointerEvent | React.PointerEvent, type: string): boolean {
switch (type) {
// pen and eraser are both pointer type 'pen', but pen is button 0 and eraser is button 5. -syip2
@@ -79,6 +90,20 @@ export namespace InteractionUtils {
return 0;
}
+ export function IsDragging(oldTouches: Map<number, React.Touch>, newTouches: React.Touch[], leniency: number): boolean {
+ for (const touch of newTouches) {
+ if (touch) {
+ const oldTouch = oldTouches.get(touch.identifier);
+ if (oldTouch) {
+ if (TwoPointEuclidist(touch, oldTouch) >= leniency) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
// These might not be very useful anymore, but I'll leave them here for now -syip2
{
@@ -134,20 +159,5 @@ export namespace InteractionUtils {
// return { type: undefined };
// }
// }
-
- // export function IsDragging(oldTouches: Map<number, React.Touch>, newTouches: TouchList, leniency: number): boolean {
- // for (let i = 0; i < newTouches.length; i++) {
- // let touch = newTouches.item(i);
- // if (touch) {
- // let oldTouch = oldTouches.get(touch.identifier);
- // if (oldTouch) {
- // if (TwoPointEuclidist(touch, oldTouch) >= leniency) {
- // return true;
- // }
- // }
- // }
- // }
- // return false;
- // }
}
} \ No newline at end of file
diff --git a/src/client/util/ProseMirrorEditorView.tsx b/src/client/util/ProseMirrorEditorView.tsx
new file mode 100644
index 000000000..3e5fd0604
--- /dev/null
+++ b/src/client/util/ProseMirrorEditorView.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { EditorView } from "prosemirror-view";
+import { EditorState } from "prosemirror-state";
+
+export interface ProseMirrorEditorViewProps {
+ /* EditorState instance to use. */
+ editorState: EditorState;
+ /* Called when EditorView produces new EditorState. */
+ onEditorState: (editorState: EditorState) => any;
+}
+
+/**
+ * This wraps ProseMirror's EditorView into React component.
+ * This code was found on https://discuss.prosemirror.net/t/using-with-react/904
+ */
+export class ProseMirrorEditorView extends React.Component<ProseMirrorEditorViewProps> {
+
+ private _editorView?: EditorView;
+
+ _createEditorView = (element: HTMLDivElement | null) => {
+ if (element != null) {
+ this._editorView = new EditorView(element, {
+ state: this.props.editorState,
+ dispatchTransaction: this.dispatchTransaction,
+ });
+ }
+ };
+
+ dispatchTransaction = (tx: any) => {
+ // In case EditorView makes any modification to a state we funnel those
+ // modifications up to the parent and apply to the EditorView itself.
+ const editorState = this.props.editorState.apply(tx);
+ if (this._editorView != null) {
+ this._editorView.updateState(editorState);
+ }
+ this.props.onEditorState(editorState);
+ };
+
+ focus() {
+ if (this._editorView) {
+ this._editorView.focus();
+ }
+ }
+
+ componentWillReceiveProps(nextProps: { editorState: EditorState<any>; }) {
+ // In case we receive new EditorState through props — we apply it to the
+ // EditorView instance.
+ if (this._editorView) {
+ if (nextProps.editorState !== this.props.editorState) {
+ this._editorView.updateState(nextProps.editorState);
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._editorView) {
+ this._editorView.destroy();
+ }
+ }
+
+ shouldComponentUpdate() {
+ // Note that EditorView manages its DOM itself so we'd ratrher don't mess
+ // with it.
+ return false;
+ }
+
+ render() {
+ // Render just an empty div which is then used as a container for an
+ // EditorView instance.
+ return (
+ <div ref={this._createEditorView} />
+ );
+ }
+} \ No newline at end of file
diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss
new file mode 100644
index 000000000..43cc23ecd
--- /dev/null
+++ b/src/client/util/RichTextMenu.scss
@@ -0,0 +1,121 @@
+@import "../views/globalCssVariables";
+
+.button-dropdown-wrapper {
+ position: relative;
+
+ .dropdown-button {
+ width: 15px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ .dropdown-button-combined {
+ width: 50px;
+ display: flex;
+ justify-content: space-between;
+
+ svg:nth-child(2) {
+ margin-top: 2px;
+ }
+ }
+
+ .dropdown {
+ position: absolute;
+ top: 35px;
+ left: 0;
+ background-color: #323232;
+ color: $light-color-secondary;
+ border: 1px solid #4d4d4d;
+ border-radius: 0 6px 6px 6px;
+ box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25);
+ min-width: 150px;
+ padding: 5px;
+ font-size: 12px;
+ z-index: 10001;
+
+ button {
+ background-color: #323232;
+ border: 1px solid black;
+ border-radius: 1px;
+ padding: 6px;
+ margin: 5px 0;
+ font-size: 10px;
+
+ &:hover {
+ background-color: black;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ input {
+ color: black;
+ }
+}
+
+.link-menu {
+ .divider {
+ background-color: white;
+ height: 1px;
+ width: 100%;
+ }
+}
+
+.color-preview-button {
+ .color-preview {
+ width: 100%;
+ height: 3px;
+ margin-top: 3px;
+ }
+}
+
+.color-wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+
+ button.color-button {
+ width: 20px;
+ height: 20px;
+ border-radius: 15px !important;
+ margin: 3px;
+ border: 2px solid transparent !important;
+ padding: 3px;
+
+ &.active {
+ border: 2px solid white !important;
+ }
+ }
+}
+
+select {
+ background-color: #323232;
+ color: white;
+ border: 1px solid black;
+ // border-top: none;
+ // border-bottom: none;
+ font-size: 12px;
+ height: 100%;
+ margin-right: 3px;
+
+ &:focus,
+ &:hover {
+ background-color: black;
+ }
+
+ &::-ms-expand {
+ color: white;
+ }
+}
+
+.row-2 {
+ display: flex;
+ justify-content: space-between;
+
+ >div {
+ display: flex;
+ }
+} \ No newline at end of file
diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx
new file mode 100644
index 000000000..639772faa
--- /dev/null
+++ b/src/client/util/RichTextMenu.tsx
@@ -0,0 +1,854 @@
+import React = require("react");
+import AntimodeMenu from "../views/AntimodeMenu";
+import { observable, action, } from "mobx";
+import { observer } from "mobx-react";
+import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model";
+import { schema } from "./RichTextSchema";
+import { EditorView } from "prosemirror-view";
+import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { IconProp, library } from '@fortawesome/fontawesome-svg-core';
+import { faBold, faItalic, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller, faSleigh } from "@fortawesome/free-solid-svg-icons";
+import { MenuItem, Dropdown } from "prosemirror-menu";
+import { updateBullets } from "./ProsemirrorExampleTransfer";
+import { FieldViewProps } from "../views/nodes/FieldView";
+import { NumCast, Cast, StrCast } from "../../new_fields/Types";
+import { FormattedTextBoxProps } from "../views/nodes/FormattedTextBox";
+import { unimplementedFunction, Utils } from "../../Utils";
+import { wrapInList } from "prosemirror-schema-list";
+import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../new_fields/SchemaHeaderField';
+import "./RichTextMenu.scss";
+import { DocServer } from "../DocServer";
+import { Doc } from "../../new_fields/Doc";
+import { SelectionManager } from "./SelectionManager";
+import { LinkManager } from "./LinkManager";
+const { toggleMark, setBlockType } = require("prosemirror-commands");
+
+library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller);
+
+@observer
+export default class RichTextMenu extends AntimodeMenu {
+ static Instance: RichTextMenu;
+ public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable
+
+ private view?: EditorView;
+ private editorProps: FieldViewProps & FormattedTextBoxProps | undefined;
+
+ private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[];
+ private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[];
+ private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any, style?: {} }[];
+ private fontColors: (string | undefined)[];
+ private highlightColors: (string | undefined)[];
+
+ @observable private boldActive: boolean = false;
+ @observable private italicsActive: boolean = false;
+ @observable private underlineActive: boolean = false;
+ @observable private strikethroughActive: boolean = false;
+ @observable private subscriptActive: boolean = false;
+ @observable private superscriptActive: boolean = false;
+
+ @observable private activeFontSize: string = "";
+ @observable private activeFontFamily: string = "";
+ @observable private activeListType: string = "";
+
+ @observable private brushIsEmpty: boolean = true;
+ @observable private brushMarks: Set<Mark> = new Set();
+ @observable private showBrushDropdown: boolean = false;
+
+ @observable private activeFontColor: string = "black";
+ @observable private showColorDropdown: boolean = false;
+
+ @observable private activeHighlightColor: string = "transparent";
+ @observable private showHighlightDropdown: boolean = false;
+
+ @observable private currentLink: string | undefined = "";
+ @observable private showLinkDropdown: boolean = false;
+
+ constructor(props: Readonly<{}>) {
+ super(props);
+ RichTextMenu.Instance = this;
+ this._canFade = false;
+
+ this.fontSizeOptions = [
+ { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "8pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize },
+ { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize },
+ { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
+ { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option
+ ];
+
+ this.fontFamilyOptions = [
+ { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily, style: { fontFamily: "Times New Roman" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily, style: { fontFamily: "Arial" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily, style: { fontFamily: "Georgia" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily, style: { fontFamily: "Comic Sans MS" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily, style: { fontFamily: "Tahoma" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily, style: { fontFamily: "Impact" } },
+ { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily, style: { fontFamily: "Crimson Text" } },
+ { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true },
+ // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true },
+ ];
+
+ this.listTypeOptions = [
+ { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType },
+ { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType },
+ { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType },
+ { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType },
+ ];
+
+ this.fontColors = [
+ DarkPastelSchemaPalette.get("pink2"),
+ DarkPastelSchemaPalette.get("purple4"),
+ DarkPastelSchemaPalette.get("bluegreen1"),
+ DarkPastelSchemaPalette.get("yellow4"),
+ DarkPastelSchemaPalette.get("red2"),
+ DarkPastelSchemaPalette.get("bluegreen7"),
+ DarkPastelSchemaPalette.get("bluegreen5"),
+ DarkPastelSchemaPalette.get("orange1"),
+ "#757472",
+ "#000"
+ ];
+
+ this.highlightColors = [
+ PastelSchemaPalette.get("pink2"),
+ PastelSchemaPalette.get("purple4"),
+ PastelSchemaPalette.get("bluegreen1"),
+ PastelSchemaPalette.get("yellow4"),
+ PastelSchemaPalette.get("red2"),
+ PastelSchemaPalette.get("bluegreen7"),
+ PastelSchemaPalette.get("bluegreen5"),
+ PastelSchemaPalette.get("orange1"),
+ "white",
+ "transparent"
+ ];
+ }
+
+ @action
+ changeView(view: EditorView) {
+ this.view = view;
+ }
+
+ update(view: EditorView, lastState: EditorState | undefined) {
+ this.updateFromDash(view, lastState, this.editorProps);
+ }
+
+ @action
+ public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) {
+ if (!view) {
+ console.log("no editor? why?");
+ return;
+ }
+ this.view = view;
+ const state = view.state;
+ props && (this.editorProps = props);
+
+ // Don't do anything if the document/selection didn't change
+ if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return;
+
+ // update active marks
+ const activeMarks = this.getActiveMarksOnSelection();
+ this.setActiveMarkButtons(activeMarks);
+
+ // update active font family and size
+ const active = this.getActiveFontStylesOnSelection();
+ const activeFamilies = active && active.get("families");
+ const activeSizes = active && active.get("sizes");
+
+ this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various";
+ this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various";
+
+ // update link in current selection
+ const targetTitle = await this.getTextLinkTargetTitle();
+ this.setCurrentLink(targetTitle);
+ }
+
+ setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => {
+ if (mark) {
+ const node = (state.selection as NodeSelection).node;
+ if (node ?.type === schema.nodes.ordered_list) {
+ let attrs = node.attrs;
+ if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family };
+ if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize };
+ if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color };
+ const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema);
+ dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from))));
+ } else {
+ toggleMark(mark.type, mark.attrs)(state, (tx: any) => {
+ const { from, $from, to, empty } = tx.selection;
+ if (!tx.doc.rangeHasMark(from, to, mark.type)) {
+ toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch);
+ } else dispatch(tx);
+ });
+ }
+ }
+ }
+
+ // finds font sizes and families in selection
+ getActiveFontStylesOnSelection() {
+ if (!this.view) return;
+
+ const activeFamilies: string[] = [];
+ const activeSizes: string[] = [];
+ const state = this.view.state;
+ const pos = this.view.state.selection.$from;
+ const ref_node = this.reference_node(pos);
+ if (ref_node && ref_node !== this.view.state.doc && ref_node.isText) {
+ ref_node.marks.forEach(m => {
+ m.type === state.schema.marks.pFontFamily && activeFamilies.push(m.attrs.family);
+ m.type === state.schema.marks.pFontSize && activeSizes.push(String(m.attrs.fontSize) + "pt");
+ });
+ }
+
+ const styles = new Map<String, String[]>();
+ styles.set("families", activeFamilies);
+ styles.set("sizes", activeSizes);
+ return styles;
+ }
+
+ getMarksInSelection(state: EditorState<any>) {
+ const found = new Set<Mark>();
+ const { from, to } = state.selection as TextSelection;
+ state.doc.nodesBetween(from, to, (node) => node.marks.forEach(m => found.add(m)));
+ return found;
+ }
+
+ //finds all active marks on selection in given group
+ getActiveMarksOnSelection() {
+ if (!this.view) return;
+
+ const markGroup = [schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript];
+ if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type);
+ //current selection
+ const { empty, ranges, $to } = this.view.state.selection as TextSelection;
+ const state = this.view.state;
+ let activeMarks: MarkType[] = [];
+ if (!empty) {
+ activeMarks = markGroup.filter(mark => {
+ const has = false;
+ for (let i = 0; !has && i < ranges.length; i++) {
+ return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark);
+ }
+ return false;
+ });
+ }
+ else {
+ const pos = this.view.state.selection.$from;
+ const ref_node: ProsNode | null = this.reference_node(pos);
+ if (ref_node !== null && ref_node !== this.view.state.doc) {
+ if (ref_node.isText) {
+ }
+ else {
+ return [];
+ }
+ activeMarks = markGroup.filter(mark_type => {
+ if (mark_type === state.schema.marks.pFontSize) {
+ return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name);
+ }
+ const mark = state.schema.mark(mark_type);
+ return ref_node.marks.includes(mark);
+ });
+ }
+ }
+ return activeMarks;
+ }
+
+ destroy() {
+ }
+
+ @action
+ setActiveMarkButtons(activeMarks: MarkType[] | undefined) {
+ if (!activeMarks) return;
+
+ this.boldActive = false;
+ this.italicsActive = false;
+ this.underlineActive = false;
+ this.strikethroughActive = false;
+ this.subscriptActive = false;
+ this.superscriptActive = false;
+
+ activeMarks.forEach(mark => {
+ switch (mark.name) {
+ case "strong": this.boldActive = true; break;
+ case "em": this.italicsActive = true; break;
+ case "underline": this.underlineActive = true; break;
+ case "strikethrough": this.strikethroughActive = true; break;
+ case "subscript": this.subscriptActive = true; break;
+ case "superscript": this.superscriptActive = true; break;
+ }
+ });
+ }
+
+ createButton(faIcon: string, title: string, isActive: boolean = false, command?: any, onclick?: any) {
+ const self = this;
+ function onClick(e: React.PointerEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.view && command && command(self.view!.state, self.view!.dispatch, self.view);
+ self.view && onclick && onclick(self.view!.state, self.view!.dispatch, self.view);
+ self.setActiveMarkButtons(self.getActiveMarksOnSelection());
+ }
+
+ return (
+ <button className={"antimodeMenu-button" + (isActive ? " active" : "")} title={title} onPointerDown={onClick}>
+ <FontAwesomeIcon icon={faIcon as IconProp} size="lg" />
+ </button>
+ );
+ }
+
+ createMarksDropdown(activeOption: string, options: { mark: Mark | null, title: string, label: string, command: (mark: Mark, view: EditorView) => void, hidden?: boolean, style?: {} }[]): JSX.Element {
+ const items = options.map(({ title, label, hidden, style }) => {
+ if (hidden) {
+ return label === activeOption ?
+ <option value={label} title={title} style={style ? style : {}} selected hidden>{label}</option> :
+ <option value={label} title={title} style={style ? style : {}} hidden>{label}</option>;
+ }
+ return label === activeOption ?
+ <option value={label} title={title} style={style ? style : {}} selected>{label}</option> :
+ <option value={label} title={title} style={style ? style : {}}>{label}</option>;
+ });
+
+ const self = this;
+ function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
+ e.stopPropagation();
+ e.preventDefault();
+ options.forEach(({ label, mark, command }) => {
+ if (e.target.value === label) {
+ self.view && mark && command(mark, self.view);
+ }
+ });
+ }
+ return <select onChange={onChange}>{items}</select>;
+ }
+
+ createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean, style?: {} }[]): JSX.Element {
+ const items = options.map(({ title, label, hidden, style }) => {
+ if (hidden) {
+ return label === activeOption ?
+ <option value={label} title={title} style={style ? style : {}} selected hidden>{label}</option> :
+ <option value={label} title={title} style={style ? style : {}} hidden>{label}</option>;
+ }
+ return label === activeOption ?
+ <option value={label} title={title} style={style ? style : {}} selected>{label}</option> :
+ <option value={label} title={title} style={style ? style : {}}>{label}</option>;
+ });
+
+ const self = this;
+ function onChange(val: string) {
+ options.forEach(({ label, node, command }) => {
+ if (val === label) {
+ self.view && node && command(node);
+ }
+ });
+ }
+ return <select onChange={e => onChange(e.target.value)}>{items}</select>;
+ }
+
+ changeFontSize = (mark: Mark, view: EditorView) => {
+ const size = mark.attrs.fontSize;
+ if (this.editorProps) {
+ const ruleProvider = this.editorProps.ruleProvider;
+ const heading = NumCast(this.editorProps.Document.heading);
+ if (ruleProvider && heading) {
+ ruleProvider["ruleSize_" + heading] = size;
+ }
+ }
+ this.setMark(view.state.schema.marks.pFontSize.create({ fontSize: size }), view.state, view.dispatch);
+ }
+
+ changeFontFamily = (mark: Mark, view: EditorView) => {
+ const fontName = mark.attrs.family;
+ if (this.editorProps) {
+ const ruleProvider = this.editorProps.ruleProvider;
+ const heading = NumCast(this.editorProps.Document.heading);
+ if (ruleProvider && heading) {
+ ruleProvider["ruleFont_" + heading] = fontName;
+ }
+ }
+ this.setMark(view.state.schema.marks.pFontFamily.create({ family: fontName }), view.state, view.dispatch);
+ }
+
+ // TODO: remove doesn't work
+ //remove all node type and apply the passed-in one to the selected text
+ changeListType = (nodeType: NodeType | undefined) => {
+ if (!this.view) return;
+
+ if (nodeType === schema.nodes.bullet_list) {
+ wrapInList(nodeType)(this.view.state, this.view.dispatch);
+ } else {
+ const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks());
+ if (!wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => {
+ const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle);
+ marks && tx3.ensureMarks([...marks]);
+ marks && tx3.setStoredMarks([...marks]);
+
+ this.view!.dispatch(tx2);
+ })) {
+ const tx2 = this.view.state.tr;
+ const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle);
+ marks && tx3.ensureMarks([...marks]);
+ marks && tx3.setStoredMarks([...marks]);
+
+ this.view.dispatch(tx3);
+ }
+ }
+ }
+
+ insertSummarizer(state: EditorState<any>, dispatch: any) {
+ if (state.selection.empty) return false;
+ const mark = state.schema.marks.summarize.create();
+ const tr = state.tr;
+ tr.addMark(state.selection.from, state.selection.to, mark);
+ const content = tr.selection.content();
+ const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() });
+ dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark));
+ return true;
+ }
+
+ @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; }
+
+ createBrushButton() {
+ const self = this;
+ function onBrushClick(e: React.PointerEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.view && self.fillBrush(self.view.state, self.view.dispatch);
+ }
+
+ let label = "Stored marks: ";
+ if (this.brushMarks && this.brushMarks.size > 0) {
+ this.brushMarks.forEach((mark: Mark) => {
+ const markType = mark.type;
+ label += markType.name;
+ label += ", ";
+ });
+ label = label.substring(0, label.length - 2);
+ } else {
+ label = "No marks are currently stored";
+ }
+
+ const button =
+ <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}>
+ <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} />
+ </button>;
+
+ const dropdownContent =
+ <div className="dropdown">
+ <p>{label}</p>
+ <button onPointerDown={this.clearBrush}>Clear brush</button>
+ {/* <input placeholder="Enter URL"></input> */}
+ </div>;
+
+ return (
+ <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
+ );
+ }
+
+ @action
+ clearBrush() {
+ RichTextMenu.Instance.brushIsEmpty = true;
+ RichTextMenu.Instance.brushMarks = new Set();
+ }
+
+ @action
+ fillBrush(state: EditorState<any>, dispatch: any) {
+ if (!this.view) return;
+
+ if (this.brushIsEmpty) {
+ const selected_marks = this.getMarksInSelection(this.view.state);
+ if (selected_marks.size >= 0) {
+ this.brushMarks = selected_marks;
+ this.brushIsEmpty = !this.brushIsEmpty;
+ }
+ }
+ else {
+ const { from, to, $from } = this.view.state.selection;
+ if (!this.view.state.selection.empty && $from && $from.nodeAfter) {
+ if (this.brushMarks && to - from > 0) {
+ this.view.dispatch(this.view.state.tr.removeMark(from, to));
+ Array.from(this.brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => {
+ this.setMark(mark, this.view!.state, this.view!.dispatch);
+ });
+ }
+ }
+ else {
+ this.brushIsEmpty = !this.brushIsEmpty;
+ }
+ }
+ }
+
+ @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; }
+ @action setActiveColor(color: string) { this.activeFontColor = color; }
+
+ createColorButton() {
+ const self = this;
+ function onColorClick(e: React.PointerEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch);
+ }
+ function changeColor(e: React.PointerEvent, color: string) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.setActiveColor(color);
+ self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch);
+ }
+
+ const button =
+ <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}>
+ <FontAwesomeIcon icon="palette" size="lg" />
+ <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div>
+ </button>;
+
+ const dropdownContent =
+ <div className="dropdown">
+ <p>Change font color:</p>
+ <div className="color-wrapper">
+ {this.fontColors.map(color => {
+ if (color) {
+ return this.activeFontColor === color ?
+ <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> :
+ <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>;
+ }
+ })}
+ </div>
+ </div>;
+
+ return (
+ <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
+ );
+ }
+
+ public insertColor(color: String, state: EditorState<any>, dispatch: any) {
+ const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color });
+ if (state.selection.empty) {
+ dispatch(state.tr.addStoredMark(colorMark));
+ return false;
+ }
+ this.setMark(colorMark, state, dispatch);
+ }
+
+ @action toggleHighlightDropdown() { this.showHighlightDropdown = !this.showHighlightDropdown; }
+ @action setActiveHighlight(color: string) { this.activeHighlightColor = color; }
+
+ createHighlighterButton() {
+ const self = this;
+ function onHighlightClick(e: React.PointerEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch);
+ }
+ function changeHighlight(e: React.PointerEvent, color: string) {
+ e.preventDefault();
+ e.stopPropagation();
+ self.view && self.view.focus();
+ self.setActiveHighlight(color);
+ self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch);
+ }
+
+ const button =
+ <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onHighlightClick}>
+ <FontAwesomeIcon icon="highlighter" size="lg" />
+ <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div>
+ </button>;
+
+ const dropdownContent =
+ <div className="dropdown">
+ <p>Change highlight color:</p>
+ <div className="color-wrapper">
+ {this.highlightColors.map(color => {
+ if (color) {
+ return this.activeHighlightColor === color ?
+ <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> :
+ <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>;
+ }
+ })}
+ </div>
+ </div>;
+
+ return (
+ <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} />
+ );
+ }
+
+ insertHighlight(color: String, state: EditorState<any>, dispatch: any) {
+ if (state.selection.empty) return false;
+ toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch);
+ }
+
+ @action toggleLinkDropdown() { this.showLinkDropdown = !this.showLinkDropdown; }
+ @action setCurrentLink(link: string) { this.currentLink = link; }
+
+ createLinkButton() {
+ const self = this;
+
+ function onLinkChange(e: React.ChangeEvent<HTMLInputElement>) {
+ self.setCurrentLink(e.target.value);
+ }
+
+ const link = this.currentLink ? this.currentLink : "";
+
+ const button = <FontAwesomeIcon icon="link" size="lg" />
+
+ const dropdownContent =
+ <div className="dropdown link-menu">
+ <p>Linked to:</p>
+ <input value={link} placeholder="Enter URL" onChange={onLinkChange} />
+ <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button>
+ <div className="divider"></div>
+ <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button>
+ </div>;
+
+ return (
+ <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} openDropdownOnButton={true} />
+ );
+ }
+
+ async getTextLinkTargetTitle() {
+ if (!this.view) return;
+
+ const node = this.view.state.selection.$from.nodeAfter;
+ const link = node && node.marks.find(m => m.type.name === "link");
+ if (link) {
+ const href = link.attrs.href;
+ if (href) {
+ if (href.indexOf(Utils.prepend("/doc/")) === 0) {
+ const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0];
+ if (linkclicked) {
+ const linkDoc = await DocServer.GetRefField(linkclicked);
+ if (linkDoc instanceof Doc) {
+ const anchor1 = await Cast(linkDoc.anchor1, Doc);
+ const anchor2 = await Cast(linkDoc.anchor2, Doc);
+ const currentDoc = SelectionManager.SelectedDocuments().length && SelectionManager.SelectedDocuments()[0].props.Document;
+ if (currentDoc && anchor1 && anchor2) {
+ if (Doc.AreProtosEqual(currentDoc, anchor1)) {
+ return StrCast(anchor2.title);
+ }
+ if (Doc.AreProtosEqual(currentDoc, anchor2)) {
+ return StrCast(anchor1.title);
+ }
+ }
+ }
+ }
+ } else {
+ return href;
+ }
+ } else {
+ return link.attrs.title;
+ }
+ }
+ }
+
+ // TODO: should check for valid URL
+ makeLinkToURL = (target: String, lcoation: string) => {
+ if (!this.view) return;
+
+ let node = this.view.state.selection.$from.nodeAfter;
+ let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location });
+ this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link));
+ this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link));
+ node = this.view.state.selection.$from.nodeAfter;
+ link = node && node.marks.find(m => m.type.name === "link");
+ }
+
+ deleteLink = () => {
+ if (!this.view) return;
+
+ const node = this.view.state.selection.$from.nodeAfter;
+ const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link);
+ const href = link!.attrs.href;
+ if (href) {
+ if (href.indexOf(Utils.prepend("/doc/")) === 0) {
+ const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0];
+ if (linkclicked) {
+ DocServer.GetRefField(linkclicked).then(async linkDoc => {
+ if (linkDoc instanceof Doc) {
+ LinkManager.Instance.deleteLink(linkDoc);
+ this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link));
+ }
+ });
+ }
+ } else {
+ if (node) {
+ const extension = this.linkExtend(this.view!.state.selection.$anchor, href);
+ this.view!.dispatch(this.view!.state.tr.removeMark(extension.from, extension.to, this.view!.state.schema.marks.link));
+ }
+ }
+ }
+ }
+
+ linkExtend($start: ResolvedPos, href: string) {
+ const mark = this.view!.state.schema.marks.link;
+
+ let startIndex = $start.index();
+ let endIndex = $start.indexAfter();
+
+ while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.href === href).length) startIndex--;
+ while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.href === href).length) endIndex++;
+
+ let startPos = $start.start();
+ let endPos = startPos;
+ for (let i = 0; i < endIndex; i++) {
+ const size = $start.parent.child(i).nodeSize;
+ if (i < startIndex) startPos += size;
+ endPos += size;
+ }
+ return { from: startPos, to: endPos };
+ }
+
+ reference_node(pos: ResolvedPos<any>): ProsNode | null {
+ if (!this.view) return null;
+
+ let ref_node: ProsNode = this.view.state.doc;
+ if (pos.nodeBefore !== null && pos.nodeBefore !== undefined) {
+ ref_node = pos.nodeBefore;
+ }
+ else if (pos.nodeAfter !== null && pos.nodeAfter !== undefined) {
+ ref_node = pos.nodeAfter;
+ }
+ else if (pos.pos > 0) {
+ let skip = false;
+ for (let i: number = pos.pos - 1; i > 0; i--) {
+ this.view.state.doc.nodesBetween(i, pos.pos, (node: ProsNode) => {
+ if (node.isLeaf && !skip) {
+ ref_node = node;
+ skip = true;
+ }
+
+ });
+ }
+ }
+ if (!ref_node.isLeaf && ref_node.childCount > 0) {
+ ref_node = ref_node.child(0);
+ }
+ return ref_node;
+ }
+
+ @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; }
+ @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; }
+
+ @action
+ toggleMenuPin = (e: React.MouseEvent) => {
+ this.Pinned = !this.Pinned;
+ if (!this.Pinned) {
+ this.fadeOut(true);
+ }
+ }
+
+ render() {
+
+ const row1 = <div className="antimodeMenu-row">{[
+ this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)),
+ this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)),
+ this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)),
+ this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)),
+ this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)),
+ this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)),
+ this.createColorButton(),
+ this.createHighlighterButton(),
+ this.createLinkButton(),
+ this.createBrushButton(),
+ this.createButton("indent", "Summarize", undefined, this.insertSummarizer),
+ ]}</div>;
+
+ const row2 = <div className="antimodeMenu-row row-2">
+ <div>
+ {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions),
+ this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions),
+ this.createNodesDropdown(this.activeListType, this.listTypeOptions)]}
+ </div>
+ <div>
+ <button className="antimodeMenu-button" title="Pin menu" onClick={this.toggleMenuPin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}>
+ <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} />
+ </button>
+ {this.getDragger()}
+ </div>
+ </div>;
+
+ return (
+ <div className="richTextMenu" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}>
+ {this.getElementWithRows([row1, row2], 2, false)}
+ </div>
+ );
+ }
+}
+
+interface ButtonDropdownProps {
+ view?: EditorView;
+ button: JSX.Element;
+ dropdownContent: JSX.Element;
+ openDropdownOnButton?: boolean;
+}
+
+@observer
+class ButtonDropdown extends React.Component<ButtonDropdownProps> {
+
+ @observable private showDropdown: boolean = false;
+ private ref: HTMLDivElement | null = null;
+
+ componentDidMount() {
+ document.addEventListener("pointerdown", this.onBlur);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener("pointerdown", this.onBlur);
+ }
+
+ @action
+ setShowDropdown(show: boolean) {
+ this.showDropdown = show;
+ }
+ @action
+ toggleDropdown() {
+ this.showDropdown = !this.showDropdown;
+ }
+
+ onDropdownClick = (e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.view && this.props.view.focus();
+ this.toggleDropdown();
+ }
+
+ onBlur = (e: PointerEvent) => {
+ setTimeout(() => {
+ if (this.ref !== null && !this.ref.contains(e.target as Node)) {
+ this.setShowDropdown(false);
+ }
+ }, 0);
+ }
+
+ render() {
+ return (
+ <div className="button-dropdown-wrapper" ref={node => this.ref = node}>
+ {this.props.openDropdownOnButton ?
+ <button className="antimodeMenu-button dropdown-button-combined" onPointerDown={this.onDropdownClick}>
+ {this.props.button}
+ <FontAwesomeIcon icon="caret-down" size="sm" />
+ </button> :
+ <>
+ {this.props.button}
+ <button className="dropdown-button antimodeMenu-button" onPointerDown={this.onDropdownClick}>
+ <FontAwesomeIcon icon="caret-down" size="sm" />
+ </button>
+ </>}
+
+ {this.showDropdown ? this.props.dropdownContent : <></>}
+ </div>
+ );
+ }
+} \ No newline at end of file
diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts
index 4612f10f4..86a7a620e 100644
--- a/src/client/util/SelectionManager.ts
+++ b/src/client/util/SelectionManager.ts
@@ -3,6 +3,8 @@ import { Doc } from "../../new_fields/Doc";
import { DocumentView } from "../views/nodes/DocumentView";
import { computedFn } from "mobx-utils";
import { List } from "../../new_fields/List";
+import { DocumentDecorations } from "../views/DocumentDecorations";
+import RichTextMenu from "./RichTextMenu";
export namespace SelectionManager {
diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx
index 8aa304fad..1c15dca7f 100644
--- a/src/client/util/TooltipTextMenu.tsx
+++ b/src/client/util/TooltipTextMenu.tsx
@@ -80,7 +80,7 @@ export class TooltipTextMenu {
span.appendChild(svg);
return span;
- }
+ };
const basicItems = [ // init basicItems in minimized toolbar -- paths to svgs are obtained from fontawesome
{ mark: schema.marks.strong, dom: svgIcon("strong", "Bold", "M333.49 238a122 122 0 0 0 27-65.21C367.87 96.49 308 32 233.42 32H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h31.87v288H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h209.32c70.8 0 134.14-51.75 141-122.4 4.74-48.45-16.39-92.06-50.83-119.6zM145.66 112h87.76a48 48 0 0 1 0 96h-87.76zm87.76 288h-87.76V288h87.76a56 56 0 0 1 0 112z") },
@@ -93,7 +93,7 @@ export class TooltipTextMenu {
{ mark: schema.marks.subscript, dom: svgIcon("subscript", "Subscript", "M496 448h-16V304a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 352h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z") },
];
- basicItems.map(({ dom, mark }) => this.basicTools?.appendChild(dom.cloneNode(true)));
+ basicItems.map(({ dom, mark }) => this.basicTools ?.appendChild(dom.cloneNode(true)));
basicItems.concat(items).forEach(({ dom, mark }) => {
this.tooltip.appendChild(dom);
this._marksToDoms.set(mark, dom);
@@ -474,7 +474,7 @@ export class TooltipTextMenu {
const node = self.view.state.selection.$from.nodeAfter;
const link = node && node.marks.find(m => m.type === self.view.state.schema.marks.link);
const href = link!.attrs.href;
- if (href?.indexOf(Utils.prepend("/doc/")) === 0) {
+ if (href ?.indexOf(Utils.prepend("/doc/")) === 0) {
const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0];
linkclicked && DocServer.GetRefField(linkclicked).then(async linkDoc => {
if (linkDoc instanceof Doc) {
@@ -500,7 +500,7 @@ export class TooltipTextMenu {
const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, targetId: targetDocId });
this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link).
addMark(this.view.state.selection.from, this.view.state.selection.to, link));
- return this.view.state.selection.$from.nodeAfter?.text || "";
+ return this.view.state.selection.$from.nodeAfter ?.text || "";
}
// SUMMARIZER TOOL
@@ -510,7 +510,7 @@ export class TooltipTextMenu {
const tr = state.tr.addMark(state.selection.from, state.selection.to, mark);
const content = tr.selection.content();
const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() });
- dispatch?.(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark));
+ dispatch ?.(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark));
}
}
@@ -737,7 +737,7 @@ export class TooltipTextMenu {
// get marks in the selection
const selected_marks = new Set<Mark>();
const { from, to } = state.selection as TextSelection;
- state.doc.nodesBetween(from, to, (node) => node.marks?.forEach(m => selected_marks.add(m)));
+ state.doc.nodesBetween(from, to, (node) => node.marks ?.forEach(m => selected_marks.add(m)));
if (this._brushdom && selected_marks.size >= 0) {
TooltipTextMenuManager.Instance._brushMarks = selected_marks;
@@ -849,7 +849,7 @@ export class TooltipTextMenu {
static setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => {
if (mark) {
const node = (state.selection as NodeSelection).node;
- if (node?.type === schema.nodes.ordered_list) {
+ if (node ?.type === schema.nodes.ordered_list) {
let attrs = node.attrs;
if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family };
if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize };
@@ -883,7 +883,7 @@ export class TooltipTextMenu {
if (!lastState || !lastState.doc.eq(view.state.doc) || !lastState.selection.eq(view.state.selection)) {
// UPDATE LINK DROPDOWN
- const linkTarget = await this.getTextLinkTargetTitle()
+ const linkTarget = await this.getTextLinkTargetTitle();
const linkDom = this.createLinkTool(linkTarget ? true : false).render(this.view).dom;
const linkDropdownDom = this.createLinkDropdown(linkTarget).render(this.view).dom;
this.linkDom && this.tooltip.replaceChild(linkDom, this.linkDom);
diff --git a/src/client/views/AntimodeMenu.scss b/src/client/views/AntimodeMenu.scss
index f3da5f284..d4a76ee17 100644
--- a/src/client/views/AntimodeMenu.scss
+++ b/src/client/views/AntimodeMenu.scss
@@ -5,13 +5,26 @@
background: #323232;
box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25);
border-radius: 0px 6px 6px 6px;
- overflow: hidden;
+ // overflow: hidden;
display: flex;
+ &.with-rows {
+ flex-direction: column
+ }
+
+ .antimodeMenu-row {
+ display: flex;
+ height: 35px;
+ }
+
.antimodeMenu-button {
background-color: transparent;
width: 35px;
height: 35px;
+
+ &.active {
+ background-color: #121212;
+ }
}
.antimodeMenu-button:hover {
diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx
index 408df8bc2..4625eb92f 100644
--- a/src/client/views/AntimodeMenu.tsx
+++ b/src/client/views/AntimodeMenu.tsx
@@ -18,9 +18,13 @@ export default abstract class AntimodeMenu extends React.Component {
@observable protected _opacity: number = 1;
@observable protected _transition: string = "opacity 0.5s";
@observable protected _transitionDelay: string = "";
+ @observable protected _canFade: boolean = true;
@observable public Pinned: boolean = false;
+ get width() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().width : 0; }
+ get height() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().height : 0; }
+
@action
/**
* @param x
@@ -62,7 +66,7 @@ export default abstract class AntimodeMenu extends React.Component {
@action
protected pointerLeave = (e: React.PointerEvent) => {
- if (!this.Pinned) {
+ if (!this.Pinned && this._canFade) {
this._transition = "opacity 0.5s";
this._transitionDelay = "1s";
this._opacity = 0.2;
@@ -88,8 +92,8 @@ export default abstract class AntimodeMenu extends React.Component {
document.removeEventListener("pointerup", this.dragEnd);
document.addEventListener("pointerup", this.dragEnd);
- this._offsetX = this._mainCont.current!.getBoundingClientRect().width - e.nativeEvent.offsetX;
- this._offsetY = e.nativeEvent.offsetY;
+ this._offsetX = e.pageX - this._mainCont.current!.getBoundingClientRect().left;
+ this._offsetY = e.pageY - this._mainCont.current!.getBoundingClientRect().top;
e.stopPropagation();
e.preventDefault();
@@ -97,8 +101,14 @@ export default abstract class AntimodeMenu extends React.Component {
@action
protected dragging = (e: PointerEvent) => {
- this._left = e.pageX - this._offsetX;
- this._top = e.pageY - this._offsetY;
+ const width = this._mainCont.current!.getBoundingClientRect().width;
+ const height = this._mainCont.current!.getBoundingClientRect().height;
+
+ const left = e.pageX - this._offsetX;
+ const top = e.pageY - this._offsetY;
+
+ this._left = Math.min(Math.max(left, 0), window.innerWidth - width);
+ this._top = Math.min(Math.max(top, 0), window.innerHeight - height);
e.stopPropagation();
e.preventDefault();
@@ -116,6 +126,10 @@ export default abstract class AntimodeMenu extends React.Component {
e.preventDefault();
}
+ protected getDragger = () => {
+ return <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} />;
+ }
+
protected getElement(buttons: JSX.Element[]) {
return (
<div className="antimodeMenu-cont" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu}
@@ -125,4 +139,14 @@ export default abstract class AntimodeMenu extends React.Component {
</div>
);
}
+
+ protected getElementWithRows(rows: JSX.Element[], numRows: number, hasDragger: boolean = true) {
+ return (
+ <div className="antimodeMenu-cont with-rows" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu}
+ style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay, height: 35 * numRows + "px" }}>
+ {rows}
+ {hasDragger ? <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> : <></>}
+ </div>
+ );
+ }
} \ No newline at end of file
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 4bc24fa93..799b3695c 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -26,6 +26,8 @@ import { IconBox } from "./nodes/IconBox";
import React = require("react");
import { DocumentType } from '../documents/DocumentTypes';
import { ScriptField } from '../../new_fields/ScriptField';
+import { render } from 'react-dom';
+import RichTextMenu from '../util/RichTextMenu';
const higflyout = require("@hig/flyout");
export const { anchorPoints } = higflyout;
export const Flyout = higflyout.default;
@@ -591,6 +593,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
}}>
{minimizeIcon}
+ {/* <RichTextMenu /> */}
+
{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} /> :
<div className="title" onPointerDown={this.onTitleDown} ><span>{`${this.selectionTitle}`}</span></div>}
diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx
index 7cee84fc5..a413eebc9 100644
--- a/src/client/views/InkingStroke.tsx
+++ b/src/client/views/InkingStroke.tsx
@@ -13,8 +13,8 @@ import React = require("react");
type InkDocument = makeInterface<[typeof documentSchema]>;
const InkDocument = makeInterface(documentSchema);
-export function CreatePolyline(points: { x: number, y: number }[], left: number, top: number, color?: string, width?: number) {
- const pts = points.reduce((acc: string, pt: { x: number, y: number }) => acc + `${pt.x - left},${pt.y - top} `, "");
+export function CreatePolyline(points: { X: number, Y: number }[], left: number, top: number, color?: string, width?: number) {
+ const pts = points.reduce((acc: string, pt: { X: number, Y: number }) => acc + `${pt.X - left},${pt.Y - top} `, "");
return (
<polyline
points={pts}
@@ -36,8 +36,8 @@ export class InkingStroke extends DocExtendableComponent<FieldViewProps, InkDocu
render() {
const data: InkData = Cast(this.Document.data, InkField)?.inkData ?? [];
- const xs = data.map(p => p.x);
- const ys = data.map(p => p.y);
+ const xs = data.map(p => p.X);
+ const ys = data.map(p => p.Y);
const left = Math.min(...xs);
const top = Math.min(...ys);
const right = Math.max(...xs);
@@ -53,7 +53,7 @@ export class InkingStroke extends DocExtendableComponent<FieldViewProps, InkDocu
transform: `translate(${left}px, ${top}px) scale(${scaleX}, ${scaleY})`,
mixBlendMode: this.Document.tool === InkTool.Highlighter ? "multiply" : "unset",
pointerEvents: "all"
- }} onTouchStart={this.onTouchStart}>
+ }}>
{points}
</svg>
);
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index db2a3c298..305966160 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -40,6 +40,7 @@ import InkSelectDecorations from './InkSelectDecorations';
import { Scripting } from '../util/Scripting';
import { AudioBox } from './nodes/AudioBox';
import { TraceMobx } from '../../new_fields/util';
+import RichTextMenu from '../util/RichTextMenu';
@observer
export class MainView extends React.Component {
@@ -386,7 +387,7 @@ export class MainView extends React.Component {
getScale={returnOne}>
</DocumentView>
</div>
- <div style={{ position: "relative", height: `calc(100% - ${this._buttonBarHeight}px)`, width: "100%", overflow: "auto" }}>
+ <div className="mainView-contentArea" style={{ position: "relative", height: `calc(100% - ${this._buttonBarHeight}px)`, width: "100%", overflow: "visible" }}>
<DocumentView
Document={sidebarContent}
DataDoc={undefined}
@@ -516,6 +517,7 @@ export class MainView extends React.Component {
<ContextMenu />
<PDFMenu />
<MarqueeOptionsMenu />
+ <RichTextMenu />
<OverlayView />
</div >);
}
diff --git a/src/client/views/Touchable.tsx b/src/client/views/Touchable.tsx
index 183d3e4e8..251cd41e5 100644
--- a/src/client/views/Touchable.tsx
+++ b/src/client/views/Touchable.tsx
@@ -2,7 +2,11 @@ import * as React from 'react';
import { action } from 'mobx';
import { InteractionUtils } from '../util/InteractionUtils';
+const HOLD_DURATION = 1000;
+
export abstract class Touchable<T = {}> extends React.Component<T> {
+ private holdTimer: NodeJS.Timeout | undefined;
+
protected _touchDrag: boolean = false;
protected prevPoints: Map<number, React.Touch> = new Map<number, React.Touch>();
@@ -18,26 +22,24 @@ export abstract class Touchable<T = {}> extends React.Component<T> {
protected onTouchStart = (e: React.TouchEvent): void => {
for (let i = 0; i < e.targetTouches.length; i++) {
const pt: any = e.targetTouches.item(i);
- // pen is also a touch, but with a radius of 0.5 (at least with the surface pens). i doubt anyone's fingers are 2 pixels wide,
+ // pen is also a touch, but with a radius of 0.5 (at least with the surface pens)
// and this seems to be the only way of differentiating pen and touch on touch events
- if (pt.radiusX > 2 && pt.radiusY > 2) {
+ if (pt.radiusX > 0.5 && pt.radiusY > 0.5) {
this.prevPoints.set(pt.identifier, pt);
}
}
if (this.prevPoints.size) {
- switch (e.targetTouches.length) {
+ switch (this.prevPoints.size) {
case 1:
this.handle1PointerDown(e);
+ e.persist();
+ this.holdTimer = setTimeout(() => this.handle1PointerHoldStart(e), HOLD_DURATION);
break;
case 2:
this.handle2PointersDown(e);
+ break;
}
-
- document.removeEventListener("touchmove", this.onTouch);
- document.addEventListener("touchmove", this.onTouch);
- document.removeEventListener("touchend", this.onTouchEnd);
- document.addEventListener("touchend", this.onTouchEnd);
}
}
@@ -46,10 +48,15 @@ export abstract class Touchable<T = {}> extends React.Component<T> {
*/
@action
protected onTouch = (e: TouchEvent): void => {
+ const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints);
+
// if we're not actually moving a lot, don't consider it as dragging yet
- // if (!InteractionUtils.IsDragging(this.prevPoints, e.targetTouches, 5) && !this._touchDrag) return;
+ if (!InteractionUtils.IsDragging(this.prevPoints, myTouches, 5) && !this._touchDrag) return;
this._touchDrag = true;
- switch (e.targetTouches.length) {
+ if (this.holdTimer) {
+ clearTimeout(this.holdTimer);
+ }
+ switch (myTouches.length) {
case 1:
this.handle1PointerMove(e);
break;
@@ -64,32 +71,36 @@ export abstract class Touchable<T = {}> extends React.Component<T> {
if (this.prevPoints.has(pt.identifier)) {
this.prevPoints.set(pt.identifier, pt);
}
- else {
- this.prevPoints.set(pt.identifier, pt);
- }
}
}
}
@action
protected onTouchEnd = (e: TouchEvent): void => {
- this._touchDrag = false;
- e.stopPropagation();
-
+ // console.log(InteractionUtils.GetMyTargetTouches(e, this.prevPoints).length + " up");
// remove all the touches associated with the event
- for (let i = 0; i < e.targetTouches.length; i++) {
- const pt = e.targetTouches.item(i);
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ const pt = e.changedTouches.item(i);
if (pt) {
if (this.prevPoints.has(pt.identifier)) {
this.prevPoints.delete(pt.identifier);
}
}
}
+ if (this.holdTimer) {
+ clearTimeout(this.holdTimer);
+ }
+ this._touchDrag = false;
+ e.stopPropagation();
+
- if (e.targetTouches.length === 0) {
- this.prevPoints.clear();
+ // if (e.targetTouches.length === 0) {
+ // this.prevPoints.clear();
+ // }
+
+ if (this.prevPoints.size === 0) {
+ this.cleanUpInteractions();
}
- this.cleanUpInteractions();
}
cleanUpInteractions = (): void => {
@@ -107,6 +118,23 @@ export abstract class Touchable<T = {}> extends React.Component<T> {
e.preventDefault();
}
- handle1PointerDown = (e: React.TouchEvent): any => { };
- handle2PointersDown = (e: React.TouchEvent): any => { };
+ handle1PointerDown = (e: React.TouchEvent): any => {
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ }
+
+ handle2PointersDown = (e: React.TouchEvent): any => {
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ }
+
+ handle1PointerHoldStart = (e: React.TouchEvent): any => {
+ console.log("Hold");
+ e.stopPropagation();
+ e.preventDefault();
+ }
} \ No newline at end of file
diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx
index 232722f48..151b84c50 100644
--- a/src/client/views/collections/CollectionDockingView.tsx
+++ b/src/client/views/collections/CollectionDockingView.tsx
@@ -32,6 +32,7 @@ import React = require("react");
import { ButtonSelector } from './ParentDocumentSelector';
import { DocumentType } from '../../documents/DocumentTypes';
import { ComputedField } from '../../../new_fields/ScriptField';
+import { InteractionUtils } from '../../util/InteractionUtils';
import { TraceMobx } from '../../../new_fields/util';
library.add(faFile);
const _global = (window /* browser */ || global /* node */) as any;
@@ -478,6 +479,28 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
this.AddTab(stack, Docs.Create.FreeformDocument([], { width: this.props.PanelWidth(), height: this.props.PanelHeight(), title: "Untitled Collection" }), undefined);
}
});
+
+ // starter code for bezel to add new pane
+ // stack.element.on("touchstart", (e: TouchEvent) => {
+ // if (e.targetTouches.length === 2) {
+ // let pt1 = e.targetTouches.item(0);
+ // let pt2 = e.targetTouches.item(1);
+ // let threshold = 40 * window.devicePixelRatio;
+ // if (pt1 && pt2 && InteractionUtils.TwoPointEuclidist(pt1, pt2) < threshold) {
+ // let edgeThreshold = 30 * window.devicePixelRatio;
+ // let center = InteractionUtils.CenterPoint([pt1, pt2]);
+ // let stackRect: DOMRect = stack.element.getBoundingClientRect();
+ // let nearLeft = center.X - stackRect.x < edgeThreshold;
+ // let nearTop = center.Y - stackRect.y < edgeThreshold;
+ // let nearRight = stackRect.right - center.X < edgeThreshold;
+ // let nearBottom = stackRect.bottom - center.Y < edgeThreshold;
+ // let ns = [nearLeft, nearTop, nearRight, nearBottom].filter(n => n);
+ // if (ns.length === 1) {
+
+ // }
+ // }
+ // }
+ // });
stack.header.controlsContainer.find('.lm_close') //get the close icon
.off('click') //unbind the current click handler
.click(action(async function () {
diff --git a/src/client/views/collections/ParentDocumentSelector.tsx b/src/client/views/collections/ParentDocumentSelector.tsx
index 422d01cee..24aa6ddfa 100644
--- a/src/client/views/collections/ParentDocumentSelector.tsx
+++ b/src/client/views/collections/ParentDocumentSelector.tsx
@@ -11,7 +11,7 @@ import { CollectionViewType } from "./CollectionView";
import { DocumentButtonBar } from "../DocumentButtonBar";
import { DocumentManager } from "../../util/DocumentManager";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faEdit } from "@fortawesome/free-solid-svg-icons";
+import { faEdit, faChevronCircleUp } from "@fortawesome/free-solid-svg-icons";
import { library } from "@fortawesome/fontawesome-svg-core";
import { MetadataEntryMenu } from "../MetadataEntryMenu";
import { DocumentView } from "../nodes/DocumentView";
@@ -86,11 +86,11 @@ export class ParentDocSelector extends React.Component<SelectorProps> {
<SelectorContextMenu {...this.props} />
</div>
);
- return <div title="Drag(create link) Tap(view links)" onPointerDown={e => e.stopPropagation()} className="parentDocumentSelector-linkFlyout">
- <Flyout anchorPoint={anchorPoints.RIGHT_TOP}
+ return <div title="Tap to View Contexts/Metadata" onPointerDown={e => e.stopPropagation()} className="parentDocumentSelector-linkFlyout">
+ <Flyout anchorPoint={anchorPoints.LEFT_TOP}
content={flyout}>
<span className="parentDocumentSelector-button" >
- <p>^</p>
+ <FontAwesomeIcon icon={faChevronCircleUp} size={"lg"} />
</span>
</Flyout>
</div>;
@@ -124,7 +124,7 @@ export class ButtonSelector extends React.Component<{ Document: Doc, Stack: any
</div>
);
return <span title="Tap for menu" onPointerDown={e => e.stopPropagation()} className="buttonSelector">
- <Flyout anchorPoint={anchorPoints.RIGHT_TOP} content={flyout} stylesheet={this.customStylesheet}>
+ <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout} stylesheet={this.customStylesheet}>
<FontAwesomeIcon icon={faEdit} size={"sm"} />
</Flyout>
</span>;
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
index 178a5bcdc..b8fbaef5c 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
@@ -54,8 +54,8 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo
} else {
setTimeout(() => {
(this.props.A.props.Document[(this.props.A.props as any).fieldKey] as Doc);
- let m = targetBhyperlink.getBoundingClientRect();
- let mp = this.props.A.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5);
+ const m = targetBhyperlink.getBoundingClientRect();
+ const mp = this.props.A.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5);
this.props.A.props.Document[afield + "_x"] = mp[0] / this.props.A.props.PanelWidth() * 100;
this.props.A.props.Document[afield + "_y"] = mp[1] / this.props.A.props.PanelHeight() * 100;
}, 0);
@@ -66,8 +66,8 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo
} else {
setTimeout(() => {
(this.props.B.props.Document[(this.props.B.props as any).fieldKey] as Doc);
- let m = targetAhyperlink.getBoundingClientRect();
- let mp = this.props.B.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5);
+ const m = targetAhyperlink.getBoundingClientRect();
+ const mp = this.props.B.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5);
this.props.B.props.Document[afield + "_x"] = mp[0] / this.props.B.props.PanelWidth() * 100;
this.props.B.props.Document[afield + "_y"] = mp[1] / this.props.B.props.PanelHeight() * 100;
}, 0);
@@ -93,8 +93,8 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo
apt.point.x, apt.point.y);
const pt1 = [apt.point.x, apt.point.y];
const pt2 = [bpt.point.x, bpt.point.y];
- let aActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
- let bActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
+ const aActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
+ const bActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
return !aActive && !bActive ? (null) :
<line key="linkLine" className="collectionfreeformlinkview-linkLine"
style={{ opacity: this._opacity, strokeDasharray: "2 2" }}
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 6af29171e..eb5a074bb 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -41,6 +41,8 @@ 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";
+import { LinkManager } from "../../../util/LinkManager";
import { CognitiveServices } from "../../../cognitive_services/CognitiveServices";
library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload);
@@ -270,7 +272,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
return clusterColor;
}
- @observable private _points: { x: number, y: number }[] = [];
+ @observable private _points: { X: number, Y: number }[] = [];
@action
onPointerDown = (e: React.PointerEvent): void => {
@@ -286,7 +288,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
e.stopPropagation();
e.preventDefault();
const point = this.getTransform().transformPoint(e.pageX, e.pageY);
- this._points.push({ x: point[0], y: point[1] });
+ this._points.push({ X: point[0], Y: point[1] });
}
// if not using a pen and in no ink mode
else if (InkingControl.Instance.selectedTool === InkTool.None) {
@@ -325,6 +327,28 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
const pt = e.targetTouches.item(0);
if (pt) {
this._hitCluster = this.props.Document.useCluster ? this.pickCluster(this.getTransform().transformPoint(pt.clientX, pt.clientY)) !== -1 : false;
+ if (!e.shiftKey && !e.altKey && !e.ctrlKey && this.props.active(true)) {
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ if (InkingControl.Instance.selectedTool === InkTool.Highlighter || InkingControl.Instance.selectedTool === InkTool.Pen) {
+ e.stopPropagation();
+ e.preventDefault();
+ const point = this.getTransform().transformPoint(pt.pageX, pt.pageY);
+ this._points.push({ X: point[0], Y: point[1] });
+ }
+ else if (InkingControl.Instance.selectedTool === InkTool.None) {
+ this._lastX = pt.pageX;
+ this._lastY = pt.pageY;
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ else {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
}
}
@@ -334,10 +358,65 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
if (this._points.length > 1) {
const B = this.svgBounds;
- const points = this._points.map(p => ({ x: p.x - B.left, y: p.y - B.top }));
- const inkDoc = Docs.Create.InkDocument(InkingControl.Instance.selectedColor, InkingControl.Instance.selectedTool, parseInt(InkingControl.Instance.selectedWidth), points, { width: B.width, height: B.height, x: B.left, y: B.top });
- this.addDocument(inkDoc);
- this._points = [];
+ const points = this._points.map(p => ({ X: p.X - B.left, Y: p.Y - B.top }));
+
+ const result = GestureUtils.GestureRecognizer.Recognize(new Array(points));
+ let actionPerformed = false;
+ if (result && result.Score > 0.7) {
+ switch (result.Name) {
+ case GestureUtils.Gestures.Box:
+ const bounds = { x: Math.min(...this._points.map(p => p.X)), r: Math.max(...this._points.map(p => p.X)), y: Math.min(...this._points.map(p => p.Y)), b: Math.max(...this._points.map(p => p.Y)) };
+ const sel = this.getActiveDocuments().filter(doc => {
+ const l = NumCast(doc.x);
+ const r = l + doc[WidthSym]();
+ const t = NumCast(doc.y);
+ const b = t + doc[HeightSym]();
+ const pass = !(bounds.x > r || bounds.r < l || bounds.y > b || bounds.b < t);
+ if (pass) {
+ doc.x = l - B.left - B.width / 2;
+ doc.y = t - B.top - B.height / 2;
+ }
+ return pass;
+ });
+ this.addDocument(Docs.Create.FreeformDocument(sel, { x: B.left, y: B.top, width: B.width, height: B.height, panX: 0, panY: 0 }));
+ sel.forEach(d => this.props.removeDocument(d));
+ actionPerformed = true;
+ break;
+ case GestureUtils.Gestures.Line:
+ const ep1 = this._points[0];
+ const ep2 = this._points[this._points.length - 1];
+ let d1: Doc | undefined;
+ let d2: Doc | undefined;
+ this.getActiveDocuments().map(doc => {
+ const l = NumCast(doc.x);
+ const r = l + doc[WidthSym]();
+ const t = NumCast(doc.y);
+ const b = t + doc[HeightSym]();
+ if (!d1 && l < ep1.X && r > ep1.X && t < ep1.Y && b > ep1.Y) {
+ d1 = doc;
+ }
+ else if (!d2 && l < ep2.X && r > ep2.X && t < ep2.Y && b > ep2.Y) {
+ d2 = doc;
+ }
+ });
+ if (d1 && d2) {
+ if (!LinkManager.Instance.doesLinkExist(d1, d2)) {
+ DocUtils.MakeLink({ doc: d1 }, { doc: d2 });
+ actionPerformed = true;
+ }
+ }
+ break;
+ }
+ if (actionPerformed) {
+ this._points = [];
+ }
+ }
+
+ if (!actionPerformed) {
+ const inkDoc = Docs.Create.InkDocument(InkingControl.Instance.selectedColor, InkingControl.Instance.selectedTool, parseInt(InkingControl.Instance.selectedWidth), points, { width: B.width, height: B.height, x: B.left, y: B.top });
+ this.addDocument(inkDoc);
+ this._points = [];
+ }
}
document.removeEventListener("pointermove", this.onPointerMove);
@@ -396,7 +475,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
const selectedTool = InkingControl.Instance.selectedTool;
if (selectedTool === InkTool.Highlighter || selectedTool === InkTool.Pen || InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) {
const point = this.getTransform().transformPoint(e.clientX, e.clientY);
- this._points.push({ x: point[0], y: point[1] });
+ this._points.push({ X: point[0], Y: point[1] });
}
else if (selectedTool === InkTool.None) {
if (this._hitCluster && this.tryDragCluster(e)) {
@@ -416,7 +495,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
handle1PointerMove = (e: TouchEvent) => {
// panning a workspace
if (!e.cancelBubble) {
- const pt = e.targetTouches.item(0);
+ const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints);
+ const pt = myTouches[0];
if (pt) {
if (InkingControl.Instance.selectedTool === InkTool.None) {
if (this._hitCluster && this.tryDragCluster(e)) {
@@ -430,7 +510,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
}
else if (InkingControl.Instance.selectedTool !== InkTool.Eraser && InkingControl.Instance.selectedTool !== InkTool.Scrubber) {
const point = this.getTransform().transformPoint(pt.clientX, pt.clientY);
- this._points.push({ x: point[0], y: point[1] });
+ this._points.push({ X: point[0], Y: point[1] });
}
}
e.stopPropagation();
@@ -441,9 +521,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
handle2PointersMove = (e: TouchEvent) => {
// pinch zooming
if (!e.cancelBubble) {
- const pt1: Touch | null = e.targetTouches.item(0);
- const pt2: Touch | null = e.targetTouches.item(1);
- if (!pt1 || !pt2) return;
+ const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints);
+ const pt1 = myTouches[0];
+ const pt2 = myTouches[1];
if (this.prevPoints.size === 2) {
const oldPoint1 = this.prevPoints.get(pt1.identifier);
@@ -462,8 +542,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
const rawDelta = (dir * (d1 + d2));
// this floors and ceils the delta value to prevent jitteriness
- const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 16);
- this.zoom(centerX, centerY, delta);
+ const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 8);
+ this.zoom(centerX, centerY, delta * window.devicePixelRatio);
this.prevPoints.set(pt1.identifier, pt1);
this.prevPoints.set(pt2.identifier, pt2);
}
@@ -478,20 +558,28 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
}
}
}
+ e.stopPropagation();
+ e.preventDefault();
}
- e.stopPropagation();
- e.preventDefault();
}
+ @action
handle2PointersDown = (e: React.TouchEvent) => {
- const pt1: React.Touch | null = e.targetTouches.item(0);
- const pt2: React.Touch | null = e.targetTouches.item(1);
- if (!pt1 || !pt2) return;
+ if (!e.nativeEvent.cancelBubble && this.props.active(true)) {
+ const pt1: React.Touch | null = e.targetTouches.item(0);
+ const pt2: React.Touch | null = e.targetTouches.item(1);
+ if (!pt1 || !pt2) return;
- const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2;
- const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2;
- this._lastX = centerX;
- this._lastY = centerY;
+ const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2;
+ const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2;
+ this._lastX = centerX;
+ this._lastY = centerY;
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ e.stopPropagation();
+ }
}
cleanUpInteractions = () => {
@@ -855,8 +943,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
}
@computed get svgBounds() {
- const xs = this._points.map(p => p.x);
- const ys = this._points.map(p => p.y);
+ const xs = this._points.map(p => p.X);
+ const ys = this._points.map(p => p.Y);
const right = Math.max(...xs);
const left = Math.min(...xs);
const bottom = Math.max(...ys);
@@ -872,7 +960,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
const B = this.svgBounds;
return (
- <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)` }}>
+ <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)`, position: "absolute", zIndex: 30000 }}>
{CreatePolyline(this._points, B.left, B.top)}
</svg>
);
@@ -950,7 +1038,7 @@ class CollectionFreeFormViewPannableContents extends React.Component<CollectionF
const panx = -this.props.panX();
const pany = -this.props.panY();
const zoom = this.props.zoomScaling();
- return <div className={freeformclass} style={{ transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` }}>
+ return <div className={freeformclass} style={{ touchAction: "none", borderRadius: "inherit", transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` }}>
{this.props.children()}
</div>;
}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 63c17b1f6..10d2e2b3e 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -4,7 +4,7 @@ import { action, computed, runInAction, trace } from "mobx";
import { observer } from "mobx-react";
import * as rp from "request-promise";
import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc";
-import { Document } from '../../../new_fields/documentSchemas';
+import { Document, PositionDocument } from '../../../new_fields/documentSchemas';
import { Id } from '../../../new_fields/FieldSymbols';
import { listSpec } from "../../../new_fields/Schema";
import { ScriptField } from '../../../new_fields/ScriptField';
@@ -236,28 +236,166 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
}
}
+ handle1PointerDown = (e: React.TouchEvent) => {
+ if (!e.nativeEvent.cancelBubble) {
+ const touch = InteractionUtils.GetMyTargetTouches(e, this.prevPoints)[0];
+ this._downX = touch.clientX;
+ this._downY = touch.clientY;
+ this._hitTemplateDrag = false;
+ for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) {
+ if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") {
+ this._hitTemplateDrag = true;
+ }
+ }
+ if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation();
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ if ((e.nativeEvent as any).formattedHandled) e.stopPropagation();
+ }
+ }
+
+ handle1PointerMove = (e: TouchEvent) => {
+ if ((e as any).formattedHandled) { e.stopPropagation; return; }
+ if (e.cancelBubble && this.active) {
+ document.removeEventListener("touchmove", this.onTouch);
+ }
+ else if (!e.cancelBubble && (SelectionManager.IsSelected(this, true) || this.props.parentActive(true) || this.Document.onDragStart || this.Document.onClick) && !this.Document.lockedPosition && !this.Document.inOverlay) {
+ const touch = InteractionUtils.GetMyTargetTouches(e, this.prevPoints)[0];
+ if (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3) {
+ if (!e.altKey && (!this.topMost || this.Document.onDragStart || this.Document.onClick)) {
+ document.removeEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined, this._hitTemplateDrag);
+ }
+ }
+ e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers
+ e.preventDefault();
+
+ }
+ }
+
+ handle2PointersDown = (e: React.TouchEvent) => {
+ if (!e.nativeEvent.cancelBubble && !this.isSelected()) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ document.removeEventListener("touchmove", this.onTouch);
+ document.addEventListener("touchmove", this.onTouch);
+ document.removeEventListener("touchend", this.onTouchEnd);
+ document.addEventListener("touchend", this.onTouchEnd);
+ }
+ }
+
+ @action
+ handle2PointersMove = (e: TouchEvent) => {
+ const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints);
+ const pt1 = myTouches[0];
+ const pt2 = myTouches[1];
+ const oldPoint1 = this.prevPoints.get(pt1.identifier);
+ const oldPoint2 = this.prevPoints.get(pt2.identifier);
+ const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!);
+ if (pinching !== 0 && oldPoint1 && oldPoint2) {
+ // let dX = (Math.min(pt1.clientX, pt2.clientX) - Math.min(oldPoint1.clientX, oldPoint2.clientX));
+ // let dY = (Math.min(pt1.clientY, pt2.clientY) - Math.min(oldPoint1.clientY, oldPoint2.clientY));
+ // let dX = Math.sign(Math.abs(pt1.clientX - oldPoint1.clientX) - Math.abs(pt2.clientX - oldPoint2.clientX));
+ // let dY = Math.sign(Math.abs(pt1.clientY - oldPoint1.clientY) - Math.abs(pt2.clientY - oldPoint2.clientY));
+ // let dW = -dX;
+ // let dH = -dY;
+ const dW = (Math.abs(pt1.clientX - pt2.clientX) - Math.abs(oldPoint1.clientX - oldPoint2.clientX));
+ const dH = (Math.abs(pt1.clientY - pt2.clientY) - Math.abs(oldPoint1.clientY - oldPoint2.clientY));
+ const dX = -1 * Math.sign(dW);
+ const dY = -1 * Math.sign(dH);
+
+ if (dX !== 0 || dY !== 0 || dW !== 0 || dH !== 0) {
+ const doc = PositionDocument(this.props.Document);
+ const layoutDoc = PositionDocument(Doc.Layout(this.props.Document));
+ let nwidth = layoutDoc.nativeWidth || 0;
+ let nheight = layoutDoc.nativeHeight || 0;
+ const width = (layoutDoc.width || 0);
+ const height = (layoutDoc.height || (nheight / nwidth * width));
+ const scale = this.props.ScreenToLocalTransform().Scale * this.props.ContentScaling();
+ const actualdW = Math.max(width + (dW * scale), 20);
+ const actualdH = Math.max(height + (dH * scale), 20);
+ doc.x = (doc.x || 0) + dX * (actualdW - width);
+ doc.y = (doc.y || 0) + dY * (actualdH - height);
+ const fixedAspect = e.ctrlKey || (!layoutDoc.ignoreAspect && nwidth && nheight);
+ if (fixedAspect && e.ctrlKey && layoutDoc.ignoreAspect) {
+ layoutDoc.ignoreAspect = false;
+ layoutDoc.nativeWidth = nwidth = layoutDoc.width || 0;
+ layoutDoc.nativeHeight = nheight = layoutDoc.height || 0;
+ }
+ if (fixedAspect && (!nwidth || !nheight)) {
+ layoutDoc.nativeWidth = nwidth = layoutDoc.width || 0;
+ layoutDoc.nativeHeight = nheight = layoutDoc.height || 0;
+ }
+ if (nwidth > 0 && nheight > 0 && !layoutDoc.ignoreAspect) {
+ if (Math.abs(dW) > Math.abs(dH)) {
+ if (!fixedAspect) {
+ layoutDoc.nativeWidth = actualdW / (layoutDoc.width || 1) * (layoutDoc.nativeWidth || 0);
+ }
+ layoutDoc.width = actualdW;
+ if (fixedAspect && !layoutDoc.fitWidth) layoutDoc.height = nheight / nwidth * layoutDoc.width;
+ else layoutDoc.height = actualdH;
+ }
+ else {
+ if (!fixedAspect) {
+ layoutDoc.nativeHeight = actualdH / (layoutDoc.height || 1) * (doc.nativeHeight || 0);
+ }
+ layoutDoc.height = actualdH;
+ if (fixedAspect && !layoutDoc.fitWidth) layoutDoc.width = nwidth / nheight * layoutDoc.height;
+ else layoutDoc.width = actualdW;
+ }
+ } else {
+ dW && (layoutDoc.width = actualdW);
+ dH && (layoutDoc.height = actualdH);
+ dH && layoutDoc.autoHeight && (layoutDoc.autoHeight = false);
+ }
+ }
+ // let newWidth = Math.max(Math.abs(oldPoint1!.clientX - oldPoint2!.clientX), Math.abs(pt1.clientX - pt2.clientX))
+ // this.props.Document.width = newWidth;
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+
onPointerDown = (e: React.PointerEvent): void => {
- if ((e.nativeEvent.cancelBubble && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)))
- // return if we're inking, and not selecting a button document
- || (InkingControl.Instance.selectedTool !== InkTool.None && !this.Document.onClick)
- // return if using pen or eraser
- || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || InteractionUtils.IsType(e, InteractionUtils.ERASERTYPE)) return;
- this._downX = e.clientX;
- this._downY = e.clientY;
- this._hitTemplateDrag = false;
- // this whole section needs to move somewhere else. We're trying to initiate a special "template" drag where
- // this document is the template and we apply it to whatever we drop it on.
- for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) {
- if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") {
- this._hitTemplateDrag = true;
+ // console.log(e.button)
+ // console.log(e.nativeEvent)
+ // continue if the event hasn't been canceled AND we are using a moues or this is has an onClick or onDragStart function (meaning it is a button document)
+ if (!InteractionUtils.IsType(e, InteractionUtils.MOUSETYPE)) {
+ if (!InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) {
+ e.stopPropagation();
}
+ return;
+ }
+ if ((!e.nativeEvent.cancelBubble || this.Document.onClick || this.Document.onDragStart)) {
+ // if ((e.nativeEvent.cancelBubble && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)))
+ // // return if we're inking, and not selecting a button document
+ // || (InkingControl.Instance.selectedTool !== InkTool.None && !this.Document.onClick)
+ // // return if using pen or eraser
+ // || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || InteractionUtils.IsType(e, InteractionUtils.ERASERTYPE)) {
+ // return;
+ // }
+
+ this._downX = e.clientX;
+ this._downY = e.clientY;
+ this._hitTemplateDrag = false;
+ // this whole section needs to move somewhere else. We're trying to initiate a special "template" drag where
+ // this document is the template and we apply it to whatever we drop it on.
+ for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) {
+ if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") {
+ this._hitTemplateDrag = true;
+ }
+ }
+ if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag);
+ document.removeEventListener("pointermove", this.onPointerMove);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ document.addEventListener("pointermove", this.onPointerMove);
+ document.addEventListener("pointerup", this.onPointerUp);
+ if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); }
}
- if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag);
- document.removeEventListener("pointermove", this.onPointerMove);
- document.removeEventListener("pointerup", this.onPointerUp);
- document.addEventListener("pointermove", this.onPointerMove);
- document.addEventListener("pointerup", this.onPointerUp);
- if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); }
}
onPointerMove = (e: PointerEvent): void => {
@@ -445,6 +583,11 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
@action
onContextMenu = async (e: React.MouseEvent): Promise<void> => {
+ // the touch onContextMenu is button 0, the pointer onContextMenu is button 2
+ if (e.button === 0) {
+ e.preventDefault();
+ return;
+ }
e.persist();
e.stopPropagation();
if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3 ||
@@ -707,21 +850,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
return (this.Document.isBackground && !this.isSelected()) || (this.Document.type === DocumentType.INK && InkingControl.Instance.selectedTool !== InkTool.None);
}
- @action
- handle2PointersMove = (e: TouchEvent) => {
- const pt1 = e.targetTouches.item(0);
- const pt2 = e.targetTouches.item(1);
- if (pt1 && pt2 && this.prevPoints.has(pt1.identifier) && this.prevPoints.has(pt2.identifier)) {
- const oldPoint1 = this.prevPoints.get(pt1.identifier);
- const oldPoint2 = this.prevPoints.get(pt2.identifier);
- const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!);
- if (pinching !== 0) {
- const newWidth = Math.max(Math.abs(oldPoint1!.clientX - oldPoint2!.clientX), Math.abs(pt1.clientX - pt2.clientX));
- this.props.Document.width = newWidth;
- }
- }
- }
-
render() {
if (!(this.props.Document instanceof Doc)) return (null);
const ruleColor = this.props.ruleProvider ? StrCast(this.props.ruleProvider["ruleColor_" + this.Document.heading]) : undefined;
@@ -742,7 +870,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
const highlightColors = ["transparent", "maroon", "maroon", "yellow", "magenta", "cyan", "orange"];
const highlightStyles = ["solid", "dashed", "solid", "solid", "solid", "solid", "solid"];
- const highlighting = fullDegree && this.layoutDoc.type !== DocumentType.FONTICON && this.layoutDoc.viewType !== CollectionViewType.Linear;
+ let highlighting = fullDegree && this.layoutDoc.type !== DocumentType.FONTICON && this.layoutDoc.viewType !== CollectionViewType.Linear;
+ highlighting = highlighting && this.props.focus !== emptyFunction; // bcz: hack to turn off highlighting onsidebar panel documents. need to flag a document as not highlightable in a more direct way
return <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} onKeyDown={this.onKeyDown}
onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick}
onPointerEnter={e => Doc.BrushDoc(this.props.Document)} onPointerLeave={e => Doc.UnBrushDoc(this.props.Document)}
diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss
index a344a50b3..c203ca0c3 100644
--- a/src/client/views/nodes/FormattedTextBox.scss
+++ b/src/client/views/nodes/FormattedTextBox.scss
@@ -11,6 +11,7 @@
}
.formattedTextBox-cont {
+ touch-action: none;
cursor: text;
background: inherit;
padding: 0;
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 3d1517d2a..8e28cf928 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -47,6 +47,8 @@ import { AudioBox } from './AudioBox';
import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView';
import { InkTool } from '../../../new_fields/InkField';
import { TraceMobx } from '../../../new_fields/util';
+import RichTextMenu from '../../util/RichTextMenu';
+import { DocumentDecorations } from '../DocumentDecorations';
library.add(faEdit);
library.add(faSmile, faTextHeight, faUpload);
@@ -904,11 +906,18 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
this.tryUpdateHeight();
// see if we need to preserve the insertion point
- const prosediv = this.ProseRef?.children?.[0] as any;
- const keeplocation = prosediv?.keeplocation;
+ const prosediv = this.ProseRef ?.children ?.[0] as any;
+ const keeplocation = prosediv ?.keeplocation;
prosediv && (prosediv.keeplocation = undefined);
- const pos = this._editorView?.state.selection.$from.pos || 1;
- keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
+ const pos = this._editorView ?.state.selection.$from.pos || 1;
+ keeplocation && setTimeout(() => this._editorView ?.dispatch(this._editorView ?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos))));
+
+ // jump rich text menu to this textbox
+ if (this._ref.current) {
+ const x = Math.min(Math.max(this._ref.current!.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width);
+ const y = this._ref.current!.getBoundingClientRect().top - RichTextMenu.Instance.height - 50;
+ RichTextMenu.Instance.jumpTo(x, y);
+ }
}
onPointerWheel = (e: React.WheelEvent): void => {
// if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time
@@ -924,7 +933,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
if ((this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text.
const pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY });
const node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text)
- if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) {
+ if (pcords && node ?.type === this._editorView!.state.schema.nodes.dashComment) {
this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2)));
e.preventDefault();
}
@@ -987,7 +996,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
for (let off = 1; off < 100; off++) {
const pos = this._editorView!.posAtCoords({ left: x + off, top: y });
const node = pos && this._editorView!.state.doc.nodeAt(pos.pos);
- if (node?.type === schema.nodes.list_item) {
+ if (node ?.type === schema.nodes.list_item) {
list_node = node;
break;
}
@@ -1032,7 +1041,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
const self = FormattedTextBox;
return new Plugin({
view(newView) {
- return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView);
+ // return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView);
+ RichTextMenu.Instance.changeView(newView);
+ return RichTextMenu.Instance;
}
});
}
@@ -1052,6 +1063,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
this._undoTyping = undefined;
}
this.doLinkOnDeselect();
+
+ // move the richtextmenu offscreen
+ if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300);
}
_lastTimedMark: Mark | undefined = undefined;
@@ -1073,7 +1087,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
}
if (e.key === "Escape") {
this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from)));
- (document.activeElement as any).blur?.();
+ (document.activeElement as any).blur ?.();
SelectionManager.DeselectAll();
}
e.stopPropagation();
@@ -1095,7 +1109,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
@action
tryUpdateHeight(limitHeight?: number) {
- let scrollHeight = this._ref.current?.scrollHeight;
+ let scrollHeight = this._ref.current ?.scrollHeight;
if (!this.layoutDoc.animateToPos && this.layoutDoc.autoHeight && scrollHeight &&
getComputedStyle(this._ref.current!.parentElement!).top === "0px") { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation
if (limitHeight && scrollHeight > limitHeight) {
@@ -1121,7 +1135,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
const rounded = StrCast(this.layoutDoc.borderRounding) === "100%" ? "-rounded" : "";
const interactive = InkingControl.Instance.selectedTool || this.layoutDoc.isBackground;
if (this.props.isSelected()) {
- FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props);
+ // TODO: ftong --> update from dash in richtextmenu
+ RichTextMenu.Instance.updateFromDash(this._editorView!, undefined, this.props);
+ // FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props);
} else if (FormattedTextBoxComment.textBox === this) {
FormattedTextBoxComment.Hide();
}
@@ -1145,7 +1161,6 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
onPointerUp={this.onPointerUp}
onPointerDown={this.onPointerDown}
onMouseUp={this.onMouseUp}
- onTouchStart={this.onTouchStart}
onWheel={this.onPointerWheel}
onPointerEnter={action(() => this._entered = true)}
onPointerLeave={action(() => this._entered = false)}
@@ -1156,7 +1171,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
{this.props.Document.hideSidebar ? (null) : this.sidebarWidthPercent === "0%" ?
<div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> :
<div className={"formattedTextBox-sidebar" + (InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : "")}
- style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc?.backgroundColor, "transparent")}` }}>
+ style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${StrCast(this.extensionDoc ?.backgroundColor, "transparent")}` }}>
<CollectionFreeFormView {...this.props}
PanelHeight={this.props.PanelHeight}
PanelWidth={() => this.sidebarWidth}
diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx
index 503696ae9..05c70b74a 100644
--- a/src/client/views/pdf/PDFMenu.tsx
+++ b/src/client/views/pdf/PDFMenu.tsx
@@ -98,7 +98,7 @@ export default class PDFMenu extends AntimodeMenu {
}
render() {
- const buttons = this.Status === "pdf" ?
+ const buttons = this.Status === "pdf" ?
[
<button key="1" className="antimodeMenu-button" title="Click to Highlight" onClick={this.highlightClicked} style={this.Highlighting ? { backgroundColor: "#121212" } : {}}>
<FontAwesomeIcon icon="highlighter" size="lg" style={{ transition: "transform 0.1s", transform: this.Highlighting ? "" : "rotate(-45deg)" }} /></button>,
diff --git a/src/client/views/search/SearchBox.scss b/src/client/views/search/SearchBox.scss
index 4eb992d36..0825580b7 100644
--- a/src/client/views/search/SearchBox.scss
+++ b/src/client/views/search/SearchBox.scss
@@ -70,8 +70,7 @@
display: flex;
flex-direction: column;
height: 100%;
- overflow: hidden;
- overflow-y: auto;
+ overflow: visible;
.no-result {
width: 500px;
diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx
index 2f28ebf76..dd1ac7421 100644
--- a/src/client/views/search/SearchBox.tsx
+++ b/src/client/views/search/SearchBox.tsx
@@ -353,7 +353,8 @@ export class SearchBox extends React.Component {
</div>
<div className="searchBox-results" onScroll={this.resultsScrolled} style={{
display: this._resultsOpen ? "flex" : "none",
- height: this.resFull ? "auto" : this.resultHeight, overflow: this.resFull ? "auto" : "visible"
+ height: this.resFull ? "auto" : this.resultHeight,
+ overflow: "visibile" // this.resFull ? "auto" : "visible"
}} ref={this.resultsRef}>
{this._visibleElements}
</div>
diff --git a/src/client/views/search/SearchItem.scss b/src/client/views/search/SearchItem.scss
index 82ff96700..469f062b2 100644
--- a/src/client/views/search/SearchItem.scss
+++ b/src/client/views/search/SearchItem.scss
@@ -1,22 +1,14 @@
@import "../globalCssVariables";
-.search-overview {
+.searchItem-overview {
display: flex;
flex-direction: reverse;
justify-content: flex-end;
z-index: 0;
}
-.link-count {
- background: black;
- border-radius: 20px;
- color: white;
- width: 15px;
- text-align: center;
- margin-top: 5px;
-}
.searchBox-placeholder,
-.search-overview .search-item {
+.searchItem-overview .searchItem {
width: 100%;
background: $light-color-secondary;
border-color: $intermediate-color;
@@ -26,19 +18,19 @@
max-height: 150px;
height: auto;
z-index: 0;
- display: inline-block;
- overflow: auto;
+ display: flex;
+ overflow: visible;
- .main-search-info {
+ .searchItem-body {
display: flex;
flex-direction: row;
width: 100%;
- .search-title-container {
+ .searchItem-title-container {
width: 100%;
overflow: hidden;
- .search-title {
+ .searchItem-title {
text-transform: uppercase;
text-align: left;
width: 100%;
@@ -46,75 +38,28 @@
}
}
- .search-info {
+ .searchItem-info {
display: flex;
justify-content: flex-end;
- .link-container.item {
- margin-left: auto;
- margin-right: auto;
- height: 26px;
- width: 26px;
- border-radius: 13px;
- background: $dark-color;
- color: $light-color-secondary;
- display: flex;
- justify-content: center;
- align-items: center;
- -webkit-transition: all 0.2s ease-in-out;
- -moz-transition: all 0.2s ease-in-out;
- -o-transition: all 0.2s ease-in-out;
- transition: all 0.2s ease-in-out;
- transform-origin: top right;
- overflow: hidden;
- position: relative;
-
-
- .link-extended {
- // display: none;
- visibility: hidden;
- opacity: 0;
- position: relative;
- z-index: 500;
- overflow: hidden;
- -webkit-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s;
- -moz-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s;
- -o-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s;
- transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s;
- // transition-delay: 1s;
- }
-
- }
-
- .link-container.item:hover {
- width: 70px;
- }
-
- .link-container.item:hover .link-count {
- opacity: 0;
- }
-
- .link-container.item:hover .link-extended {
- opacity: 1;
- visibility: visible;
- // display: inline;
- }
-
.icon-icons {
width: 50px
}
.icon-live {
width: 175px;
+ height: 0px;
}
+ .icon-icons {
+ height:auto;
+ }
.icon-icons,
.icon-live {
- height: auto;
margin: auto;
- overflow: hidden;
+ overflow: visible;
- .search-type {
+ .searchItem-type {
display: inline-block;
width: 100%;
position: absolute;
@@ -133,11 +78,11 @@
}
}
- .search-type:hover+.search-label {
+ .searchItem-type:hover+.searchItem-label {
opacity: 1;
}
- .search-label {
+ .searchItem-label {
font-size: 10;
position: relative;
right: 0px;
@@ -151,8 +96,6 @@
}
.icon-live:hover {
- height: 175px;
-
.pdfBox-cont {
img {
width: 100% !important;
@@ -161,42 +104,44 @@
}
}
- .search-info:hover {
+ .searchItem-info:hover {
width: 60%;
}
}
}
-.search-item:hover~.searchBox-instances,
+.searchItem:hover~.searchBox-instances,
.searchBox-instances:hover,
.searchBox-instances:active {
opacity: 1;
background: $lighter-alt-accent;
- width:150px
}
-.search-item:hover {
+.searchItem:hover {
transition: all 0.2s;
background: $lighter-alt-accent;
}
-.search-highlighting {
+.searchItem-highlighting {
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.searchBox-instances {
- float: left;
opacity: 1;
- width: 0px;
+ width:40px;
+ height:40px;
+ background: gray;
transition: all 0.2s ease;
color: black;
overflow: hidden;
+ right:-100;
+ display:inline-block;
}
-.search-overview:hover {
+.searchItem-overview:hover {
z-index: 1;
}
diff --git a/src/client/views/search/SearchItem.tsx b/src/client/views/search/SearchItem.tsx
index 673cb7937..32ba5d19d 100644
--- a/src/client/views/search/SearchItem.tsx
+++ b/src/client/views/search/SearchItem.tsx
@@ -17,11 +17,11 @@ import { SEARCH_THUMBNAIL_SIZE } from "../../views/globalCssVariables.scss";
import { CollectionViewType } from "../collections/CollectionView";
import { CollectionDockingView } from "../collections/CollectionDockingView";
import { ContextMenu } from "../ContextMenu";
-import { DocumentView } from "../nodes/DocumentView";
import { SearchBox } from "./SearchBox";
import "./SearchItem.scss";
import "./SelectorContextMenu.scss";
import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView";
+import { ButtonSelector, ParentDocSelector } from "../collections/ParentDocumentSelector";
export interface SearchItemProps {
doc: Doc;
@@ -188,24 +188,12 @@ export class SearchItem extends React.Component<SearchItemProps> {
layoutresult.indexOf(DocumentType.HIST) !== -1 ? faChartBar :
layoutresult.indexOf(DocumentType.WEB) !== -1 ? faGlobeAsia :
faCaretUp;
- return <div onPointerDown={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} >
+ return <div onClick={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} >
<FontAwesomeIcon icon={button} size="2x" />
</div>;
}
collectionRef = React.createRef<HTMLDivElement>();
- startDocDrag = () => {
- const doc = this.props.doc;
- const isProto = Doc.GetT(doc, "isPrototype", "boolean", true);
- if (isProto) {
- return Doc.MakeDelegate(doc);
- } else {
- return Doc.MakeAlias(doc);
- }
- }
-
- @computed
- get linkCount() { return DocListCast(this.props.doc.links).length; }
@action
pointerDown = (e: React.PointerEvent) => { e.preventDefault(); e.button === 0 && SearchBox.Instance.openSearch(e); }
@@ -258,43 +246,62 @@ export class SearchItem extends React.Component<SearchItemProps> {
ContextMenu.Instance.displayMenu(e.clientX, e.clientY);
}
+ _downX = 0;
+ _downY = 0;
+ _target: any;
onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+ this._downX = e.clientX;
+ this._downY = e.clientY;
e.stopPropagation();
- const doc = Doc.IsPrototype(this.props.doc) ? Doc.MakeDelegate(this.props.doc) : this.props.doc;
- DragManager.StartDocumentDrag([e.currentTarget], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY);
+ this._target = e.currentTarget;
+ document.removeEventListener("pointermove", this.onPointerMoved);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ document.addEventListener("pointermove", this.onPointerMoved);
+ document.addEventListener("pointerup", this.onPointerUp);
+ }
+ onPointerMoved = (e: PointerEvent) => {
+ if (Math.abs(e.clientX - this._downX) > Utils.DRAG_THRESHOLD ||
+ Math.abs(e.clientY - this._downY) > Utils.DRAG_THRESHOLD) {
+ console.log("DRAGGIGNG");
+ document.removeEventListener("pointermove", this.onPointerMoved);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ const doc = Doc.IsPrototype(this.props.doc) ? Doc.MakeDelegate(this.props.doc) : this.props.doc;
+ DragManager.StartDocumentDrag([this._target], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY);
+ }
+ }
+ onPointerUp = (e: PointerEvent) => {
+ document.removeEventListener("pointermove", this.onPointerMoved);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ }
+
+ @computed
+ get contextButton() {
+ return <ParentDocSelector Views={DocumentManager.Instance.DocumentViews} Document={this.props.doc} addDocTab={(doc, data, where) => CollectionDockingView.AddRightSplit(doc, data)} />;
}
render() {
const doc1 = Cast(this.props.doc.anchor1, Doc);
const doc2 = Cast(this.props.doc.anchor2, Doc);
- return (
- <div className="search-overview" onPointerDown={this.pointerDown} onContextMenu={this.onContextMenu}>
- <div className="search-item" onPointerDown={this.nextHighlight} onPointerEnter={this.highlightDoc} onPointerLeave={this.unHighlightDoc} id="result"
- onClick={this.onClick}>
- <div className="main-search-info">
- <div title="Drag as document" onPointerDown={this.onPointerDown} style={{ marginRight: "7px" }}> <FontAwesomeIcon icon="file" size="lg" />
- <div className="link-container item">
- <div className="link-count" title={`${this.linkCount + " links"}`}>{this.linkCount}</div>
- </div>
- </div>
- <div className="search-title-container">
- <div className="search-title">{StrCast(this.props.doc.title)}</div>
- <div className="search-highlighting">{this.props.highlighting.length ? "Matched fields:" + this.props.highlighting.join(", ") : this.props.lines.length ? this.props.lines[0] : ""}</div>
- {this.props.lines.filter((m, i) => i).map((l, i) => <div id={i.toString()} className="search-highlighting">`${l}`</div>)}
- </div>
- <div className="search-info" style={{ width: this._useIcons ? "15%" : "100%" }}>
- <div className={`icon-${this._useIcons ? "icons" : "live"}`}>
- <div className="search-type" title="Click to Preview">{this.DocumentIcon()}</div>
- <div className="search-label">{this.props.doc.type ? this.props.doc.type : "Other"}</div>
- </div>
- </div>
+ return <div className="searchItem-overview" onPointerDown={this.pointerDown} onContextMenu={this.onContextMenu}>
+ <div className="searchItem" onPointerDown={this.nextHighlight} onPointerEnter={this.highlightDoc} onPointerLeave={this.unHighlightDoc}>
+ <div className="searchItem-body" onClick={this.onClick}>
+ <div className="searchItem-title-container">
+ <div className="searchItem-title">{StrCast(this.props.doc.title)}</div>
+ <div className="searchItem-highlighting">{this.props.highlighting.length ? "Matched fields:" + this.props.highlighting.join(", ") : this.props.lines.length ? this.props.lines[0] : ""}</div>
+ {this.props.lines.filter((m, i) => i).map((l, i) => <div id={i.toString()} className="searchItem-highlighting">`${l}`</div>)}
</div>
</div>
- <div className="searchBox-instances">
+ <div className="searchItem-info" style={{ width: this._useIcons ? "30px" : "100%" }}>
+ <div className={`icon-${this._useIcons ? "icons" : "live"}`}>
+ <div className="searchItem-type" title="Click to Preview" onPointerDown={this.onPointerDown}>{this.DocumentIcon()}</div>
+ <div className="searchItem-label">{this.props.doc.type ? this.props.doc.type : "Other"}</div>
+ </div>
+ </div>
+ <div className="searchItem-context" title="Drag as document">
{(doc1 instanceof Doc && doc2 instanceof Doc) && this.props.doc.type === DocumentType.LINK ? <LinkContextMenu doc1={doc1} doc2={doc2} /> :
- <SelectorContextMenu {...this.props} />}
+ this.contextButton}
</div>
</div>
- );
+ </div>;
}
} \ No newline at end of file
diff --git a/src/new_fields/InkField.ts b/src/new_fields/InkField.ts
index 2d8bb582a..e2aa7ee16 100644
--- a/src/new_fields/InkField.ts
+++ b/src/new_fields/InkField.ts
@@ -2,7 +2,6 @@ import { Deserializable } from "../client/util/SerializationHelper";
import { serializable, custom, createSimpleSchema, list, object, map } from "serializr";
import { ObjectField } from "./ObjectField";
import { Copy, ToScriptString } from "./FieldSymbols";
-import { DeepCopy } from "../Utils";
export enum InkTool {
None,
@@ -13,14 +12,14 @@ export enum InkTool {
}
export interface PointData {
- x: number;
- y: number;
+ X: number;
+ Y: number;
}
export type InkData = Array<PointData>;
const pointSchema = createSimpleSchema({
- x: true, y: true
+ X: true, Y: true
});
const strokeDataSchema = createSimpleSchema({
diff --git a/src/pen-gestures/GestureUtils.ts b/src/pen-gestures/GestureUtils.ts
new file mode 100644
index 000000000..59a85b66b
--- /dev/null
+++ b/src/pen-gestures/GestureUtils.ts
@@ -0,0 +1,28 @@
+import { NDollarRecognizer } from "./ndollar";
+import { Type } from "typescript";
+import { InkField } from "../new_fields/InkField";
+import { Docs } from "../client/documents/Documents";
+import { Doc, WidthSym, HeightSym } from "../new_fields/Doc";
+import { NumCast } from "../new_fields/Types";
+import { CollectionFreeFormView } from "../client/views/collections/collectionFreeForm/CollectionFreeFormView";
+
+export namespace GestureUtils {
+ namespace GestureDataTypes {
+ export type BoxData = Array<Doc>;
+ }
+
+ export enum Gestures {
+ Box = "box",
+ Line = "line"
+ }
+
+ export const GestureRecognizer = new NDollarRecognizer(false);
+
+ export function GestureOptions(name: string, gestureData?: any): (params: {}) => any {
+ switch (name) {
+ case Gestures.Box:
+ break;
+ }
+ throw new Error("This means that you're trying to do something with the gesture that hasn't been defined yet. Define it in GestureUtils.ts");
+ }
+} \ No newline at end of file
diff --git a/src/pen-gestures/ndollar.ts b/src/pen-gestures/ndollar.ts
new file mode 100644
index 000000000..872c524d6
--- /dev/null
+++ b/src/pen-gestures/ndollar.ts
@@ -0,0 +1,533 @@
+import { GestureUtils } from "./GestureUtils";
+
+/**
+ * The $N Multistroke Recognizer (JavaScript version)
+ * Converted to TypeScript -syip2
+ *
+ * Lisa Anthony, Ph.D.
+ * UMBC
+ * Information Systems Department
+ * 1000 Hilltop Circle
+ * Baltimore, MD 21250
+ * lanthony@umbc.edu
+ *
+ * Jacob O. Wobbrock, Ph.D.
+ * The Information School
+ * University of Washington
+ * Seattle, WA 98195-2840
+ * wobbrock@uw.edu
+ *
+ * The academic publications for the $N recognizer, and what should be
+ * used to cite it, are:
+ *
+ * Anthony, L. and Wobbrock, J.O. (2010). A lightweight multistroke
+ * recognizer for user interface prototypes. Proceedings of Graphics
+ * Interface (GI '10). Ottawa, Ontario (May 31-June 2, 2010). Toronto,
+ * Ontario: Canadian Information Processing Society, pp. 245-252.
+ * https://dl.acm.org/citation.cfm?id=1839258
+ *
+ * Anthony, L. and Wobbrock, J.O. (2012). $N-Protractor: A fast and
+ * accurate multistroke recognizer. Proceedings of Graphics Interface
+ * (GI '12). Toronto, Ontario (May 28-30, 2012). Toronto, Ontario:
+ * Canadian Information Processing Society, pp. 117-120.
+ * https://dl.acm.org/citation.cfm?id=2305296
+ *
+ * The Protractor enhancement was separately published by Yang Li and programmed
+ * here by Jacob O. Wobbrock and Lisa Anthony:
+ *
+ * Li, Y. (2010). Protractor: A fast and accurate gesture
+ * recognizer. Proceedings of the ACM Conference on Human
+ * Factors in Computing Systems (CHI '10). Atlanta, Georgia
+ * (April 10-15, 2010). New York: ACM Press, pp. 2169-2172.
+ * https://dl.acm.org/citation.cfm?id=1753654
+ *
+ * This software is distributed under the "New BSD License" agreement:
+ *
+ * Copyright (C) 2007-2011, Jacob O. Wobbrock and Lisa Anthony.
+ * All rights reserved. Last updated July 14, 2018.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * * Neither the names of UMBC nor the University of Washington,
+ * nor the names of its contributors may be used to endorse or promote
+ * products derived from this software without specific prior written
+ * permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+ * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Lisa Anthony OR Jacob O. Wobbrock
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+ * GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+**/
+
+//
+// Point class
+//
+export class Point {
+ constructor(public X: number, public Y: number) { }
+}
+
+//
+// Rectangle class
+//
+export class Rectangle {
+ constructor(public X: number, public Y: number, public Width: number, public Height: number) { }
+}
+
+//
+// Unistroke class: a unistroke template
+//
+export class Unistroke {
+ public Points: Point[];
+ public StartUnitVector: Point;
+ public Vector: number[];
+
+ constructor(public Name: string, useBoundedRotationInvariance: boolean, points: Point[]) {
+ this.Points = Resample(points, NumPoints);
+ const radians = IndicativeAngle(this.Points);
+ this.Points = RotateBy(this.Points, -radians);
+ this.Points = ScaleDimTo(this.Points, SquareSize, OneDThreshold);
+ if (useBoundedRotationInvariance) {
+ this.Points = RotateBy(this.Points, +radians); // restore
+ }
+ this.Points = TranslateTo(this.Points, Origin);
+ this.StartUnitVector = CalcStartUnitVector(this.Points, StartAngleIndex);
+ this.Vector = Vectorize(this.Points, useBoundedRotationInvariance); // for Protractor
+ }
+}
+//
+// Multistroke class: a container for unistrokes
+//
+export class Multistroke {
+ public NumStrokes: number;
+ public Unistrokes: Unistroke[];
+
+ constructor(public Name: string, useBoundedRotationInvariance: boolean, strokes: any[]) // constructor
+ {
+ this.NumStrokes = strokes.length; // number of individual strokes
+
+ const order = new Array(strokes.length); // array of integer indices
+ for (var i = 0; i < strokes.length; i++) {
+ order[i] = i; // initialize
+ }
+ const orders = new Array(); // array of integer arrays
+ HeapPermute(strokes.length, order, /*out*/ orders);
+
+ const unistrokes = MakeUnistrokes(strokes, orders); // returns array of point arrays
+ this.Unistrokes = new Array(unistrokes.length); // unistrokes for this multistroke
+ for (var j = 0; j < unistrokes.length; j++) {
+ this.Unistrokes[j] = new Unistroke(this.Name, useBoundedRotationInvariance, unistrokes[j]);
+ }
+ }
+}
+
+//
+// Result class
+//
+export class Result {
+ constructor(public Name: string, public Score: any, public Time: any) { }
+}
+
+//
+// NDollarRecognizer constants
+//
+const NumMultistrokes = 2;
+const NumPoints = 96;
+const SquareSize = 250.0;
+const OneDThreshold = 0.25; // customize to desired gesture set (usually 0.20 - 0.35)
+const Origin = new Point(0, 0);
+const Diagonal = Math.sqrt(SquareSize * SquareSize + SquareSize * SquareSize);
+const HalfDiagonal = 0.5 * Diagonal;
+const AngleRange = Deg2Rad(45.0);
+const AnglePrecision = Deg2Rad(2.0);
+const Phi = 0.5 * (-1.0 + Math.sqrt(5.0)); // Golden Ratio
+const StartAngleIndex = (NumPoints / 8); // eighth of gesture length
+const AngleSimilarityThreshold = Deg2Rad(30.0);
+
+//
+// NDollarRecognizer class
+//
+export class NDollarRecognizer {
+ public Multistrokes: Multistroke[];
+
+ constructor(useBoundedRotationInvariance: boolean) // constructor
+ {
+ //
+ // one predefined multistroke for each multistroke type
+ //
+ this.Multistrokes = new Array(NumMultistrokes);
+ this.Multistrokes[0] = new Multistroke(GestureUtils.Gestures.Box, useBoundedRotationInvariance, new Array(
+ new Array(new Point(30, 146), new Point(30, 222), new Point(106, 225), new Point(106, 146), new Point(30, 146))
+ ));
+ this.Multistrokes[1] = new Multistroke(GestureUtils.Gestures.Line, useBoundedRotationInvariance, new Array(
+ new Array(new Point(12, 347), new Point(119, 347))
+ ));
+
+ //
+ // PREDEFINED STROKES
+ //
+
+ // this.Multistrokes[0] = new Multistroke("T", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(30, 7), new Point(103, 7)),
+ // new Array(new Point(66, 7), new Point(66, 87))
+ // ));
+ // this.Multistrokes[1] = new Multistroke("N", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(177, 92), new Point(177, 2)),
+ // new Array(new Point(182, 1), new Point(246, 95)),
+ // new Array(new Point(247, 87), new Point(247, 1))
+ // ));
+ // this.Multistrokes[2] = new Multistroke("D", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(345, 9), new Point(345, 87)),
+ // new Array(new Point(351, 8), new Point(363, 8), new Point(372, 9), new Point(380, 11), new Point(386, 14), new Point(391, 17), new Point(394, 22), new Point(397, 28), new Point(399, 34), new Point(400, 42), new Point(400, 50), new Point(400, 56), new Point(399, 61), new Point(397, 66), new Point(394, 70), new Point(391, 74), new Point(386, 78), new Point(382, 81), new Point(377, 83), new Point(372, 85), new Point(367, 87), new Point(360, 87), new Point(355, 88), new Point(349, 87))
+ // ));
+ // this.Multistrokes[3] = new Multistroke("P", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(507, 8), new Point(507, 87)),
+ // new Array(new Point(513, 7), new Point(528, 7), new Point(537, 8), new Point(544, 10), new Point(550, 12), new Point(555, 15), new Point(558, 18), new Point(560, 22), new Point(561, 27), new Point(562, 33), new Point(561, 37), new Point(559, 42), new Point(556, 45), new Point(550, 48), new Point(544, 51), new Point(538, 53), new Point(532, 54), new Point(525, 55), new Point(519, 55), new Point(513, 55), new Point(510, 55))
+ // ));
+ // this.Multistrokes[4] = new Multistroke("X", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(30, 146), new Point(106, 222)),
+ // new Array(new Point(30, 225), new Point(106, 146))
+ // ));
+ // this.Multistrokes[5] = new Multistroke("H", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(188, 137), new Point(188, 225)),
+ // new Array(new Point(188, 180), new Point(241, 180)),
+ // new Array(new Point(241, 137), new Point(241, 225))
+ // ));
+ // this.Multistrokes[6] = new Multistroke("I", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(371, 149), new Point(371, 221)),
+ // new Array(new Point(341, 149), new Point(401, 149)),
+ // new Array(new Point(341, 221), new Point(401, 221))
+ // ));
+ // this.Multistrokes[7] = new Multistroke("exclamation", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(526, 142), new Point(526, 204)),
+ // new Array(new Point(526, 221))
+ // ));
+ // this.Multistrokes[9] = new Multistroke("five-point star", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(177, 396), new Point(223, 299), new Point(262, 396), new Point(168, 332), new Point(278, 332), new Point(184, 397))
+ // ));
+ // this.Multistrokes[10] = new Multistroke("null", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(382, 310), new Point(377, 308), new Point(373, 307), new Point(366, 307), new Point(360, 310), new Point(356, 313), new Point(353, 316), new Point(349, 321), new Point(347, 326), new Point(344, 331), new Point(342, 337), new Point(341, 343), new Point(341, 350), new Point(341, 358), new Point(342, 362), new Point(344, 366), new Point(347, 370), new Point(351, 374), new Point(356, 379), new Point(361, 382), new Point(368, 385), new Point(374, 387), new Point(381, 387), new Point(390, 387), new Point(397, 385), new Point(404, 382), new Point(408, 378), new Point(412, 373), new Point(416, 367), new Point(418, 361), new Point(419, 353), new Point(418, 346), new Point(417, 341), new Point(416, 336), new Point(413, 331), new Point(410, 326), new Point(404, 320), new Point(400, 317), new Point(393, 313), new Point(392, 312)),
+ // new Array(new Point(418, 309), new Point(337, 390))
+ // ));
+ // this.Multistrokes[11] = new Multistroke("arrowhead", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(506, 349), new Point(574, 349)),
+ // new Array(new Point(525, 306), new Point(584, 349), new Point(525, 388))
+ // ));
+ // this.Multistrokes[12] = new Multistroke("pitchfork", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(38, 470), new Point(36, 476), new Point(36, 482), new Point(37, 489), new Point(39, 496), new Point(42, 500), new Point(46, 503), new Point(50, 507), new Point(56, 509), new Point(63, 509), new Point(70, 508), new Point(75, 506), new Point(79, 503), new Point(82, 499), new Point(85, 493), new Point(87, 487), new Point(88, 480), new Point(88, 474), new Point(87, 468)),
+ // new Array(new Point(62, 464), new Point(62, 571))
+ // ));
+ // this.Multistrokes[13] = new Multistroke("six-point star", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(177, 554), new Point(223, 476), new Point(268, 554), new Point(183, 554)),
+ // new Array(new Point(177, 490), new Point(223, 568), new Point(268, 490), new Point(183, 490))
+ // ));
+ // this.Multistrokes[14] = new Multistroke("asterisk", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(325, 499), new Point(417, 557)),
+ // new Array(new Point(417, 499), new Point(325, 557)),
+ // new Array(new Point(371, 486), new Point(371, 571))
+ // ));
+ // this.Multistrokes[15] = new Multistroke("half-note", useBoundedRotationInvariance, new Array(
+ // new Array(new Point(546, 465), new Point(546, 531)),
+ // new Array(new Point(540, 530), new Point(536, 529), new Point(533, 528), new Point(529, 529), new Point(524, 530), new Point(520, 532), new Point(515, 535), new Point(511, 539), new Point(508, 545), new Point(506, 548), new Point(506, 554), new Point(509, 558), new Point(512, 561), new Point(517, 564), new Point(521, 564), new Point(527, 563), new Point(531, 560), new Point(535, 557), new Point(538, 553), new Point(542, 548), new Point(544, 544), new Point(546, 540), new Point(546, 536))
+ // ));
+ //
+ // The $N Gesture Recognizer API begins here -- 3 methods: Recognize(), AddGesture(), and DeleteUserGestures()
+ //
+ }
+
+ Recognize = (strokes: any[], useBoundedRotationInvariance: boolean = false, requireSameNoOfStrokes: boolean = false, useProtractor: boolean = true) => {
+ const t0 = Date.now();
+ const points = CombineStrokes(strokes); // make one connected unistroke from the given strokes
+ const candidate = new Unistroke("", useBoundedRotationInvariance, points);
+
+ var u = -1;
+ var b = +Infinity;
+ for (var i = 0; i < this.Multistrokes.length; i++) // for each multistroke template
+ {
+ if (!requireSameNoOfStrokes || strokes.length === this.Multistrokes[i].NumStrokes) // optional -- only attempt match when same # of component strokes
+ {
+ for (var j = 0; j < this.Multistrokes[i].Unistrokes.length; j++) // for each unistroke within this multistroke
+ {
+ if (AngleBetweenUnitVectors(candidate.StartUnitVector, this.Multistrokes[i].Unistrokes[j].StartUnitVector) <= AngleSimilarityThreshold) // strokes start in the same direction
+ {
+ var d;
+ if (useProtractor) {
+ d = OptimalCosineDistance(this.Multistrokes[i].Unistrokes[j].Vector, candidate.Vector); // Protractor
+ }
+ else {
+ d = DistanceAtBestAngle(candidate.Points, this.Multistrokes[i].Unistrokes[j], -AngleRange, +AngleRange, AnglePrecision); // Golden Section Search (original $N)
+ }
+ if (d < b) {
+ b = d; // best (least) distance
+ u = i; // multistroke owner of unistroke
+ }
+ }
+ }
+ }
+ }
+ const t1 = Date.now();
+ return (u === -1) ? null : new Result(this.Multistrokes[u].Name, useProtractor ? (1.0 - b) : (1.0 - b / HalfDiagonal), t1 - t0);
+ }
+
+ AddGesture = (name: string, useBoundedRotationInvariance: boolean, strokes: any[]) => {
+ this.Multistrokes[this.Multistrokes.length] = new Multistroke(name, useBoundedRotationInvariance, strokes);
+ var num = 0;
+ for (var i = 0; i < this.Multistrokes.length; i++) {
+ if (this.Multistrokes[i].Name === name) {
+ num++;
+ }
+ }
+ return num;
+ }
+
+ DeleteUserGestures = () => {
+ this.Multistrokes.length = NumMultistrokes; // clear any beyond the original set
+ return NumMultistrokes;
+ }
+}
+
+
+//
+// Private helper functions from here on down
+//
+function HeapPermute(n: number, order: any[], /*out*/ orders: any[]) {
+ if (n === 1) {
+ orders[orders.length] = order.slice(); // append copy
+ } else {
+ for (var i = 0; i < n; i++) {
+ HeapPermute(n - 1, order, orders);
+ if (n % 2 === 1) { // swap 0, n-1
+ const tmp = order[0];
+ order[0] = order[n - 1];
+ order[n - 1] = tmp;
+ } else { // swap i, n-1
+ const tmp = order[i];
+ order[i] = order[n - 1];
+ order[n - 1] = tmp;
+ }
+ }
+ }
+}
+
+function MakeUnistrokes(strokes: any, orders: any) {
+ const unistrokes = new Array(); // array of point arrays
+ for (var r = 0; r < orders.length; r++) {
+ for (var b = 0; b < Math.pow(2, orders[r].length); b++) // use b's bits for directions
+ {
+ const unistroke = new Array(); // array of points
+ for (var i = 0; i < orders[r].length; i++) {
+ var pts;
+ if (((b >> i) & 1) === 1) {// is b's bit at index i on?
+ pts = strokes[orders[r][i]].slice().reverse(); // copy and reverse
+ }
+ else {
+ pts = strokes[orders[r][i]].slice(); // copy
+ }
+ for (var p = 0; p < pts.length; p++) {
+ unistroke[unistroke.length] = pts[p]; // append points
+ }
+ }
+ unistrokes[unistrokes.length] = unistroke; // add one unistroke to set
+ }
+ }
+ return unistrokes;
+}
+
+function CombineStrokes(strokes: any) {
+ const points = new Array();
+ for (var s = 0; s < strokes.length; s++) {
+ for (var p = 0; p < strokes[s].length; p++) {
+ points[points.length] = new Point(strokes[s][p].X, strokes[s][p].Y);
+ }
+ }
+ return points;
+}
+function Resample(points: any, n: any) {
+ const I = PathLength(points) / (n - 1); // interval length
+ var D = 0.0;
+ const newpoints = new Array(points[0]);
+ for (var i = 1; i < points.length; i++) {
+ const d = Distance(points[i - 1], points[i]);
+ if ((D + d) >= I) {
+ const qx = points[i - 1].X + ((I - D) / d) * (points[i].X - points[i - 1].X);
+ const qy = points[i - 1].Y + ((I - D) / d) * (points[i].Y - points[i - 1].Y);
+ const q = new Point(qx, qy);
+ newpoints[newpoints.length] = q; // append new point 'q'
+ points.splice(i, 0, q); // insert 'q' at position i in points s.t. 'q' will be the next i
+ D = 0.0;
+ }
+ else D += d;
+ }
+ if (newpoints.length === n - 1) {// sometimes we fall a rounding-error short of adding the last point, so add it if so
+ newpoints[newpoints.length] = new Point(points[points.length - 1].X, points[points.length - 1].Y);
+ }
+ return newpoints;
+}
+function IndicativeAngle(points: any) {
+ const c = Centroid(points);
+ return Math.atan2(c.Y - points[0].Y, c.X - points[0].X);
+}
+function RotateBy(points: any, radians: any) // rotates points around centroid
+{
+ const c = Centroid(points);
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ const newpoints = new Array();
+ for (var i = 0; i < points.length; i++) {
+ const qx = (points[i].X - c.X) * cos - (points[i].Y - c.Y) * sin + c.X;
+ const qy = (points[i].X - c.X) * sin + (points[i].Y - c.Y) * cos + c.Y;
+ newpoints[newpoints.length] = new Point(qx, qy);
+ }
+ return newpoints;
+}
+function ScaleDimTo(points: any, size: any, ratio1D: any) // scales bbox uniformly for 1D, non-uniformly for 2D
+{
+ const B = BoundingBox(points);
+ const uniformly = Math.min(B.Width / B.Height, B.Height / B.Width) <= ratio1D; // 1D or 2D gesture test
+ const newpoints = new Array();
+ for (var i = 0; i < points.length; i++) {
+ const qx = uniformly ? points[i].X * (size / Math.max(B.Width, B.Height)) : points[i].X * (size / B.Width);
+ const qy = uniformly ? points[i].Y * (size / Math.max(B.Width, B.Height)) : points[i].Y * (size / B.Height);
+ newpoints[newpoints.length] = new Point(qx, qy);
+ }
+ return newpoints;
+}
+function TranslateTo(points: any, pt: any) // translates points' centroid
+{
+ const c = Centroid(points);
+ const newpoints = new Array();
+ for (var i = 0; i < points.length; i++) {
+ const qx = points[i].X + pt.X - c.X;
+ const qy = points[i].Y + pt.Y - c.Y;
+ newpoints[newpoints.length] = new Point(qx, qy);
+ }
+ return newpoints;
+}
+function Vectorize(points: any, useBoundedRotationInvariance: any) // for Protractor
+{
+ var cos = 1.0;
+ var sin = 0.0;
+ if (useBoundedRotationInvariance) {
+ const iAngle = Math.atan2(points[0].Y, points[0].X);
+ const baseOrientation = (Math.PI / 4.0) * Math.floor((iAngle + Math.PI / 8.0) / (Math.PI / 4.0));
+ cos = Math.cos(baseOrientation - iAngle);
+ sin = Math.sin(baseOrientation - iAngle);
+ }
+ var sum = 0.0;
+ const vector = new Array<number>();
+ for (var i = 0; i < points.length; i++) {
+ const newX = points[i].X * cos - points[i].Y * sin;
+ const newY = points[i].Y * cos + points[i].X * sin;
+ vector[vector.length] = newX;
+ vector[vector.length] = newY;
+ sum += newX * newX + newY * newY;
+ }
+ const magnitude = Math.sqrt(sum);
+ for (var i = 0; i < vector.length; i++) {
+ vector[i] /= magnitude;
+ }
+ return vector;
+}
+function OptimalCosineDistance(v1: any, v2: any) // for Protractor
+{
+ var a = 0.0;
+ var b = 0.0;
+ for (var i = 0; i < v1.length; i += 2) {
+ a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1];
+ b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i];
+ }
+ const angle = Math.atan(b / a);
+ return Math.acos(a * Math.cos(angle) + b * Math.sin(angle));
+}
+function DistanceAtBestAngle(points: any, T: any, a: any, b: any, threshold: any) {
+ var x1 = Phi * a + (1.0 - Phi) * b;
+ var f1 = DistanceAtAngle(points, T, x1);
+ var x2 = (1.0 - Phi) * a + Phi * b;
+ var f2 = DistanceAtAngle(points, T, x2);
+ while (Math.abs(b - a) > threshold) {
+ if (f1 < f2) {
+ b = x2;
+ x2 = x1;
+ f2 = f1;
+ x1 = Phi * a + (1.0 - Phi) * b;
+ f1 = DistanceAtAngle(points, T, x1);
+ } else {
+ a = x1;
+ x1 = x2;
+ f1 = f2;
+ x2 = (1.0 - Phi) * a + Phi * b;
+ f2 = DistanceAtAngle(points, T, x2);
+ }
+ }
+ return Math.min(f1, f2);
+}
+function DistanceAtAngle(points: any, T: any, radians: any) {
+ const newpoints = RotateBy(points, radians);
+ return PathDistance(newpoints, T.Points);
+}
+function Centroid(points: any) {
+ var x = 0.0, y = 0.0;
+ for (var i = 0; i < points.length; i++) {
+ x += points[i].X;
+ y += points[i].Y;
+ }
+ x /= points.length;
+ y /= points.length;
+ return new Point(x, y);
+}
+function BoundingBox(points: any) {
+ var minX = +Infinity, maxX = -Infinity, minY = +Infinity, maxY = -Infinity;
+ for (var i = 0; i < points.length; i++) {
+ minX = Math.min(minX, points[i].X);
+ minY = Math.min(minY, points[i].Y);
+ maxX = Math.max(maxX, points[i].X);
+ maxY = Math.max(maxY, points[i].Y);
+ }
+ return new Rectangle(minX, minY, maxX - minX, maxY - minY);
+}
+function PathDistance(pts1: any, pts2: any) // average distance between corresponding points in two paths
+{
+ var d = 0.0;
+ for (var i = 0; i < pts1.length; i++) {// assumes pts1.length == pts2.length
+ d += Distance(pts1[i], pts2[i]);
+ }
+ return d / pts1.length;
+}
+function PathLength(points: any) // length traversed by a point path
+{
+ var d = 0.0;
+ for (var i = 1; i < points.length; i++) {
+ d += Distance(points[i - 1], points[i]);
+ }
+ return d;
+}
+function Distance(p1: any, p2: any) // distance between two points
+{
+ const dx = p2.X - p1.X;
+ const dy = p2.Y - p1.Y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+function CalcStartUnitVector(points: any, index: any) // start angle from points[0] to points[index] normalized as a unit vector
+{
+ const v = new Point(points[index].X - points[0].X, points[index].Y - points[0].Y);
+ const len = Math.sqrt(v.X * v.X + v.Y * v.Y);
+ return new Point(v.X / len, v.Y / len);
+}
+function AngleBetweenUnitVectors(v1: any, v2: any) // gives acute angle between unit vectors from (0,0) to v1, and (0,0) to v2
+{
+ const n = (v1.X * v2.X + v1.Y * v2.Y);
+ const c = Math.max(-1.0, Math.min(1.0, n)); // ensure [-1,+1]
+ return Math.acos(c); // arc cosine of the vector dot product
+}
+function Deg2Rad(d: any) { return (d * Math.PI / 180.0); } \ No newline at end of file
diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts
index a93566fb1..f0bfbc525 100644
--- a/src/server/ActionUtilities.ts
+++ b/src/server/ActionUtilities.ts
@@ -6,6 +6,7 @@ import * as rimraf from "rimraf";
import { yellow, Color } from 'colors';
import * as nodemailer from "nodemailer";
import { MailOptions } from "nodemailer/lib/json-transport";
+import Mail = require('nodemailer/lib/mailer');
const projectRoot = path.resolve(__dirname, "../../");
export function pathFromRoot(relative?: string) {
@@ -137,12 +138,13 @@ export namespace Email {
return failures.length ? failures : undefined;
}
- export async function dispatch(recipient: string, subject: string, content: string): Promise<Error | null> {
+ export async function dispatch(recipient: string, subject: string, content: string, attachments?: Mail.Attachment[]): Promise<Error | null> {
const mailOptions = {
to: recipient,
from: 'brownptcdash@gmail.com',
subject,
- text: `Hello ${recipient.split("@")[0]},\n\n${content}`
+ text: `Hello ${recipient.split("@")[0]},\n\n${content}`,
+ attachments
} as MailOptions;
return new Promise<Error | null>(resolve => {
smtpTransport.sendMail(mailOptions, resolve);
diff --git a/src/server/ApiManagers/SearchManager.ts b/src/server/ApiManagers/SearchManager.ts
index c1c908088..4ce12f9f3 100644
--- a/src/server/ApiManagers/SearchManager.ts
+++ b/src/server/ApiManagers/SearchManager.ts
@@ -4,11 +4,11 @@ import { Search } from "../Search";
const findInFiles = require('find-in-files');
import * as path from 'path';
import { pathToDirectory, Directory } from "./UploadManager";
-import { command_line } from "../ActionUtilities";
-import request = require('request-promise');
-import { red } from "colors";
+import { red, cyan, yellow } from "colors";
import RouteSubscriber from "../RouteSubscriber";
-import { execSync } from "child_process";
+import { exec } from "child_process";
+import { onWindows } from "..";
+import { get } from "request-promise";
export class SearchManager extends ApiManager {
@@ -69,15 +69,23 @@ export class SearchManager extends ApiManager {
export namespace SolrManager {
+ const command = onWindows ? "solr.cmd" : "solr";
+
export async function SetRunning(status: boolean): Promise<boolean> {
const args = status ? "start" : "stop -p 8983";
+ console.log(`solr management: trying to ${args}`);
+ exec(`${command} ${args}`, { cwd: "./solr-8.3.1/bin" }, (error, stdout, stderr) => {
+ if (error) {
+ console.log(red(`solr management error: unable to ${args} server`));
+ console.log(red(error.message));
+ }
+ console.log(cyan(stdout));
+ console.log(yellow(stderr));
+ });
try {
- console.log(`Solr management: trying to ${args}`);
- console.log(execSync(`./solr.cmd ${args}`, { cwd: "./solr-8.3.1/bin" }));
+ await get("http://localhost:8983");
return true;
- } catch (e) {
- console.log(red(`Solr management error: unable to ${args}`));
- console.log(e);
+ } catch {
return false;
}
}
diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts
new file mode 100644
index 000000000..0290b578c
--- /dev/null
+++ b/src/server/ApiManagers/SessionManager.ts
@@ -0,0 +1,62 @@
+import ApiManager, { Registration } from "./ApiManager";
+import { Method, _permission_denied, AuthorizedCore, SecureHandler } from "../RouteManager";
+import RouteSubscriber from "../RouteSubscriber";
+import { sessionAgent } from "..";
+
+const permissionError = "You are not authorized!";
+
+export default class SessionManager extends ApiManager {
+
+ private secureSubscriber = (root: string, ...params: string[]) => new RouteSubscriber(root).add("password", ...params);
+
+ private authorizedAction = (handler: SecureHandler) => {
+ return (core: AuthorizedCore) => {
+ const { req, res, isRelease } = core;
+ const { password } = req.params;
+ if (!isRelease) {
+ return res.send("This can be run only on the release server.");
+ }
+ if (password !== process.env.session_key) {
+ return _permission_denied(res, permissionError);
+ }
+ handler(core);
+ };
+ }
+
+ protected initialize(register: Registration): void {
+
+ register({
+ method: Method.GET,
+ subscription: this.secureSubscriber("debug", "mode", "recipient"),
+ secureHandler: this.authorizedAction(({ req, res }) => {
+ const { mode, recipient } = req.params;
+ if (["passive", "active"].includes(mode)) {
+ sessionAgent.serverWorker.sendMonitorAction("debug", { mode, recipient });
+ res.send(`Your request was successful: the server is ${mode === "active" ? "creating and compressing a new" : "retrieving and compressing the most recent"} back up. It will be sent to ${recipient}.`);
+ } else {
+ res.send(`Your request failed. '${mode}' is not a valid mode: please choose either 'active' or 'passive'`);
+ }
+ })
+ });
+
+ register({
+ method: Method.GET,
+ subscription: this.secureSubscriber("backup"),
+ secureHandler: this.authorizedAction(({ res }) => {
+ sessionAgent.serverWorker.sendMonitorAction("backup");
+ res.send(`Your request was successful: the server is creating a new back up.`);
+ })
+ });
+
+ register({
+ method: Method.GET,
+ subscription: this.secureSubscriber("kill"),
+ secureHandler: this.authorizedAction(({ res }) => {
+ res.send("Your request was successful: the server and its session have been killed.");
+ sessionAgent.killSession("an authorized user has manually ended the server session via the /kill route");
+ })
+ });
+
+ }
+
+} \ No newline at end of file
diff --git a/src/server/DashSession.ts b/src/server/DashSession.ts
index 9c36fa17f..56610874e 100644
--- a/src/server/DashSession.ts
+++ b/src/server/DashSession.ts
@@ -1,60 +1,138 @@
import { Session } from "./Session/session";
-import { Email } from "./ActionUtilities";
-import { red, yellow } from "colors";
-import { SolrManager } from "./ApiManagers/SearchManager";
-import { execSync } from "child_process";
-import { isMaster } from "cluster";
+import { Email, pathFromRoot } from "./ActionUtilities";
+import { red, yellow, green, cyan } from "colors";
+import { get } from "request-promise";
import { Utils } from "../Utils";
import { WebSocket } from "./Websocket/Websocket";
import { MessageStore } from "./Message";
-import { launchServer } from ".";
-
-const notificationRecipients = ["samuel_wilkins@brown.edu"];
-const signature = "-Dash Server Session Manager";
-
-const monitorHooks: Session.MonitorNotifierHooks = {
- key: async (key, masterLog) => {
- const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${signature}`;
- const failures = await Email.dispatchAll(notificationRecipients, "Server Termination Key", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => masterLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
+import { launchServer, onWindows } from ".";
+import { existsSync, mkdirSync, readdirSync, statSync, createWriteStream, readFileSync } from "fs";
+import * as Archiver from "archiver";
+import { resolve } from "path";
+
+/**
+ * If we're the monitor (master) thread, we should launch the monitor logic for the session.
+ * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
+ * our job should be to run the server.
+ */
+export class DashSessionAgent extends Session.AppliedSessionAgent {
+
+ private readonly notificationRecipients = ["samuel_wilkins@brown.edu"];
+ private readonly signature = "-Dash Server Session Manager";
+ private readonly releaseDesktop = pathFromRoot("../../Desktop");
+
+ protected async launchMonitor() {
+ const monitor = Session.Monitor.Create(this.notifiers);
+ monitor.addReplCommand("pull", [], () => monitor.exec("git pull"));
+ monitor.addReplCommand("solr", [/start|stop|index/], this.executeSolrCommand);
+ monitor.addReplCommand("backup", [], this.backup);
+ monitor.addReplCommand("debug", [/active|passive/, /\S+\@\S+/], async ([mode, recipient]) => this.dispatchZippedDebugBackup(mode, recipient));
+ monitor.addServerMessageListener("backup", this.backup);
+ monitor.addServerMessageListener("debug", ({ args: { mode, recipient } }) => this.dispatchZippedDebugBackup(mode, recipient));
+ return monitor;
+ }
+
+ protected async launchServerWorker() {
+ const worker = Session.ServerWorker.Create(launchServer); // server initialization delegated to worker
+ worker.addExitHandler(this.notifyClient);
+ return worker;
+ }
+
+ private readonly notifiers: Session.Monitor.NotifierHooks = {
+ key: async key => {
+ // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
+ // to kill the server via the /kill/:key route
+ const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${this.signature}`;
+ const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Release Session Admin Authentication Key", content);
+ if (failures) {
+ failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
+ return false;
+ }
+ return true;
+ },
+ crash: async ({ name, message, stack }) => {
+ const body = [
+ "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
+ `name:\n${name}`,
+ `message:\n${message}`,
+ `stack:\n${stack}`,
+ "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.",
+ ].join("\n\n");
+ const content = `${body}\n\n${this.signature}`;
+ const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Web Server Crash", content);
+ if (failures) {
+ failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
+ return false;
+ }
+ return true;
}
- return true;
- },
- crash: async ({ name, message, stack }, masterLog) => {
- const body = [
- "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
- `name:\n${name}`,
- `message:\n${message}`,
- `stack:\n${stack}`,
- "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.",
- ].join("\n\n");
- const content = `${body}\n\n${signature}`;
- const failures = await Email.dispatchAll(notificationRecipients, "Dash Web Server Crash", content);
- if (failures) {
- failures.map(({ recipient, error: { message } }) => masterLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`)));
- return false;
+ };
+
+ private executeSolrCommand = async (args: string[]) => {
+ const { exec, mainLog } = this.sessionMonitor;
+ const action = args[0];
+ if (action === "index") {
+ exec("npx ts-node ./updateSearch.ts", { cwd: pathFromRoot("./src/server") });
+ } else {
+ const command = `${onWindows ? "solr.cmd" : "solr"} ${args[0] === "start" ? "start" : "stop -p 8983"}`;
+ await exec(command, { cwd: "./solr-8.3.1/bin" });
+ try {
+ await get("http://localhost:8983");
+ mainLog(green("successfully connected to 8983 after running solr initialization"));
+ } catch {
+ mainLog(red("unable to connect at 8983 after running solr initialization"));
+ }
}
- return true;
}
-};
-export class DashSessionAgent extends Session.AppliedSessionAgent {
+ private notifyClient: Session.ExitHandler = reason => {
+ const { _socket } = WebSocket;
+ if (_socket) {
+ const message = typeof reason === "boolean" ? (reason ? "exit" : "temporary") : "crash";
+ Utils.Emit(_socket, MessageStore.ConnectionTerminated, message);
+ }
+ }
- /**
- * If we're the monitor (master) thread, we should launch the monitor logic for the session.
- * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
- * our job should be to run the server.
- */
- protected async launchImplementation() {
- if (isMaster) {
- this.sessionMonitor = await Session.initializeMonitorThread(monitorHooks);
- this.sessionMonitor.addReplCommand("pull", [], () => execSync("git pull", { stdio: ["ignore", "inherit", "inherit"] }));
- this.sessionMonitor.addReplCommand("solr", [/start|stop/g], args => SolrManager.SetRunning(args[0] === "start"));
- } else {
- this.serverWorker = await Session.initializeWorkerThread(launchServer); // server initialization delegated to worker
- this.serverWorker.addExitHandler(() => Utils.Emit(WebSocket._socket, MessageStore.ConnectionTerminated, "Manual"));
+ private backup = async () => this.sessionMonitor.exec("backup.bat", { cwd: this.releaseDesktop });
+
+ private async dispatchZippedDebugBackup(mode: string, recipient: string) {
+ const { mainLog } = this.sessionMonitor;
+ try {
+ if (mode === "active") {
+ await this.backup();
+ }
+ mainLog("backup complete");
+ const backupsDirectory = `${this.releaseDesktop}/backups`;
+ const compressedDirectory = `${this.releaseDesktop}/compressed`;
+ if (!existsSync(compressedDirectory)) {
+ mkdirSync(compressedDirectory);
+ }
+ const target = readdirSync(backupsDirectory).map(filename => ({
+ modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs,
+ filename
+ })).sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename;
+ mainLog(`targeting ${target}...`);
+ const zipName = `${target}.zip`;
+ const zipPath = `${compressedDirectory}/${zipName}`;
+ const output = createWriteStream(zipPath);
+ const zip = Archiver('zip');
+ zip.pipe(output);
+ zip.directory(`${backupsDirectory}/${target}/Dash`, false);
+ await zip.finalize();
+ mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`);
+ let instructions = readFileSync(resolve(__dirname, "./remote_debug_instructions.txt"), { encoding: "utf8" });
+ instructions = instructions.replace(/__zipname__/, zipName).replace(/__target__/, target).replace(/__signature__/, this.signature);
+ const error = await Email.dispatch(recipient, `Compressed backup of ${target}...`, instructions, [
+ {
+ filename: zipName,
+ path: zipPath
+ }
+ ]);
+ mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(recipient)}`);
+ error && mainLog(red(error.message));
+ } catch (error) {
+ mainLog(red("unable to dispatch zipped backup..."));
+ mainLog(red(error.message));
}
}
diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts
index 75bf5f3b1..35d5131a4 100644
--- a/src/server/RouteManager.ts
+++ b/src/server/RouteManager.ts
@@ -14,7 +14,8 @@ export interface CoreArguments {
isRelease: boolean;
}
-export type SecureHandler = (core: CoreArguments & { user: DashUserModel }) => any | Promise<any>;
+export type AuthorizedCore = CoreArguments & { user: DashUserModel };
+export type SecureHandler = (core: AuthorizedCore) => any | Promise<any>;
export type PublicHandler = (core: CoreArguments) => any | Promise<any>;
export type ErrorHandler = (core: CoreArguments & { error: any }) => any | Promise<any>;
diff --git a/src/server/Search.ts b/src/server/Search.ts
index 2b59c14b1..21064e520 100644
--- a/src/server/Search.ts
+++ b/src/server/Search.ts
@@ -1,4 +1,5 @@
import * as rp from 'request-promise';
+import { red } from 'colors';
const pathTo = (relative: string) => `http://localhost:8983/solr/dash/${relative}`;
@@ -43,7 +44,7 @@ export namespace Search {
export async function clear() {
try {
- return rp.post(pathTo("update"), {
+ await rp.post(pathTo("update"), {
body: {
delete: {
query: "*:*"
@@ -51,7 +52,10 @@ export namespace Search {
},
json: true
});
- } catch { }
+ } catch (e) {
+ console.log(red("Unable to clear search..."));
+ console.log(red(e.message));
+ }
}
export async function deleteDocuments(docs: string[]) {
diff --git a/src/server/Session/session.ts b/src/server/Session/session.ts
index a3e6c4e16..ec3d46ac1 100644
--- a/src/server/Session/session.ts
+++ b/src/server/Session/session.ts
@@ -1,439 +1,686 @@
-import { red, cyan, green, yellow, magenta, blue } from "colors";
-import { on, fork, setupMaster, Worker, isMaster } from "cluster";
+import { red, cyan, green, yellow, magenta, blue, white, Color, grey, gray, black } from "colors";
+import { on, fork, setupMaster, Worker, isMaster, isWorker } from "cluster";
import { get } from "request-promise";
import { Utils } from "../../Utils";
import Repl, { ReplAction } from "../repl";
import { readFileSync } from "fs";
import { validate, ValidationError } from "jsonschema";
import { configurationSchema } from "./session_config_schema";
+import { exec, ExecOptions } from "child_process";
/**
- * This namespace relies on NodeJS's cluster module, which allows a parent (master) process to share
- * code with its children (workers). A simple `isMaster` flag indicates who is trying to access
- * the code, and thus determines the functionality that actually gets invoked (checked by the caller, not internally).
- *
- * Think of the master thread as a factory, and the workers as the helpers that actually run the server.
- *
- * So, when we run `npm start`, given the appropriate check, initializeMaster() is called in the parent process
- * This will spawn off its own child process (by default, mirrors the execution path of its parent),
- * in which initializeWorker() is invoked.
- */
+ * This namespace relies on NodeJS's cluster module, which allows a parent (master) process to share
+ * code with its children (workers). A simple `isMaster` flag indicates who is trying to access
+ * the code, and thus determines the functionality that actually gets invoked (checked by the caller, not internally).
+ *
+ * Think of the master thread as a factory, and the workers as the helpers that actually run the server.
+ *
+ * So, when we run `npm start`, given the appropriate check, initializeMaster() is called in the parent process
+ * This will spawn off its own child process (by default, mirrors the execution path of its parent),
+ * in which initializeWorker() is invoked.
+ */
export namespace Session {
+ type ColorLabel = "yellow" | "red" | "cyan" | "green" | "blue" | "magenta" | "grey" | "gray" | "white" | "black";
+ 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]
+ ]);
+
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 launchMonitor(): Promise<Session.Monitor>;
+ protected abstract async launchServerWorker(): Promise<Session.ServerWorker>;
+
private launched = false;
- protected sessionMonitorRef: Session.Monitor | undefined;
+ public killSession = (reason: string, graceful = true, errorCode = 0) => {
+ const target = isMaster ? this.sessionMonitor : this.serverWorker;
+ target.killSession(reason, graceful, errorCode);
+ }
+
+ private sessionMonitorRef: Session.Monitor | undefined;
public get sessionMonitor(): Session.Monitor {
if (!isMaster) {
- throw new Error("Cannot access the session monitor directly from the server worker thread");
+ this.serverWorker.sendMonitorAction("kill", {
+ graceful: false,
+ reason: "Cannot access the session monitor directly from the server worker thread.",
+ errorCode: 1
+ });
+ throw new Error();
}
return this.sessionMonitorRef!;
}
- public set sessionMonitor(monitor: Session.Monitor) {
- if (!isMaster) {
- throw new Error("Cannot set the session monitor directly from the server worker thread");
- }
- this.sessionMonitorRef = monitor;
- }
- protected serverWorkerRef: Session.ServerWorker | undefined;
+ private serverWorkerRef: Session.ServerWorker | undefined;
public get serverWorker(): Session.ServerWorker {
if (isMaster) {
throw new Error("Cannot access the server worker directly from the session monitor thread");
}
return this.serverWorkerRef!;
}
- public set serverWorker(worker: Session.ServerWorker) {
- if (isMaster) {
- throw new Error("Cannot set the server worker directly from the session monitor thread");
- }
- this.serverWorkerRef = worker;
- }
public async launch(): Promise<void> {
if (!this.launched) {
this.launched = true;
- await this.launchImplementation();
+ if (isMaster) {
+ this.sessionMonitorRef = await this.launchMonitor();
+ } else {
+ this.serverWorkerRef = await this.launchServerWorker();
+ }
} else {
throw new Error("Cannot launch a session thread more than once per process.");
}
}
- protected abstract async launchImplementation(): Promise<void>;
+ }
+
+ interface Identifier {
+ text: string;
+ color: ColorLabel;
+ }
+ interface Identifiers {
+ master: Identifier;
+ worker: Identifier;
+ exec: Identifier;
}
interface Configuration {
showServerOutput: boolean;
- masterIdentifier: string;
- workerIdentifier: string;
+ identifiers: Identifiers;
ports: { [description: string]: number };
- pollingRoute: string;
- pollingIntervalSeconds: number;
- pollingFailureTolerance: number;
- [key: string]: any;
+ polling: {
+ route: string;
+ intervalSeconds: number;
+ failureTolerance: number;
+ };
}
- const defaultConfiguration: Configuration = {
+ const defaultConfig: Configuration = {
showServerOutput: false,
- masterIdentifier: yellow("__monitor__:"),
- workerIdentifier: magenta("__server__:"),
+ identifiers: {
+ master: {
+ text: "__monitor__",
+ color: "yellow"
+ },
+ worker: {
+ text: "__server__",
+ color: "magenta"
+ },
+ exec: {
+ text: "__exec__",
+ color: "green"
+ }
+ },
ports: { server: 3000 },
- pollingRoute: "/",
- pollingIntervalSeconds: 30,
- pollingFailureTolerance: 1
+ polling: {
+ route: "/",
+ intervalSeconds: 30,
+ failureTolerance: 0
+ }
};
- export interface Monitor {
- log: (...optionalParams: any[]) => void;
- restartServer: () => void;
- setPort: (port: "server" | "socket" | string, value: number, immediateRestart: boolean) => void;
- killSession: (graceful?: boolean) => never;
- addReplCommand: (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => void;
- addChildMessageHandler: (message: string, handler: ActionHandler) => void;
- }
+ export type ExitHandler = (reason: Error | boolean) => void | Promise<void>;
- export interface ServerWorker {
- killSession: () => void;
- addExitHandler: (handler: ExitHandler) => void;
- }
+ export namespace Monitor {
- export interface MonitorNotifierHooks {
- key?: (key: string, masterLog: (...optionalParams: any[]) => void) => boolean | Promise<boolean>;
- crash?: (error: Error, masterLog: (...optionalParams: any[]) => void) => boolean | Promise<boolean>;
- }
+ export interface NotifierHooks {
+ key?: (key: string) => (boolean | Promise<boolean>);
+ crash?: (error: Error) => (boolean | Promise<boolean>);
+ }
- export interface SessionAction {
- message: string;
- args: any;
- }
+ export interface Action {
+ message: string;
+ args: any;
+ }
+
+ export type ServerMessageHandler = (action: Action) => void | Promise<void>;
- export type ExitHandler = (reason: Error | null) => void | Promise<void>;
- export type ActionHandler = (action: SessionAction) => void | Promise<void>;
- export interface EmailTemplate {
- subject: string;
- body: string;
}
- function loadAndValidateConfiguration(): Configuration {
- try {
- console.log(timestamp(), cyan("validating configuration..."));
- const configuration: Configuration = 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(configuration, configurationSchema, options);
- let formatMaster = true;
- let formatWorker = true;
- Object.keys(defaultConfiguration).forEach(property => {
- if (!configuration[property]) {
- if (property === "masterIdentifier") {
- formatMaster = false;
- } else if (property === "workerIdentifier") {
- formatWorker = false;
+ /**
+ * 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 {
+
+ private static count = 0;
+ private exitHandlers: ExitHandler[] = [];
+ private readonly notifiers: Monitor.NotifierHooks | undefined;
+ private readonly config: Configuration;
+ private onMessage: { [message: string]: Monitor.ServerMessageHandler[] | undefined } = {};
+ private activeWorker: Worker | undefined;
+ private key: string | undefined;
+ private repl: Repl;
+
+ public static Create(notifiers?: Monitor.NotifierHooks) {
+ if (isWorker) {
+ process.send?.({
+ action: {
+ message: "kill",
+ args: {
+ reason: "cannot create a monitor on the worker process.",
+ graceful: false,
+ errorCode: 1
+ }
}
- configuration[property] = defaultConfiguration[property];
- }
+ });
+ process.exit(1);
+ } else if (++Monitor.count > 1) {
+ console.error(red("cannot create more than one monitor."));
+ process.exit(1);
+ } else {
+ return new Monitor(notifiers);
+ }
+ }
+
+ /**
+ * 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);
+ 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();
+ });
});
- if (formatMaster) {
- configuration.masterIdentifier = yellow(configuration.masterIdentifier + ":");
+ }
+
+ /**
+ * Add a listener at this message. When the monitor process
+ * receives a message, it will invoke all registered functions.
+ */
+ public addServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ handlers.push(handler);
+ } else {
+ this.onMessage[message] = [handler];
}
- if (formatWorker) {
- configuration.workerIdentifier = magenta(configuration.workerIdentifier + ":");
+ }
+
+ /**
+ * Unregister a given listener at this message.
+ */
+ public removeServerMessageListener = (message: string, handler: Monitor.ServerMessageHandler) => {
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ }
}
- return configuration;
- } 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.");
- return defaultConfiguration;
- } else {
- console.log(red("\nSession configuration failed."));
- console.log("The following unknown error occurred during configuration.");
- console.log(error.stack);
- process.exit(0);
+ }
+
+ /**
+ * Unregister all listeners at this message.
+ */
+ public clearServerMessageListeners = (message: string) => this.onMessage[message] = undefined;
+
+ private constructor(notifiers?: Monitor.NotifierHooks) {
+ this.notifiers = notifiers;
+
+ console.log(this.timestamp(), cyan("initializing session..."));
+
+ this.config = this.loadAndValidateConfiguration();
+
+ this.initializeSessionKey();
+ // 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"] });
+
+ // 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)}`);
+ }
+ }
+ });
+
+ // 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();
+ });
+
+ this.repl = this.initializeRepl();
+ this.spawn();
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * If the caller has indicated an interest
+ * in being notified of this feature, creates
+ * a GUID for this session that can, for example,
+ * be used as authentication for killing the server
+ * (checked externally).
+ */
+ private initializeSessionKey = async (): Promise<void> => {
+ if (this.notifiers?.key) {
+ this.key = Utils.GenerateGuid();
+ const success = await this.notifiers.key(this.key);
+ const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
+ this.mainLog(statement);
}
}
- }
- function timestamp() {
- return blue(`[${new Date().toUTCString()}]`);
- }
+ /**
+ * 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
+ */
+ private preciseAssign = (target: any, ...sources: any[]): any => {
+ for (const source of sources) {
+ this.preciseAssignHelper(target, source);
+ }
+ return target;
+ }
- /**
- * 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 async function initializeMonitorThread(notifiers?: MonitorNotifierHooks): Promise<Monitor> {
- console.log(timestamp(), cyan("initializing session..."));
- let activeWorker: Worker;
- const childMessageHandlers: { [message: string]: ActionHandler } = {};
-
- // read in configuration .json file only once, in the master thread
- // pass down any variables the pertinent to the child processes as environment variables
- const configuration = loadAndValidateConfiguration();
- const {
- masterIdentifier,
- workerIdentifier,
- ports,
- pollingRoute,
- showServerOutput,
- pollingFailureTolerance
- } = configuration;
- let { pollingIntervalSeconds } = configuration;
-
- const log = (...optionalParams: any[]) => console.log(timestamp(), masterIdentifier, ...optionalParams);
-
- // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
- // to kill the server via the /kill/:key route
- let key: string | undefined;
- if (notifiers && notifiers.key) {
- key = Utils.GenerateGuid();
- const success = await notifiers.key(key, log);
- const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
- log(statement);
+ private 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") {
+ this.preciseAssignHelper(targetValue, sourceValue);
+ } else {
+ target[property] = sourceValue;
+ }
+ }
+ });
}
- // 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") {
- log(red(message));
- if (stack) {
- log(`uncaught exception\n${red(stack)}`);
+ /**
+ * 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 = this.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 = this.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!;
}
- });
+ }
- // determines whether or not we see the compilation / initialization / runtime output of each child server process
- setupMaster({ silent: !showServerOutput });
+ /**
+ * 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[2]));
+ 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[3] === "true") {
+ this.activeWorker?.send({ newPollingIntervalSeconds });
+ }
+ }
+ }
+ });
+ return repl;
+ }
+
+ private executeExitHandlers = async (reason: Error | boolean) => Promise.all(this.exitHandlers.map(handler => handler(reason)));
- // attempts to kills the active worker ungracefully, unless otherwise specified
- const tryKillActiveWorker = (graceful = false): boolean => {
- if (activeWorker && !activeWorker.isDead()) {
+ /**
+ * Attempts to kill the active worker gracefully, unless otherwise specified.
+ */
+ private killActiveWorker = (graceful = true, isSessionEnd = false): void => {
+ if (this.activeWorker && !this.activeWorker.isDead()) {
if (graceful) {
- activeWorker.kill();
+ this.activeWorker.send({ manualExit: { isSessionEnd } });
} else {
- activeWorker.process.kill();
+ this.activeWorker.process.kill();
}
- return true;
}
- return false;
- };
-
- const restartServer = (): void => {
- // indicate to the worker that we are 'expecting' this restart
- activeWorker.send({ setResponsiveness: false });
- tryKillActiveWorker(true);
- };
-
- const killSession = (graceful = true): never => {
- log(cyan(`exiting session ${graceful ? "clean" : "immediate"}ly`));
- tryKillActiveWorker(graceful);
- process.exit(0);
- };
+ }
- const setPort = (port: "server" | "socket" | string, value: number, immediateRestart: boolean): void => {
+ /**
+ * 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) {
- ports[port] = value;
+ this.config.ports[port] = value;
if (immediateRestart) {
- restartServer();
+ this.killActiveWorker();
}
} else {
- log(red(`${port} is an invalid port number`));
+ 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
- const spawn = (): void => {
- tryKillActiveWorker();
- activeWorker = fork({
- pollingRoute,
- pollingFailureTolerance,
+ /**
+ * Kills the current active worker and proceeds to spawn a new worker,
+ * feeding in configuration information as environment variables.
+ */
+ private spawn = (): void => {
+ const {
+ polling: {
+ route,
+ failureTolerance,
+ intervalSeconds
+ },
+ ports
+ } = this.config;
+ this.killActiveWorker();
+ this.activeWorker = fork({
+ pollingRoute: route,
+ pollingFailureTolerance: failureTolerance,
serverPort: ports.server,
socketPort: ports.socket,
- pollingIntervalSeconds,
- session_key: key,
+ pollingIntervalSeconds: intervalSeconds,
+ session_key: this.key,
DB: process.env.DB
});
- log(cyan(`spawned new server worker with process id ${activeWorker.process.pid}`));
+ this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker.process.pid}`));
// an IPC message handler that executes actions on the master thread when prompted by the active worker
- activeWorker.on("message", async ({ lifecycle, action }) => {
+ this.activeWorker.on("message", async ({ lifecycle, action }) => {
if (action) {
- const { message, args } = action as SessionAction;
- console.log(timestamp(), `${workerIdentifier} action requested (${cyan(message)})`);
+ const { message, args } = action as Monitor.Action;
+ console.log(this.timestamp(), `${this.config.identifiers.worker.text} action requested (${cyan(message)})`);
switch (message) {
case "kill":
- log(red("an authorized user has manually ended the server session"));
- killSession();
+ const { reason, graceful, errorCode } = args;
+ this.killSession(reason, graceful, errorCode);
+ break;
case "notify_crash":
- if (notifiers && notifiers.crash) {
+ if (this.notifiers?.crash) {
const { error } = args;
- const success = await notifiers.crash(error, log);
+ const success = await this.notifiers.crash(error);
const statement = success ? green("distributed crash notification to recipients") : red("distribution of crash notification failed");
- log(statement);
+ this.mainLog(statement);
}
+ break;
case "set_port":
const { port, value, immediateRestart } = args;
- setPort(port, value, immediateRestart);
- default:
- const handler = childMessageHandlers[message];
- if (handler) {
- handler({ message, args });
- }
+ this.setPort(port, value, immediateRestart);
+ break;
+ }
+ const handlers = this.onMessage[message];
+ if (handlers) {
+ handlers.forEach(handler => handler({ message, args }));
}
- } else if (lifecycle) {
- console.log(timestamp(), `${workerIdentifier} lifecycle phase (${lifecycle})`);
+ }
+ if (lifecycle) {
+ console.log(this.timestamp(), `${this.config.identifiers.worker.text} lifecycle phase (${lifecycle})`);
}
});
- };
+ }
- // 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}`}.`;
- log(cyan(prompt));
- // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one
- spawn();
- });
-
- // builds the repl that allows the following commands to be typed into stdin of the master thread
- const repl = new Repl({ identifier: () => `${timestamp()} ${masterIdentifier}` });
- const boolean = /true|false/;
- const number = /\d+/;
- const letters = /[a-zA-Z]+/;
- repl.registerCommand("exit", [/clean|force/], args => killSession(args[0] === "clean"));
- repl.registerCommand("restart", [], restartServer);
- repl.registerCommand("set", [letters, "port", number, boolean], args => setPort(args[0], Number(args[2]), args[3] === "true"));
- repl.registerCommand("set", [/polling/, number, boolean], args => {
- const newPollingIntervalSeconds = Math.floor(Number(args[2]));
- if (newPollingIntervalSeconds < 0) {
- log(red("the polling interval must be a non-negative integer"));
- } else {
- if (newPollingIntervalSeconds !== pollingIntervalSeconds) {
- pollingIntervalSeconds = newPollingIntervalSeconds;
- if (args[3] === "true") {
- activeWorker.send({ newPollingIntervalSeconds });
- }
- }
- }
- });
- // finally, set things in motion by spawning off the first child (server) process
- spawn();
-
- // returned to allow the caller to add custom commands
- return {
- addReplCommand: repl.registerCommand,
- addChildMessageHandler: (message: string, handler: ActionHandler) => { childMessageHandlers[message] = handler; },
- restartServer,
- killSession,
- setPort,
- log
- };
}
/**
* 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.
- * @param work the function specifying the work to be done by each worker thread
*/
- export async function initializeWorkerThread(work: Function): Promise<ServerWorker> {
- let shouldServerBeResponsive = false;
- const exitHandlers: ExitHandler[] = [];
- let pollingFailureCount = 0;
-
- const lifecycleNotification = (lifecycle: string) => process.send?.({ lifecycle });
-
- // notify master thread (which will log update in the console) of initialization via IPC
- lifecycleNotification(green("compiling and initializing..."));
-
- // updates the local value of listening to the value sent from master
- process.on("message", ({ setResponsiveness, newPollingIntervalSeconds }) => {
- if (setResponsiveness) {
- shouldServerBeResponsive = setResponsiveness;
- }
- if (newPollingIntervalSeconds) {
- pollingIntervalSeconds = newPollingIntervalSeconds;
+ export class ServerWorker {
+
+ 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;
+
+ 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) {
+ process.send?.({
+ action: {
+ message: "kill", args: {
+ reason: "cannot create more than one worker on a given worker process.",
+ graceful: false,
+ errorCode: 1
+ }
+ }
+ });
+ process.exit(1);
+ } else {
+ return new ServerWorker(work);
}
- });
+ }
- const executeExitHandlers = async (reason: Error | null) => Promise.all(exitHandlers.map(handler => handler(reason)));
+ /**
+ * 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.sendMonitorAction("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 sendMonitorAction = (message: string, args?: any) => process.send!({ action: { message, args } });
+
+ private constructor(work: Function) {
+ 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}`;
+
+ this.configureProcess();
+ work();
+ this.pollServer();
+ }
- // 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
- const activeExit = async (error: Error): Promise<void> => {
- shouldServerBeResponsive = false;
- // communicates via IPC to the master thread that it should dispatch a crash notification email
- process.send?.({
- action: {
- message: "notify_crash",
- args: { error }
+ /**
+ * Set up message and uncaught exception handlers for this
+ * server process.
+ */
+ private configureProcess = () => {
+ // updates the local values of variables to the those sent from master
+ process.on("message", async ({ newPollingIntervalSeconds, manualExit }) => {
+ if (newPollingIntervalSeconds !== undefined) {
+ this.pollingIntervalSeconds = newPollingIntervalSeconds;
}
+ if (manualExit !== undefined) {
+ const { isSessionEnd } = manualExit;
+ 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);
});
- await executeExitHandlers(error);
+ }
+
+ /**
+ * 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) => process.send?.({ 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.sendMonitorAction("notify_crash", { error });
+ await this.executeExitHandlers(error);
// notify master thread (which will log update in the console) of crash event via IPC
- lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
- lifecycleNotification(red(error.message));
+ this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`));
+ this.lifecycleNotification(red(error.message));
process.exit(1);
- };
+ }
- // one reason to exit, as the process might be in an inconsistent state after such an exception
- process.on('uncaughtException', activeExit);
-
- const { env } = process;
- const { pollingRoute, serverPort } = env;
- let pollingIntervalSeconds = Number(env.pollingIntervalSeconds);
- const pollingFailureTolerance = Number(env.pollingFailureTolerance);
- // 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.
- const pollTarget = `http://localhost:${serverPort}${pollingRoute}`;
- const pollServer = async (): Promise<void> => {
+ /**
+ * 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(pollTarget);
- if (!shouldServerBeResponsive) {
- // notify master thread (which will log update in the console) via IPC that the server is up and running
- process.send?.({ lifecycle: green(`listening on ${serverPort}...`) });
+ await get(this.pollTarget);
+ if (!this.shouldServerBeResponsive) {
+ // notify monitor thread that the server is up and running
+ this.lifecycleNotification(green(`listening on ${this.serverPort}...`));
}
- shouldServerBeResponsive = true;
- resolve();
+ 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 (shouldServerBeResponsive) {
- if (++pollingFailureCount > pollingFailureTolerance) {
- activeExit(error);
+ if (this.shouldServerBeResponsive) {
+ if (++this.pollingFailureCount > this.pollingFailureTolerance) {
+ this.proactiveUnplannedExit(error);
} else {
- lifecycleNotification(yellow(`the server has encountered ${pollingFailureCount} of ${pollingFailureTolerance} tolerable failures`));
+ this.lifecycleNotification(yellow(`the server has encountered ${this.pollingFailureCount} of ${this.pollingFailureTolerance} tolerable failures`));
}
}
+ } finally {
+ resolve();
}
- }, 1000 * pollingIntervalSeconds);
+ }, 1000 * this.pollingIntervalSeconds);
});
// controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed
- pollServer();
- };
-
- work();
- pollServer(); // begin polling
+ this.pollServer();
+ }
- return {
- addExitHandler: (handler: ExitHandler) => exitHandlers.push(handler),
- killSession: () => process.send!({ action: { message: "kill" } })
- };
}
}
diff --git a/src/server/Session/session_config_schema.ts b/src/server/Session/session_config_schema.ts
index 5a85a45e3..e32cf8c6a 100644
--- a/src/server/Session/session_config_schema.ts
+++ b/src/server/Session/session_config_schema.ts
@@ -1,39 +1,67 @@
import { Schema } from "jsonschema";
+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: { type: "number", minimum: 1024, maximum: 65535 },
- socket: { type: "number", minimum: 1024, maximum: 65535 }
+ server: portProperties,
+ socket: portProperties
},
required: ["server"],
additionalProperties: true
},
- pollingRoute: {
- type: "string",
- pattern: /\/[a-zA-Z]*/g
- },
- masterIdentifier: {
- type: "string",
- minLength: 1
- },
- workerIdentifier: {
- type: "string",
- minLength: 1
+ identifiers: {
+ type: "object",
+ properties: {
+ master: identifierProperties,
+ worker: identifierProperties,
+ exec: identifierProperties
+ }
},
- showServerOutput: { type: "boolean" },
- pollingIntervalSeconds: {
- type: "number",
- minimum: 1,
- maximum: 86400
+ 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,
+ }
+ }
},
- pollingFailureTolerance: {
- type: "number",
- minimum: 0,
- }
}
}; \ No newline at end of file
diff --git a/src/server/index.ts b/src/server/index.ts
index 85242bef7..2c8f32130 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -24,7 +24,10 @@ import { Logger } from "./ProcessFactory";
import { yellow, red } from "colors";
import { Session } from "./Session/session";
import { DashSessionAgent } from "./DashSession";
+import SessionManager from "./ApiManagers/SessionManager";
+export const onWindows = process.platform === "win32";
+export let sessionAgent: Session.AppliedSessionAgent;
export const publicDirectory = path.resolve(__dirname, "public");
export const filesDirectory = path.resolve(publicDirectory, "files");
@@ -58,6 +61,7 @@ async function preliminaryFunctions() {
*/
function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: RouteManager) {
const managers = [
+ new SessionManager(),
new UserManager(),
new UploadManager(),
new DownloadManager(),
@@ -88,19 +92,6 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }:
secureHandler: ({ res }) => res.send(true)
});
- addSupervisedRoute({
- method: Method.GET,
- subscription: new RouteSubscriber("kill").add("key"),
- secureHandler: ({ req, res }) => {
- if (req.params.key === process.env.session_key) {
- res.send("<img src='https://media.giphy.com/media/NGIfqtcS81qi4/giphy.gif' style='width:100%;height:100%;'/>");
- sessionAgent.serverWorker.killSession();
- } else {
- res.redirect("/home");
- }
- }
- });
-
const serve: PublicHandler = ({ req, res }) => {
const detector = new mobileDetect(req.headers['user-agent'] || "");
const filename = detector.mobile() !== null ? 'mobile/image.html' : 'index.html';
@@ -143,7 +134,6 @@ export async function launchServer() {
await initializeServer(routeSetter);
}
-export const sessionAgent = new DashSessionAgent();
/**
* If you're in development mode, you won't need to run a session.
* The session spawns off new server processes each time an error is encountered, and doesn't
@@ -151,7 +141,7 @@ export const sessionAgent = new DashSessionAgent();
* So, the 'else' clause is exactly what we've always run when executing npm start.
*/
if (process.env.RELEASE) {
- sessionAgent.launch();
+ (sessionAgent = new DashSessionAgent()).launch();
} else {
launchServer();
}
diff --git a/src/server/remote_debug_instructions.txt b/src/server/remote_debug_instructions.txt
new file mode 100644
index 000000000..c279c460a
--- /dev/null
+++ b/src/server/remote_debug_instructions.txt
@@ -0,0 +1,16 @@
+Instructions:
+
+Download this attachment, open your downloads folder and find this file (__zipname__).
+Right click on the zip file and select 'Extract to __target__\'.
+Open up the command line, and remember that you can get the path to any file or directory by literally dragging it from the file system and dropping it onto the terminal.
+Unless it's in your path, you'll want to navigate to the MongoDB bin directory, given for Windows:
+
+cd '/c/Program Files/MongoDB/Server/[your version, i.e. 4.0, goes here]/bin'
+
+Then run the following command (if you're in the bin folder, make that ./mongorestore ...):
+
+mongorestore --gzip [/path/to/directory/you/just/unzipped] --db Dash
+
+Assuming everything runs well, this will mirror your local database with that of the server. Now, just start the server locally and debug.
+
+__signature__ \ No newline at end of file
diff --git a/src/server/repl.ts b/src/server/repl.ts
index faf1eab15..ad55b6aaa 100644
--- a/src/server/repl.ts
+++ b/src/server/repl.ts
@@ -97,20 +97,25 @@ export default class Repl {
const candidates = registered.filter(({ argPatterns: { length: count } }) => count === length);
for (const { argPatterns, action } of candidates) {
const parsed: string[] = [];
- let matched = false;
+ 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]);
}
- matched = true;
}
if (!length || matched) {
- await action(parsed);
- this.valid(`${command} ${parsed.join(" ")}`);
+ const result = action(parsed);
+ const resolve = () => this.valid(`${command} ${parsed.join(" ")}`);
+ if (result instanceof Promise) {
+ result.then(resolve);
+ } else {
+ resolve();
+ }
return;
}
}