diff options
author | Geireann Lindfield Roberts <60007097+geireann@users.noreply.github.com> | 2020-08-07 15:17:46 +0800 |
---|---|---|
committer | Geireann Lindfield Roberts <60007097+geireann@users.noreply.github.com> | 2020-08-07 15:17:46 +0800 |
commit | e4ee924550052a5be2dcb598587d73434c8f87d8 (patch) | |
tree | a87bc2d93116d6cfd12e1eb8459e5fa9364281f0 /src | |
parent | 79166a51eae57e59cc63009e22310b416500de9e (diff) | |
parent | 20149e4849b8c24ba98ea1372f6ab3def6585265 (diff) |
Merge branch 'master' into presentation_updates
Diffstat (limited to 'src')
33 files changed, 1153 insertions, 284 deletions
diff --git a/src/Utils.ts b/src/Utils.ts index 0b057dc23..6608bb176 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -378,6 +378,15 @@ export function timenow() { return now.toLocaleDateString() + ' ' + h + ':' + m + ' ' + ampm; } +export function formatTime(time: number) { + time = Math.round(time); + const hours = Math.floor(time / 60 / 60); + const minutes = Math.floor(time / 60) - (hours * 60); + const seconds = time % 60; + + return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); +} + export function aggregateBounds(boundsList: { x: number, y: number, width?: number, height?: number }[], xpad: number, ypad: number) { const bounds = boundsList.map(b => ({ x: b.x, y: b.y, r: b.x + (b.width || 0), b: b.y + (b.height || 0) })).reduce((bounds, b) => ({ x: Math.min(b.x, bounds.x), y: Math.min(b.y, bounds.y), diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 7719b3eef..a06b4a581 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -199,6 +199,10 @@ export interface DocumentOptions { syntaxColor?: string; // can be applied to text for syntax highlighting all matches in the text searchQuery?: string; // for quersyBox linearViewIsExpanded?: boolean; // is linear view expanded + isLabel?: boolean; // whether the document is a label or not (video / audio) + useLinkSmallAnchor?: boolean; // whether links to this document should use a miniature linkAnchorBox + audioStart?: number; // the time frame where the audio should begin playing + audioEnd?: number; // the time frame where the audio should stop playing border?: string; //for searchbox hovercolor?: string; } @@ -565,6 +569,7 @@ export namespace Docs { // without this, if a doc has no annotations but the user has AddOnly privileges, they won't be able to add an annotation because they would have needed to create the field's list which they don't have permissions to do. dataDoc[fieldKey + "-annotations"] = new List<Doc>(); + dataDoc.aliases = new List<Doc>(); proto.links = ComputedField.MakeFunction("links(self)"); @@ -633,7 +638,7 @@ export namespace Docs { } export function AudioDocument(url: string, options: DocumentOptions = {}) { - const instance = InstanceFromProto(Prototypes.get(DocumentType.AUDIO), new AudioField(new URL(url)), options); + const instance = InstanceFromProto(Prototypes.get(DocumentType.AUDIO), new AudioField(new URL(url)), { useLinkSmallAnchor: true, ...options }); // hideLinkButton: false, useLinkSmallAnchor: false, Doc.GetProto(instance).backgroundColor = ComputedField.MakeFunction("this._audioState === 'playing' ? 'green':'gray'"); return instance; } @@ -927,6 +932,8 @@ export namespace DocUtils { if (target.doc === Doc.UserDoc()) return undefined; const linkDoc = Docs.Create.LinkDocument(source, target, { linkRelationship, layoutKey: "layout_linkView", description }, id); + Doc.GetProto(linkDoc)["anchor1-useLinkSmallAnchor"] = source.doc.useLinkSmallAnchor; + Doc.GetProto(linkDoc)["anchor2-useLinkSmallAnchor"] = target.doc.useLinkSmallAnchor; linkDoc.linkDisplay = true; linkDoc.hidden = true; linkDoc.layout_linkView = Cast(Cast(Doc.UserDoc()["template-button-link"], Doc, null).dragFactory, Doc, null); diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 1f140e145..37ffcb78e 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -421,7 +421,7 @@ export class CurrentUserUtils { { _width: 250, _height: 250, title: "container" }); } if (doc.emptyWebpage === undefined) { - doc.emptyWebpage = Docs.Create.WebDocument("", { title: "webpage", _nativeWidth: 850, _nativeHeight: 962, _width: 600, UseCors: true }); + doc.emptyWebpage = Docs.Create.WebDocument("", { title: "webpage", _nativeWidth: 850, _nativeHeight: 962, _width: 400, UseCors: true }); } if (doc.activeMobileMenu === undefined) { this.setupActiveMobileMenu(doc); @@ -836,7 +836,7 @@ export class CurrentUserUtils { // Right sidebar is where mobile uploads are contained static setupSharingSidebar(doc: Doc) { if (doc["sidebar-sharing"] === undefined) { - doc["sidebar-sharing"] = new PrefetchProxy(Docs.Create.StackingDocument([], { title: "Sharing Sidebar" })); + doc["sidebar-sharing"] = new PrefetchProxy(Docs.Create.StackingDocument([], { title: "Shared Documents", childDropAction: "alias" })); } } diff --git a/src/client/util/GroupManager.scss b/src/client/util/GroupManager.scss index 51e4fa9e2..9438bdd72 100644 --- a/src/client/util/GroupManager.scss +++ b/src/client/util/GroupManager.scss @@ -92,6 +92,7 @@ .sort-groups { text-align: left; margin-left: 5; + width: 50px; cursor: pointer; } diff --git a/src/client/util/GroupManager.tsx b/src/client/util/GroupManager.tsx index 229a846a9..277e96a89 100644 --- a/src/client/util/GroupManager.tsx +++ b/src/client/util/GroupManager.tsx @@ -18,7 +18,7 @@ import { setGroups } from "../../fields/util"; import { DocServer } from "../DocServer"; import { TaskCompletionBox } from "../views/nodes/TaskCompletedBox"; -library.add(fa.faPlus, fa.faTimes, fa.faInfoCircle); +library.add(fa.faPlus, fa.faTimes, fa.faInfoCircle, fa.faCaretUp, fa.faCaretRight, fa.faCaretDown); /** * Interface for options for the react-select component @@ -42,6 +42,7 @@ export default class GroupManager extends React.Component<{}> { private currentUserGroups: string[] = []; // the list of groups the current user is a member of @observable private buttonColour: "#979797" | "black" = "#979797"; @observable private groupSort: "ascending" | "descending" | "none" = "none"; + private populating: boolean = false; @@ -62,21 +63,24 @@ export default class GroupManager extends React.Component<{}> { * Fetches the list of users stored on the database. */ populateUsers = async () => { - runInAction(() => this.users = []); - const userList = await RequestPromise.get(Utils.prepend("/getUsers")); - const raw = JSON.parse(userList) as User[]; - const evaluating = raw.map(async user => { - const userDocument = await DocServer.GetRefField(user.userDocumentId); - if (userDocument instanceof Doc) { - const notificationDoc = await Cast(userDocument["sidebar-sharing"], Doc); - runInAction(() => { - if (notificationDoc instanceof Doc) { - this.users.push(user.email); - } - }); - } - }); - return Promise.all(evaluating); + if (!this.populating) { + this.populating = true; + runInAction(() => this.users = []); + const userList = await RequestPromise.get(Utils.prepend("/getUsers")); + const raw = JSON.parse(userList) as User[]; + const evaluating = raw.map(async user => { + const userDocument = await DocServer.GetRefField(user.userDocumentId); + if (userDocument instanceof Doc) { + const notificationDoc = await Cast(userDocument["sidebar-sharing"], Doc); + runInAction(() => { + if (notificationDoc instanceof Doc) { + this.users.push(user.email); + } + }); + } + }); + return Promise.all(evaluating).then(() => this.populating = false); + } } /** @@ -88,7 +92,6 @@ export default class GroupManager extends React.Component<{}> { const members: string[] = JSON.parse(StrCast(group.members)); if (members.includes(Doc.CurrentUserEmail)) this.currentUserGroups.push(StrCast(group.groupName)); }); - setGroups(this.currentUserGroups); }); } @@ -120,6 +123,7 @@ export default class GroupManager extends React.Component<{}> { this.currentGroup = undefined; // this.users = []; this.createGroupModalOpen = false; + TaskCompletionBox.taskCompleted = false; } /** @@ -132,7 +136,6 @@ export default class GroupManager extends React.Component<{}> { /** * @returns a list of all group documents. */ - // private ? getAllGroups(): Doc[] { const groupDoc = this.GroupManagerDoc; return groupDoc ? DocListCast(groupDoc.data) : []; @@ -142,7 +145,6 @@ export default class GroupManager extends React.Component<{}> { * @returns a group document based on the group name. * @param groupName */ - // private? getGroup(groupName: string): Doc | undefined { const groupDoc = this.getAllGroups().find(group => group.groupName === groupName); return groupDoc; @@ -209,8 +211,6 @@ export default class GroupManager extends React.Component<{}> { deleteGroup(group: Doc): boolean { if (group) { if (this.GroupManagerDoc && this.hasEditAccess(group)) { - // TODO look at this later - // SharingManager.Instance.setInternalGroupSharing(group, "Not Shared"); Doc.RemoveDocFromList(this.GroupManagerDoc, "data", group); SharingManager.Instance.removeGroup(group); const members: string[] = JSON.parse(StrCast(group.members)); @@ -311,7 +311,9 @@ export default class GroupManager extends React.Component<{}> { <div className="group-create"> <div className="group-heading" style={{ marginBottom: 0 }}> <p><b>New Group</b></p> - <div className={"close-button"} onClick={action(() => this.createGroupModalOpen = false)}> + <div className={"close-button"} onClick={action(() => { + this.createGroupModalOpen = false; TaskCompletionBox.taskCompleted = false; + })}> <FontAwesomeIcon icon={fa.faTimes} color={"black"} size={"lg"} /> </div> </div> @@ -319,6 +321,7 @@ export default class GroupManager extends React.Component<{}> { className="group-input" ref={this.inputRef} onKeyDown={this.handleKeyDown} + autoFocus type="text" placeholder="Group name" onChange={action(() => this.buttonColour = this.inputRef.current?.value ? "black" : "#979797")} /> @@ -363,7 +366,7 @@ export default class GroupManager extends React.Component<{}> { interactive={true} contents={contents} dialogueBoxStyle={{ width: "90%", height: "70%" }} - closeOnExternalClick={action(() => this.createGroupModalOpen = false)} + closeOnExternalClick={action(() => { this.createGroupModalOpen = false; TaskCompletionBox.taskCompleted = false; })} /> ); } @@ -405,7 +408,10 @@ export default class GroupManager extends React.Component<{}> { <div className="sort-groups" onClick={action(() => this.groupSort = this.groupSort === "ascending" ? "descending" : this.groupSort === "descending" ? "none" : "ascending")}> - Name {this.groupSort === "ascending" ? "↑" : this.groupSort === "descending" ? "↓" : ""} + Name {this.groupSort === "ascending" ? <FontAwesomeIcon icon={fa.faCaretUp} size={"xs"} /> + : this.groupSort === "descending" ? <FontAwesomeIcon icon={fa.faCaretDown} size={"xs"} /> + : <FontAwesomeIcon icon={fa.faCaretRight} size={"xs"} /> + } </div> <div className="group-body"> {groups.map(group => diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss index 8da80ef52..7912db74d 100644 --- a/src/client/util/SharingManager.scss +++ b/src/client/util/SharingManager.scss @@ -84,6 +84,7 @@ .user-sort { text-align: left; margin-left: 10; + width: 100px; cursor: pointer; } diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx index 9d91ce1ba..d50a132f8 100644 --- a/src/client/util/SharingManager.tsx +++ b/src/client/util/SharingManager.tsx @@ -1,7 +1,7 @@ import { observable, runInAction, action } from "mobx"; import * as React from "react"; import MainViewModal from "../views/MainViewModal"; -import { Doc, Opt, DocListCastAsync, AclAdmin, DataSym, AclPrivate } from "../../fields/Doc"; +import { Doc, Opt, AclAdmin, AclPrivate, DocListCast } from "../../fields/Doc"; import { DocServer } from "../DocServer"; import { Cast, StrCast } from "../../fields/Types"; import * as RequestPromise from "request-promise"; @@ -21,6 +21,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { List } from "../../fields/List"; import { distributeAcls, SharingPermissions, GetEffectiveAcl } from "../../fields/util"; import { TaskCompletionBox } from "../views/nodes/TaskCompletedBox"; +import { library } from "@fortawesome/fontawesome-svg-core"; + +library.add(fa.faInfoCircle, fa.faCaretUp, fa.faCaretRight, fa.faCaretDown); + export interface User { email: string; @@ -71,6 +75,7 @@ export default class SharingManager extends React.Component<{}> { // if both showUserOptions and showGroupOptions are false then both are displayed @observable private showUserOptions: boolean = false; // whether to show individuals as options when sharing (in the react-select component) @observable private showGroupOptions: boolean = false; // // whether to show groups as options when sharing (in the react-select component) + private populating: boolean = false; // private get linkVisible() { // return this.sharingDoc ? this.sharingDoc[PublicKey] !== SharingPermissions.None : false; @@ -87,13 +92,13 @@ export default class SharingManager extends React.Component<{}> { this.isOpen = true; this.permissions = SharingPermissions.Edit; }); - + this.targetDoc!.author === Doc.CurrentUserEmail && !this.targetDoc![`ACL-${Doc.CurrentUserEmail.replace(".", "_")}`] && distributeAcls(`ACL-${Doc.CurrentUserEmail.replace(".", "_")}`, SharingPermissions.Admin, this.targetDoc!); } public close = action(() => { this.isOpen = false; this.selectedUsers = null; // resets the list of users and seleected users (in the react-select component) - + TaskCompletionBox.taskCompleted = false; setTimeout(action(() => { // this.copied = false; DictationOverlay.Instance.hasActiveModal = false; @@ -117,24 +122,27 @@ export default class SharingManager extends React.Component<{}> { * Populates the list of validated users (this.users) by adding registered users which have a sidebar-sharing. */ populateUsers = async () => { - runInAction(() => this.users = []); - const userList = await RequestPromise.get(Utils.prepend("/getUsers")); - const raw = JSON.parse(userList) as User[]; - const evaluating = raw.map(async user => { - const isCandidate = user.email !== Doc.CurrentUserEmail; - if (isCandidate) { - const userDocument = await DocServer.GetRefField(user.userDocumentId); - if (userDocument instanceof Doc) { - const notificationDoc = await Cast(userDocument["sidebar-sharing"], Doc); - runInAction(() => { - if (notificationDoc instanceof Doc) { - this.users.push({ user, notificationDoc }); - } - }); + if (!this.populating) { + this.populating = true; + runInAction(() => this.users = []); + const userList = await RequestPromise.get(Utils.prepend("/getUsers")); + const raw = JSON.parse(userList) as User[]; + const evaluating = raw.map(async user => { + const isCandidate = user.email !== Doc.CurrentUserEmail; + if (isCandidate) { + const userDocument = await DocServer.GetRefField(user.userDocumentId); + if (userDocument instanceof Doc) { + const notificationDoc = await Cast(userDocument["sidebar-sharing"], Doc); + runInAction(() => { + if (notificationDoc instanceof Doc) { + this.users.push({ user, notificationDoc }); + } + }); + } } - } - }); - return Promise.all(evaluating); + }); + return Promise.all(evaluating).then(() => this.populating = false); + } } /** @@ -152,13 +160,11 @@ export default class SharingManager extends React.Component<{}> { target.author === Doc.CurrentUserEmail && distributeAcls(ACL, permission as SharingPermissions, target); // if documents have been shared, add the target to that list if it doesn't already exist, otherwise create a new list with the target - group.docsShared ? DocListCastAsync(group.docsShared).then(resolved => Doc.IndexOf(target, resolved!) === -1 && (group.docsShared as List<Doc>).push(target)) : group.docsShared = new List<Doc>([target]); + group.docsShared ? Doc.IndexOf(target, DocListCast(group.docsShared)) === -1 && (group.docsShared as List<Doc>).push(target) : group.docsShared = new List<Doc>([target]); users.forEach(({ notificationDoc }) => { - DocListCastAsync(notificationDoc[storage]).then(resolved => { - if (permission !== SharingPermissions.None) Doc.IndexOf(target, resolved!) === -1 && Doc.AddDocToList(notificationDoc, storage, target); // add the target to the notificationDoc if it hasn't already been added - else Doc.IndexOf(target, resolved!) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); // remove the target from the list if it already exists - }); + if (permission !== SharingPermissions.None) Doc.IndexOf(target, DocListCast(notificationDoc[storage])) === -1 && Doc.AddDocToList(notificationDoc, storage, target); // add the target to the notificationDoc if it hasn't already been added + else Doc.IndexOf(target, DocListCast(notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); // remove the target from the list if it already exists }); } @@ -170,13 +176,7 @@ export default class SharingManager extends React.Component<{}> { shareWithAddedMember = (group: Doc, emailId: string) => { const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; - if (group.docsShared) { - DocListCastAsync(group.docsShared).then(docsShared => { - docsShared?.forEach(doc => { - DocListCastAsync(user.notificationDoc[storage]).then(resolved => Doc.IndexOf(doc, resolved!) === -1 && Doc.AddDocToList(user.notificationDoc, storage, doc)); // add the doc if it isn't already in the list - }); - }); - } + if (group.docsShared) DocListCast(group.docsShared).forEach(doc => Doc.IndexOf(doc, DocListCast(user.notificationDoc[storage])) === -1 && Doc.AddDocToList(user.notificationDoc, storage, doc)); } shareFromPropertiesSidebar = (shareWith: string, permission: SharingPermissions, target: Doc) => { @@ -194,10 +194,8 @@ export default class SharingManager extends React.Component<{}> { const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; if (group.docsShared) { - DocListCastAsync(group.docsShared).then(docsShared => { - docsShared?.forEach(doc => { - DocListCastAsync(user.notificationDoc[storage]).then(resolved => Doc.IndexOf(doc, resolved!) !== -1 && Doc.RemoveDocFromList(user.notificationDoc, storage, doc)); // remove the doc only if it is in the list - }); + DocListCast(group.docsShared).forEach(doc => { + Doc.IndexOf(doc, DocListCast(user.notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(user.notificationDoc, storage, doc); // remove the doc only if it is in the list }); } } @@ -208,39 +206,36 @@ export default class SharingManager extends React.Component<{}> { */ removeGroup = (group: Doc) => { if (group.docsShared) { - DocListCastAsync(group.docsShared).then(resolved => { - resolved?.forEach(doc => { - const ACL = `ACL-${StrCast(group.groupName)}`; + DocListCast(group.docsShared).forEach(doc => { + const ACL = `ACL-${StrCast(group.groupName)}`; - distributeAcls(ACL, SharingPermissions.None, doc); + distributeAcls(ACL, SharingPermissions.None, doc); - const members: string[] = JSON.parse(StrCast(group.members)); - const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); - - users.forEach(({ notificationDoc }) => Doc.RemoveDocFromList(notificationDoc, storage, doc)); - }); + const members: string[] = JSON.parse(StrCast(group.members)); + const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); + users.forEach(({ notificationDoc }) => Doc.RemoveDocFromList(notificationDoc, storage, doc)); }); } } + /** + * Shares the document with a user. + */ setInternalSharing = (recipient: ValidatedUser, permission: string, targetDoc?: Doc) => { const { user, notificationDoc } = recipient; const target = targetDoc || this.targetDoc!; const key = user.email.replace('.', '_'); const ACL = `ACL-${key}`; + target.author === Doc.CurrentUserEmail && distributeAcls(ACL, permission as SharingPermissions, target); if (permission !== SharingPermissions.None) { - DocListCastAsync(notificationDoc[storage]).then(resolved => { - Doc.IndexOf(target, resolved!) === -1 && Doc.AddDocToList(notificationDoc, storage, target); - }); + Doc.IndexOf(target, DocListCast(notificationDoc[storage])) === -1 && Doc.AddDocToList(notificationDoc, storage, target); } else { - DocListCastAsync(notificationDoc[storage]).then(resolved => { - Doc.IndexOf(target, resolved!) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); - }); + Doc.IndexOf(target, DocListCast(notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); } } @@ -268,14 +263,17 @@ export default class SharingManager extends React.Component<{}> { // } // }); + /** + * Returns the SharingPermissions (Admin, Can Edit etc) access that's used to share + */ private get sharingOptions() { - return Object.values(SharingPermissions).map(permission => { - return ( - <option key={permission} value={permission} selected={permission === SharingPermissions.Edit}> + return Object.values(SharingPermissions).map(permission => + ( + <option key={permission} value={permission}> {permission} </option> - ); - }); + ) + ); } private focusOn = (contents: string) => { @@ -308,16 +306,25 @@ export default class SharingManager extends React.Component<{}> { ); } + /** + * Handles changes in the users selected in react-select + */ @action handleUsersChange = (selectedOptions: any) => { this.selectedUsers = selectedOptions as UserOptions[]; } + /** + * Handles changes in the permission chosen to share with someone with + */ @action handlePermissionsChange = (event: React.ChangeEvent<HTMLSelectElement>) => { this.permissions = event.currentTarget.value as SharingPermissions; } + /** + * Calls the relevant method for sharing, displays the popup, and resets the relevant variables. + */ @action share = () => { if (this.selectedUsers) { @@ -341,18 +348,27 @@ export default class SharingManager extends React.Component<{}> { } } + /** + * Sorting algorithm to sort users. + */ sortUsers = (u1: ValidatedUser, u2: ValidatedUser) => { const { email: e1 } = u1.user; const { email: e2 } = u2.user; return e1 < e2 ? -1 : e1 === e2 ? 0 : 1; } + /** + * Sorting algorithm to sort groups. + */ sortGroups = (group1: Doc, group2: Doc) => { const g1 = StrCast(group1.groupName); const g2 = StrCast(group2.groupName); return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; } + /** + * @returns the main interface of the SharingManager. + */ private get sharingInterface() { const groupList = GroupManager.Instance?.getAllGroups() || []; @@ -361,8 +377,8 @@ export default class SharingManager extends React.Component<{}> { const sortedGroups = groupList.slice().sort(this.sortGroups) .map(({ groupName }) => ({ label: StrCast(groupName), value: groupType + StrCast(groupName) })); + // the next block handles the users shown (individuals/groups/both) const options: GroupedOptions[] = []; - if (GroupManager.Instance) { if ((this.showUserOptions && this.showGroupOptions) || (!this.showUserOptions && !this.showGroupOptions)) { options.push({ @@ -393,6 +409,7 @@ export default class SharingManager extends React.Component<{}> { const effectiveAcl = this.targetDoc ? GetEffectiveAcl(this.targetDoc) : AclPrivate; + // the list of users shared with const userListContents: (JSX.Element | null)[] = users.map(({ user, notificationDoc }) => { const userKey = user.email.replace('.', '_'); const permissions = StrCast(this.targetDoc?.[`ACL-${userKey}`]); @@ -422,6 +439,7 @@ export default class SharingManager extends React.Component<{}> { ); }); + // the owner of the doc and the current user are placed at the top of the user list. userListContents.unshift( ( <div @@ -452,6 +470,7 @@ export default class SharingManager extends React.Component<{}> { ) : null ); + // the list of groups shared with const groupListContents = groups.map(group => { const permissions = StrCast(this.targetDoc?.[`ACL-${StrCast(group.groupName)}`]); @@ -477,6 +496,7 @@ export default class SharingManager extends React.Component<{}> { ); }); + // don't display the group list if all groups are null const displayGroupList = !groupListContents?.every(group => group === null); return ( @@ -538,7 +558,7 @@ export default class SharingManager extends React.Component<{}> { }) }} /> - <select className="permissions-select" onChange={this.handlePermissionsChange}> + <select className="permissions-select" onChange={this.handlePermissionsChange} value={this.permissions}> {this.sharingOptions} </select> <button ref={this.shareDocumentButtonRef} className="share-button" onClick={this.share}> @@ -556,7 +576,9 @@ export default class SharingManager extends React.Component<{}> { <div className="user-sort" onClick={action(() => this.individualSort = this.individualSort === "ascending" ? "descending" : this.individualSort === "descending" ? "none" : "ascending")}> - Individuals {this.individualSort === "ascending" ? "↑" : this.individualSort === "descending" ? "↓" : ""} {/* → */} + Individuals {this.individualSort === "ascending" ? <FontAwesomeIcon icon={fa.faCaretUp} size={"xs"} /> + : this.individualSort === "descending" ? <FontAwesomeIcon icon={fa.faCaretDown} size={"xs"} /> + : <FontAwesomeIcon icon={fa.faCaretRight} size={"xs"} />} </div> <div className={"users-list"} style={{ display: "block" }}>{/*200*/} {userListContents} @@ -566,7 +588,9 @@ export default class SharingManager extends React.Component<{}> { <div className="user-sort" onClick={action(() => this.groupSort = this.groupSort === "ascending" ? "descending" : this.groupSort === "descending" ? "none" : "ascending")}> - Groups {this.groupSort === "ascending" ? "↑" : this.groupSort === "descending" ? "↓" : ""} {/* → */} + Groups {this.groupSort === "ascending" ? <FontAwesomeIcon icon={fa.faCaretUp} size={"xs"} /> + : this.groupSort === "descending" ? <FontAwesomeIcon icon={fa.faCaretDown} size={"xs"} /> + : <FontAwesomeIcon icon={fa.faCaretRight} size={"xs"} />} </div> <div className={"groups-list"} style={{ display: !displayGroupList ? "flex" : "block" }}>{/*200*/} diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 804c7a8d4..831c246d1 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -1,4 +1,4 @@ -import { Doc, Opt, DataSym, AclReadonly, AclAddonly, AclPrivate, AclEdit, AclSym, DocListCastAsync, DocListCast } from '../../fields/Doc'; +import { Doc, Opt, DataSym, AclReadonly, AclAddonly, AclPrivate, AclEdit, AclSym, DocListCastAsync, DocListCast, AclAdmin } from '../../fields/Doc'; import { Touchable } from './Touchable'; import { computed, action, observable } from 'mobx'; import { Cast, BoolCast, ScriptCast } from '../../fields/Types'; @@ -7,7 +7,7 @@ import { InteractionUtils } from '../util/InteractionUtils'; import { List } from '../../fields/List'; import { DateField } from '../../fields/DateField'; import { ScriptField } from '../../fields/ScriptField'; -import { GetEffectiveAcl, SharingPermissions } from '../../fields/util'; +import { GetEffectiveAcl, SharingPermissions, distributeAcls } from '../../fields/util'; /// DocComponent returns a generic React base class used by views that don't have 'fieldKey' props (e.g.,CollectionFreeFormDocumentView, DocumentView) @@ -96,7 +96,8 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T [AclPrivate, SharingPermissions.None], [AclReadonly, SharingPermissions.View], [AclAddonly, SharingPermissions.Add], - [AclEdit, SharingPermissions.Edit] + [AclEdit, SharingPermissions.Edit], + [AclAdmin, SharingPermissions.Admin] ]); lookupField = (field: string) => ScriptCast((this.layoutDoc as any).lookupField)?.script.run({ self: this.layoutDoc, data: this.rootDoc, field: field }).result; @@ -154,15 +155,14 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T return false; } else { - // if (this.props.Document[AclSym]) { - // added.forEach(d => { - // const dataDoc = d[DataSym]; - // dataDoc[AclSym] = d[AclSym] = this.props.Document[AclSym]; - // for (const [key, value] of Object.entries(this.props.Document[AclSym])) { - // dataDoc[key] = d[key] = this.AclMap.get(value); - // } - // }); - // } + if (this.props.Document[AclSym]) { + added.forEach(d => { + for (const [key, value] of Object.entries(this.props.Document[AclSym])) { + distributeAcls(key, this.AclMap.get(value) as SharingPermissions, d, true); + } + }); + } + if (effectiveAcl === AclAddonly) { added.map(doc => Doc.AddDocToList(targetDataDoc, this.annotationKey, doc)); } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 005df6fe0..f1169763e 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -611,7 +611,11 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (SnappingManager.GetIsDragging() || bounds.r - bounds.x < 2 || bounds.x === Number.MAX_VALUE || !seldoc || this._hidden || isNaN(bounds.r) || isNaN(bounds.b) || isNaN(bounds.x) || isNaN(bounds.y)) { return (null); } - const canDelete = SelectionManager.SelectedDocuments().map(docView => GetEffectiveAcl(docView.props.ContainingCollectionDoc)).some(permission => permission === AclAdmin || permission === AclEdit); + const canDelete = SelectionManager.SelectedDocuments().some(docView => { + const docAcl = GetEffectiveAcl(docView.props.Document); + const collectionAcl = GetEffectiveAcl(docView.props.ContainingCollectionDoc); + return [docAcl, collectionAcl].some(acl => [AclAdmin, AclEdit].includes(acl)); + }); const minimal = bounds.r - bounds.x < 100 ? true : false; const maximizeIcon = minimal ? ( <Tooltip title={<><div className="dash-tooltip">Show context menu</div></>} placement="top"> diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index c9f95a538..3a61e89ce 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -1,6 +1,6 @@ import { action } from "mobx"; import { DateField } from "../../fields/DateField"; -import { Doc, DocListCast } from "../../fields/Doc"; +import { Doc, DocListCast, AclEdit, AclAdmin } from "../../fields/Doc"; import { Id } from "../../fields/FieldSymbols"; import { InkTool } from "../../fields/InkField"; import { List } from "../../fields/List"; @@ -24,6 +24,7 @@ import PDFMenu from "./pdf/PDFMenu"; import { ContextMenu } from "./ContextMenu"; import GroupManager from "../util/GroupManager"; import { CollectionFreeFormViewChrome } from "./collections/CollectionMenu"; +import { GetEffectiveAcl } from "../../fields/util"; const modifiers = ["control", "meta", "shift", "alt"]; type KeyHandler = (keycode: string, e: KeyboardEvent) => KeyControlInfo | Promise<KeyControlInfo>; @@ -118,8 +119,18 @@ export default class KeyManager { return { stopPropagation: false, preventDefault: false }; } } - UndoManager.RunInBatch(() => - SelectionManager.SelectedDocuments().map(dv => dv.props.removeDocument?.(dv.props.Document)), "delete"); + + const recent = Cast(Doc.UserDoc().myRecentlyClosed, Doc) as Doc; + const selected = SelectionManager.SelectedDocuments().slice(); + UndoManager.RunInBatch(() => { + selected.map(dv => { + const effectiveAcl = GetEffectiveAcl(dv.props.Document); + if (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) { // deletes whatever you have the right to delete + recent && Doc.AddDocToList(recent, "data", dv.props.Document, undefined, true, true); + dv.props.removeDocument?.(dv.props.Document); + } + }); + }, "delete"); SelectionManager.DeselectAll(); break; case "arrowleft": diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 51137a080..b6058db7a 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -437,7 +437,7 @@ export class MainView extends React.Component { } sidebarScreenToLocal = () => new Transform(0, (CollectionMenu.Instance.Pinned ? -35 : 0), 1); //sidebarScreenToLocal = () => new Transform(0, (RichTextMenu.Instance.Pinned ? -35 : 0) + (CollectionMenu.Instance.Pinned ? -35 : 0), 1); - mainContainerXf = () => this.sidebarScreenToLocal().translate(0, -this._buttonBarHeight); + mainContainerXf = () => this.sidebarScreenToLocal().translate(-55, 0); @computed get closePosition() { return 55 + this.flyoutWidth; } @computed get flyout() { @@ -788,7 +788,7 @@ export class MainView extends React.Component { <FormatShapePane /> <div style={{ display: "none" }}><RichTextMenu key="rich" /></div> {LinkDescriptionPopup.descriptionPopup ? <LinkDescriptionPopup /> : null} - {DocumentLinksButton.EditLink ? <LinkMenu location={DocumentLinksButton.EditLinkLoc} docView={DocumentLinksButton.EditLink} addDocTab={DocumentLinksButton.EditLink.props.addDocTab} changeFlyout={emptyFunction} /> : (null)} + {DocumentLinksButton.EditLink ? <LinkMenu docView={DocumentLinksButton.EditLink} addDocTab={DocumentLinksButton.EditLink.props.addDocTab} changeFlyout={emptyFunction} /> : (null)} {LinkDocPreview.LinkInfo ? <LinkDocPreview location={LinkDocPreview.LinkInfo.Location} backgroundColor={this.defaultBackgroundColors} linkDoc={LinkDocPreview.LinkInfo.linkDoc} linkSrc={LinkDocPreview.LinkInfo.linkSrc} href={LinkDocPreview.LinkInfo.href} addDocTab={LinkDocPreview.LinkInfo.addDocTab} /> : (null)} diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 4025e25f9..0e40cd21c 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -407,8 +407,8 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: title: uriList, _width: 400, _height: 315, - _nativeWidth: 600, - _nativeHeight: 472.5 + _nativeWidth: 850, + _nativeHeight: 962 })); return; } diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 6e15cb887..4d1cb670c 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -17,7 +17,7 @@ import { listSpec } from '../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; -import { TraceMobx, GetEffectiveAcl, SharingPermissions } from '../../../fields/util'; +import { TraceMobx, GetEffectiveAcl, SharingPermissions, distributeAcls } from '../../../fields/util'; import { emptyFunction, emptyPath, returnEmptyFilter, returnFalse, returnOne, returnZero, setupMoveUpEvents, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; @@ -148,16 +148,14 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus return false; } else { - // if (this.props.Document[AclSym]) { - // // change so it only adds if more restrictive - // added.forEach(d => { - // // const dataDoc = d[DataSym]; - // for (const [key, value] of Object.entries(this.props.Document[AclSym])) { - // // key.substring(4).replace("_", ".") !== Doc.CurrentUserEmail && distributeAcls(key, this.AclMap.get(value) as SharingPermissions, d, true); - // distributeAcls(key, this.AclMap.get(value) as SharingPermissions, d, true); - // } - // }); - // } + if (this.props.Document[AclSym]) { + added.forEach(d => { + for (const [key, value] of Object.entries(this.props.Document[AclSym])) { + if (d.author === Doc.CurrentUserEmail && !d.aliasOf) distributeAcls(key, SharingPermissions.Admin, d, true); + else distributeAcls(key, this.AclMap.get(value) as SharingPermissions, d, true); + } + }); + } if (effectiveAcl === AclAddonly) { added.map(doc => Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc)); diff --git a/src/client/views/collections/SchemaTable.tsx b/src/client/views/collections/SchemaTable.tsx index 75d484cbe..a974c5496 100644 --- a/src/client/views/collections/SchemaTable.tsx +++ b/src/client/views/collections/SchemaTable.tsx @@ -177,7 +177,6 @@ export class SchemaTable extends React.Component<SchemaTableProps> { } ); } - console.log(columns); const cols = this.props.columns.map(col => { @@ -315,7 +314,6 @@ export class SchemaTable extends React.Component<SchemaTableProps> { width: 28, resizable: false }); - console.log(columns); return columns; } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index bfe569853..3a2979696 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -54,15 +54,15 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo const bfield = afield === "anchor1" ? "anchor2" : "anchor1"; // really hacky stuff to make the LinkAnchorBox display where we want it to: - // if there's an element in the DOM with a classname containing the link's id and a targetids attribute containing the other end of the link, + // if there's an element in the DOM with a classname containing the link's id and a data-targetids attribute containing the other end of the link, // then that DOM element is a hyperlink source for the current anchor and we want to place our link box at it's top right // otherwise, we just use the computed nearest point on the document boundary to the target Document const linkId = this.props.LinkDocs[0][Id]; // this link's Id const AanchorId = (this.props.LinkDocs[0][afield] as Doc)[Id]; // anchor a's id const BanchorId = (this.props.LinkDocs[0][bfield] as Doc)[Id]; // anchor b's id const linkEles = Array.from(window.document.getElementsByClassName(linkId)); - const targetAhyperlink = linkEles.find((ele: any) => ele.getAttribute("targetids")?.includes(AanchorId)); - const targetBhyperlink = linkEles.find((ele: any) => ele.getAttribute("targetids")?.includes(BanchorId)); + const targetAhyperlink = linkEles.find((ele: any) => ele.dataset.targetids?.includes(AanchorId)); + const targetBhyperlink = linkEles.find((ele: any) => ele.dataset.targetids?.includes(BanchorId)); if (!targetBhyperlink) { this.props.A.rootDoc[afield + "_x"] = (apt.point.x - abounds.left) / abounds.width * 100; this.props.A.rootDoc[afield + "_y"] = (apt.point.y - abounds.top) / abounds.height * 100; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 034d32e0a..d9acc3376 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1706,49 +1706,39 @@ class CollectionFreeFormViewPannableContents extends React.Component<CollectionF } @computed get zoomProgressivize() { - if (PresBox.Instance) return ( - <> - {this.props.zoomProgressivize ? this.zoomProgressivizeContainer : (null)} - </> - ); + return PresBox.Instance && this.props.zoomProgressivize ? PresBox.Instance.zoomProgressivizeContainer : (null); } @computed get progressivize() { - if (PresBox.Instance) return ( - <> - {this.props.progressivize ? PresBox.Instance.progressivizeChildDocs : (null)} - </> - ); + return PresBox.Instance && this.props.progressivize ? PresBox.Instance.progressivizeChildDocs : (null); } @computed get presPaths() { const presPaths = "presPaths" + (this.props.presPaths ? "" : "-hidden"); - if (PresBox.Instance) return ( - <> - {!this.props.presPaths ? (null) : <><div>{PresBox.Instance.order}</div> - <svg className={presPaths}> - <defs> - <marker id="arrow" markerWidth="3" overflow="visible" markerHeight="3" refX="5" refY="5" orient="auto" markerUnits="strokeWidth"> - <path d="M0,0 L0,6 L9,3 z" fill="#69a6db" /> - </marker> - <marker id="square" markerWidth="3" markerHeight="3" overflow="visible" - refX="5" refY="5" orient="auto" markerUnits="strokeWidth"> - <path d="M 5,1 L 9,5 5,9 1,5 z" fill="#69a6db" /> - </marker> - <marker id="markerSquare" markerWidth="7" markerHeight="7" refX="4" refY="4" - orient="auto" overflow="visible"> - <rect x="1" y="1" width="5" height="5" fill="#69a6db" /> - </marker> - - <marker id="markerArrow" markerWidth="5" markerHeight="5" refX="2" refY="7" - orient="auto" overflow="visible"> - <path d="M2,2 L2,13 L8,7 L2,2" fill="#69a6db" /> - </marker> - </defs>; + return !(PresBox.Instance) ? (null) : (<> + {!this.props.presPaths ? (null) : <><div>{PresBox.Instance.order}</div> + <svg className={presPaths}> + <defs> + <marker id="arrow" markerWidth="3" overflow="visible" markerHeight="3" refX="5" refY="5" orient="auto" markerUnits="strokeWidth"> + <path d="M0,0 L0,6 L9,3 z" fill="#69a6db" /> + </marker> + <marker id="square" markerWidth="3" markerHeight="3" overflow="visible" + refX="5" refY="5" orient="auto" markerUnits="strokeWidth"> + <path d="M 5,1 L 9,5 5,9 1,5 z" fill="#69a6db" /> + </marker> + <marker id="markerSquare" markerWidth="7" markerHeight="7" refX="4" refY="4" + orient="auto" overflow="visible"> + <rect x="1" y="1" width="5" height="5" fill="#69a6db" /> + </marker> + + <marker id="markerArrow" markerWidth="5" markerHeight="5" refX="2" refY="7" + orient="auto" overflow="visible"> + <path d="M2,2 L2,13 L8,7 L2,2" fill="#69a6db" /> + </marker> + </defs>; {PresBox.Instance.paths} - </svg></>} - </> - ); + </svg></>} + </>); } render() { diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index a32c8b363..88fe03efd 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -339,10 +339,21 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque this._visible = false; } + @undoBatch @action delete = () => { - this.props.removeDocument(this.marqueeSelect(false)); + const recent = Cast(Doc.UserDoc().myRecentlyClosed, Doc) as Doc; + const selected = this.marqueeSelect(false); SelectionManager.DeselectAll(); + + selected.map(doc => { + const effectiveAcl = GetEffectiveAcl(doc); + if (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) { // deletes whatever you have the right to delete + recent && Doc.AddDocToList(recent, "data", doc, undefined, true, true); + this.props.removeDocument(doc); + } + }); + this.cleanupInteractions(false); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); diff --git a/src/client/views/collections/collectionFreeForm/PropertiesView.scss b/src/client/views/collections/collectionFreeForm/PropertiesView.scss index 3ae94efb7..5e0c9fcbb 100644 --- a/src/client/views/collections/collectionFreeForm/PropertiesView.scss +++ b/src/client/views/collections/collectionFreeForm/PropertiesView.scss @@ -119,6 +119,19 @@ font-size: 10px; padding: 10px; margin-left: 5px; + + .change-buttons { + display: flex; + + button { + width: 5; + height: 5; + } + + input { + width: 100%; + } + } } } @@ -233,11 +246,15 @@ .propertiesView-sharingTable { + // whatever's commented out - add it back in when adding the buttons + + // border: 1.5px solid black; border: 1px solid black; - padding: 5px; - border-radius: 6px; - /* width: 170px; */ - margin-right: 10px; + padding: 5px; // remove when adding buttons + border-radius: 6px; // remove when adding buttons + margin-right: 10px; // remove when adding buttons + // width: 100%; + // display: inline-table; background-color: #ececec; max-height: 130px; overflow-y: scroll; @@ -245,9 +262,11 @@ .propertiesView-sharingTable-item { display: flex; + // padding: 5px; padding: 3px; align-items: center; border-bottom: 0.5px solid grey; + cursor: pointer; &:hover .propertiesView-sharingTable-item-name { overflow-x: unset; diff --git a/src/client/views/collections/collectionFreeForm/PropertiesView.tsx b/src/client/views/collections/collectionFreeForm/PropertiesView.tsx index 9c1cbec99..1a8ee3ea1 100644 --- a/src/client/views/collections/collectionFreeForm/PropertiesView.tsx +++ b/src/client/views/collections/collectionFreeForm/PropertiesView.tsx @@ -2,13 +2,11 @@ import React = require("react"); import { observer } from "mobx-react"; import "./PropertiesView.scss"; import { observable, action, computed, runInAction } from "mobx"; -import { Doc, Field, DocListCast, WidthSym, HeightSym, AclSym, AclPrivate, AclReadonly, AclAddonly, AclEdit, AclAdmin, Opt } from "../../../../fields/Doc"; -import { DocumentView } from "../../nodes/DocumentView"; +import { Doc, Field, WidthSym, HeightSym, AclSym, AclPrivate, AclReadonly, AclAddonly, AclEdit, AclAdmin, Opt, DocCastAsync } from "../../../../fields/Doc"; import { ComputedField } from "../../../../fields/ScriptField"; import { EditableView } from "../../EditableView"; import { KeyValueBox } from "../../nodes/KeyValueBox"; import { Cast, NumCast, StrCast } from "../../../../fields/Types"; -import { listSpec } from "../../../../fields/Schema"; import { ContentFittingDocumentView } from "../../nodes/ContentFittingDocumentView"; import { returnFalse, returnOne, emptyFunction, emptyPath, returnTrue, returnZero, returnEmptyFilter, Utils } from "../../../../Utils"; import { Id } from "../../../../fields/FieldSymbols"; @@ -16,23 +14,26 @@ import { Transform } from "../../../util/Transform"; import { PropertiesButtons } from "../../PropertiesButtons"; import { SelectionManager } from "../../../util/SelectionManager"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Tooltip, Checkbox, Divider } from "@material-ui/core"; +import { Tooltip, Checkbox } from "@material-ui/core"; import SharingManager from "../../../util/SharingManager"; import { DocumentType } from "../../../documents/DocumentTypes"; -import FormatShapePane from "./FormatShapePane"; import { SharingPermissions, GetEffectiveAcl } from "../../../../fields/util"; import { InkField } from "../../../../fields/InkField"; import { undoBatch, UndoManager } from "../../../util/UndoManager"; import { ColorState, SketchPicker } from "react-color"; -import AntimodeMenu from "../../AntimodeMenu"; import "./FormatShapePane.scss"; import { discovery_v1 } from "googleapis"; import { PresBox } from "../../nodes/PresBox"; import { DocumentManager } from "../../../util/DocumentManager"; +import FormatShapePane from "./FormatShapePane"; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; +// import * as fa from '@fortawesome/free-solid-svg-icons'; +// import { library } from "@fortawesome/fontawesome-svg-core"; + +// library.add(fa.faPlus, fa.faMinus, fa.faCog); interface PropertiesViewProps { width: number; @@ -70,6 +71,9 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { @observable openLayout: boolean = true; @observable openAppearance: boolean = true; @observable openTransform: boolean = true; + // @observable selectedUser: string = ""; + // @observable addButtonPressed: boolean = false; + //Pres Trails booleans: @observable openPresTransitions: boolean = false; @observable openPresProgressivize: boolean = false; @@ -288,13 +292,20 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { } } + /** + * Handles the changing of a user's permissions from the permissions panel. + */ @undoBatch changePermissions = (e: any, user: string) => { SharingManager.Instance.shareFromPropertiesSidebar(user, e.currentTarget.value as SharingPermissions, this.selectedDoc!); } - getPermissionsSelect(user: string) { + /** + * @returns the options for the permissions dropdown. + */ + getPermissionsSelect(user: string, permission: string) { return <select className="permissions-select" + defaultValue={permission} onChange={e => this.changePermissions(e, user)}> {Object.values(SharingPermissions).map(permission => { return ( @@ -305,6 +316,9 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { </select>; } + /** + * @returns the notification icon. On clicking, it should notify someone of a document been shared with them. + */ @computed get notifyIcon() { return <Tooltip title={<><div className="dash-tooltip">Notify with message</div></>}> <div className="notify-button"> @@ -313,6 +327,9 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { </Tooltip>; } + /** + * ... next to the owner that opens the main SharingManager interface on click. + */ @computed get expansionIcon() { return <Tooltip title={<><div className="dash-tooltip">{"Show more permissions"}</div></>}> <div className="expansion-button" onPointerDown={() => { @@ -325,17 +342,26 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { </Tooltip>; } - sharingItem(name: string, effectiveAcl: symbol, permission?: string) { - return <div className="propertiesView-sharingTable-item"> + /** + * @returns a row of the permissions panel + */ + sharingItem(name: string, effectiveAcl: symbol, permission: string) { + return <div className="propertiesView-sharingTable-item" + // style={{ backgroundColor: this.selectedUser === name ? "#bcecfc" : "" }} + // onPointerDown={action(() => this.selectedUser = this.selectedUser === name ? "" : name)} + > <div className="propertiesView-sharingTable-item-name" style={{ width: name !== "Me" ? "85px" : "80px" }}> {name} </div> {/* {name !== "Me" ? this.notifyIcon : null} */} <div className="propertiesView-sharingTable-item-permission"> - {effectiveAcl === AclAdmin && permission !== "Owner" ? this.getPermissionsSelect(name) : permission} + {effectiveAcl === AclAdmin && permission !== "Owner" ? this.getPermissionsSelect(name, permission) : permission} {permission === "Owner" ? this.expansionIcon : null} </div> </div>; } + /** + * @returns the sharing and permissiosn panel. + */ @computed get sharingTable() { const AclMap = new Map<symbol, string>([ [AclPrivate, SharingPermissions.None], @@ -348,13 +374,22 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { const effectiveAcl = GetEffectiveAcl(this.selectedDoc!); const tableEntries = []; + // DocCastAsync(Doc.UserDoc().sidebarUsersDisplayed).then(sidebarUsersDisplayed => { if (this.selectedDoc![AclSym]) { for (const [key, value] of Object.entries(this.selectedDoc![AclSym])) { const name = key.substring(4).replace("_", "."); - if (name !== Doc.CurrentUserEmail && name !== this.selectedDoc!.author) tableEntries.push(this.sharingItem(name, effectiveAcl, AclMap.get(value)!)); + if (name !== Doc.CurrentUserEmail && name !== this.selectedDoc!.author/* && sidebarUsersDisplayed![name] !== false*/) tableEntries.push(this.sharingItem(name, effectiveAcl, AclMap.get(value)!)); } } + // if (Doc.UserDoc().sidebarUsersDisplayed) { + // for (const [name, value] of Object.entries(sidebarUsersDisplayed!)) { + // if (value === true && !this.selectedDoc![`ACL-${name.substring(8).replace(".", "_")}`]) tableEntries.push(this.sharingItem(name.substring(8), effectiveAcl, SharingPermissions.None)); + // } + // } + // }) + + // shifts the current user and the owner to the top of the doc. tableEntries.unshift(this.sharingItem("Me", effectiveAcl, Doc.CurrentUserEmail === this.selectedDoc!.author ? "Owner" : StrCast(this.selectedDoc![`ACL-${Doc.CurrentUserEmail.replace(".", "_")}`]))); if (Doc.CurrentUserEmail !== this.selectedDoc!.author) tableEntries.unshift(this.sharingItem(StrCast(this.selectedDoc!.author), effectiveAcl, "Owner")); @@ -753,8 +788,18 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { </div>; } - render() { + /** + * Handles adding and removing members from the sharing panel + */ + // handleUserChange = (selectedUser: string, add: boolean) => { + // if (!Doc.UserDoc().sidebarUsersDisplayed) Doc.UserDoc().sidebarUsersDisplayed = new Doc; + // DocCastAsync(Doc.UserDoc().sidebarUsersDisplayed).then(sidebarUsersDisplayed => { + // sidebarUsersDisplayed![`display-${selectedUser}`] = add; + // !add && runInAction(() => this.selectedUser = ""); + // }); + // } + render() { if (!this.selectedDoc && !this.isPres) { return <div className="propertiesView" style={{ width: this.props.width }}> <div className="propertiesView-title" style={{ width: this.props.width }}> @@ -806,6 +851,36 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { {!this.openSharing ? (null) : <div className="propertiesView-sharing-content"> {this.sharingTable} + {/* <div className="change-buttons"> + <button + onPointerDown={action(() => this.addButtonPressed = !this.addButtonPressed)} + > + <FontAwesomeIcon icon={fa.faPlus} size={"sm"} style={{ marginTop: -3, marginLeft: -3 }} /> + </button> + <button + id="sharingProperties-removeUser" + onPointerDown={() => this.handleUserChange(this.selectedUser, false)} + style={{ backgroundColor: this.selectedUser ? "#121721" : "#777777" }} + ><FontAwesomeIcon icon={fa.faMinus} size={"sm"} style={{ marginTop: -3, marginLeft: -3 }} /></button> + <button onClick={() => SharingManager.Instance.open(this.selectedDocumentView!)}><FontAwesomeIcon icon={fa.faCog} size={"sm"} style={{ marginTop: -3, marginLeft: -3 }} /></button> + {this.addButtonPressed ? + // <input type="text" onKeyDown={this.handleKeyPress} /> : + <select onChange={e => this.handleUserChange(e.target.value, true)}> + <option selected disabled hidden> + Add users + </option> + {SharingManager.Instance.users.map(user => + (<option value={user.user.email}> + {user.user.email} + </option>) + )} + {GroupManager.Instance.getAllGroups().map(group => + (<option value={StrCast(group.groupName)}> + {StrCast(group.groupName)} + </option>))} + </select> : + null} + </div> */} </div>} </div> @@ -884,9 +959,9 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { <div className="propertiesView-name"> {this.editableTitle} <div className="propertiesView-presSelected"> - {PresBox.Instance._selectedArray.length} selected + {PresBox.Instance?._selectedArray.length} selected <div className="propertiesView-selectedList"> - {PresBox.Instance.listOfSelected} + {PresBox.Instance?.listOfSelected} </div> </div> </div> diff --git a/src/client/views/linking/LinkMenu.scss b/src/client/views/linking/LinkMenu.scss index 98e4171f0..4dc25031d 100644 --- a/src/client/views/linking/LinkMenu.scss +++ b/src/client/views/linking/LinkMenu.scss @@ -6,6 +6,7 @@ position: absolute; top: 0; left: 0; + z-index: 999; .linkMenu-list { diff --git a/src/client/views/linking/LinkMenu.tsx b/src/client/views/linking/LinkMenu.tsx index 7b5fb0127..8ecde959f 100644 --- a/src/client/views/linking/LinkMenu.tsx +++ b/src/client/views/linking/LinkMenu.tsx @@ -4,14 +4,13 @@ import { DocumentView } from "../nodes/DocumentView"; import { LinkEditor } from "./LinkEditor"; import './LinkMenu.scss'; import React = require("react"); -import { Doc, Opt } from "../../../fields/Doc"; +import { Doc } from "../../../fields/Doc"; import { LinkManager } from "../../util/LinkManager"; import { LinkMenuGroup } from "./LinkMenuGroup"; import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { library } from "@fortawesome/fontawesome-svg-core"; import { DocumentLinksButton } from "../nodes/DocumentLinksButton"; import { LinkDocPreview } from "../nodes/LinkDocPreview"; -import { isUndefined } from "util"; library.add(faTrash); @@ -19,7 +18,6 @@ interface Props { docView: DocumentView; changeFlyout: () => void; addDocTab: (document: Doc, where: string) => boolean; - location: number[]; } @observer @@ -85,17 +83,25 @@ export class LinkMenu extends React.Component<Props> { return linkItems; } + @computed + get position() { + const docView = this.props.docView; + const transform = (docView.props.ScreenToLocalTransform().scale(docView.props.ContentScaling())).inverse(); + const [sptX, sptY] = transform.transformPoint(0, 0); + const [bptX, bptY] = transform.transformPoint(docView.props.PanelWidth(), docView.props.PanelHeight()); + return { x: sptX, y: sptY, r: bptX, b: bptY }; + } + render() { + console.log("computed", this.position.x, this.position.b); const sourceDoc = this.props.docView.props.Document; const groups: Map<string, Doc[]> = LinkManager.Instance.getRelatedGroupedLinks(sourceDoc); return <div className="linkMenu" ref={this._linkMenuRef} > - {!this._editingLink ? <div className="linkMenu-list" style={{ - left: this.props.location[0], top: this.props.location[1] - }}> - {this.renderAllGroups(groups)} - </div> : <div className="linkMenu-listEditor" style={{ - left: this.props.location[0], top: this.props.location[1] - }}> + {!this._editingLink ? + <div className="linkMenu-list" style={{ left: this.position.x, top: this.position.b + 15 }}> + {this.renderAllGroups(groups)} + </div> : + <div className="linkMenu-listEditor" style={{ left: this.position.x, top: this.position.b + 15 }}> <LinkEditor sourceDoc={this.props.docView.props.Document} linkDoc={this._editingLink} showLinks={action(() => this._editingLink = undefined)} /> </div> diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index e9420a072..306062ced 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -46,6 +46,40 @@ width: 100%; height: 100%; position: relative; + + + } + + .recording { + margin-top: auto; + margin-bottom: auto; + width: 100%; + height: 100%; + position: relative; + padding-right: 5px; + display: flex; + background-color: red; + + .time { + position: relative; + height: 100%; + width: 100%; + font-size: 20; + text-align: center; + top: 5; + } + + .buttons { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + padding: 5px; + } + + .buttons:hover { + background-color: crimson; + } } .audiobox-controls { @@ -54,6 +88,17 @@ position: relative; display: flex; padding-left: 2px; + background: black; + + .audiobox-dictation { + position: absolute; + width: 30px; + height: 100%; + align-items: center; + display: inherit; + background: dimgray; + left: 0px; + } .audiobox-player { margin-top: auto; @@ -64,16 +109,32 @@ padding-right: 5px; display: flex; - .audiobox-playhead, - .audiobox-dictation { + .audiobox-playhead { position: relative; margin-top: auto; margin-bottom: auto; - width: 25px; + margin-right: 2px; + width: 30px; + height: 25px; padding: 2px; + border-radius: 50%; + background-color: black; + color: white; + } + + .audiobox-playhead:hover { + // background-color: black; + // border-radius: 5px; + background-color: grey; + color: lightgrey; } .audiobox-dictation { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + padding: 2px; align-items: center; display: inherit; background: dimgray; @@ -81,17 +142,29 @@ .audiobox-timeline { position: relative; - height: 100%; + height: 80%; width: 100%; background: white; border: gray solid 1px; border-radius: 3px; + z-index: 1000; + overflow: hidden; .audiobox-current { width: 1px; height: 100%; background-color: red; position: absolute; + top: 0px; + } + + .waveform { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + z-index: -1000; + bottom: -30%; } .audiobox-linker, @@ -104,7 +177,6 @@ background: gray; border-radius: 100%; opacity: 0.9; - background-color: transparent; box-shadow: black 2px 2px 1px; .linkAnchorBox-cont { @@ -142,11 +214,37 @@ .audiobox-marker-minicontainer { position: absolute; width: 10px; + height: 10px; + top: 2.5%; + background: gray; + border-radius: 50%; + box-shadow: black 2px 2px 1px; + overflow: visible; + cursor: pointer; + + .audiobox-marker { + position: relative; + height: 100%; + // height: calc(100% - 15px); + width: 100%; + //margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + } + + .audiobox-marker-container1, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; height: 90%; top: 2.5%; background: gray; border-radius: 5px; box-shadow: black 2px 2px 1px; + opacity: 0.3; .audiobox-marker { position: relative; @@ -157,6 +255,36 @@ .audio-marker:hover { border: orange 2px solid; } + + .resizer { + position: absolute; + right: 0; + cursor: ew-resize; + height: 100%; + width: 2px; + z-index: 100; + } + + .click { + position: relative; + height: 100%; + width: 100%; + z-index: 100; + } + + .left-resizer { + position: absolute; + left: 0; + cursor: ew-resize; + height: 100%; + width: 2px; + z-index: 100; + } + } + + .audiobox-marker-container1:hover, + .audiobox-marker-minicontainer:hover { + opacity: 0.8; } .audiobox-marker-minicontainer { @@ -170,6 +298,22 @@ } } } + + .current-time { + position: absolute; + font-size: 8; + top: calc(100% - 8px); + left: 30px; + color: white; + } + + .total-time { + position: absolute; + top: calc(100% - 8px); + font-size: 8; + right: 2px; + color: white; + } } } } diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 2396e6973..eba1046b2 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -2,31 +2,30 @@ import React = require("react"); import { FieldViewProps, FieldView } from './FieldView'; import { observer } from "mobx-react"; import "./AudioBox.scss"; -import { Cast, DateCast, NumCast } from "../../../fields/Types"; +import { Cast, DateCast, NumCast, FieldValue, ScriptCast } from "../../../fields/Types"; import { AudioField, nullAudio } from "../../../fields/URLField"; -import { ViewBoxBaseComponent } from "../DocComponent"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { makeInterface, createSchema } from "../../../fields/Schema"; import { documentSchema } from "../../../fields/documentSchemas"; -import { Utils, returnTrue, emptyFunction, returnOne, returnTransparent, returnFalse, returnZero } from "../../../Utils"; -import { runInAction, observable, reaction, IReactionDisposer, computed, action } from "mobx"; +import { Utils, returnTrue, emptyFunction, returnOne, returnTransparent, returnFalse, returnZero, formatTime } from "../../../Utils"; +import { runInAction, observable, reaction, IReactionDisposer, computed, action, trace, toJS } from "mobx"; import { DateField } from "../../../fields/DateField"; import { SelectionManager } from "../../util/SelectionManager"; -import { Doc, DocListCast } from "../../../fields/Doc"; +import { Doc, DocListCast, Opt } from "../../../fields/Doc"; import { ContextMenuProps } from "../ContextMenuItem"; import { ContextMenu } from "../ContextMenu"; import { Id } from "../../../fields/FieldSymbols"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { DocumentView } from "./DocumentView"; import { Docs, DocUtils } from "../../documents/Documents"; -import { ComputedField } from "../../../fields/ScriptField"; +import { ComputedField, ScriptField } from "../../../fields/ScriptField"; import { Networking } from "../../Network"; import { LinkAnchorBox } from "./LinkAnchorBox"; - -// testing testing - -interface Window { - MediaRecorder: MediaRecorder; -} +import { List } from "../../../fields/List"; +import { Scripting } from "../../util/Scripting"; +import Waveform from "react-audio-waveform"; +import axios from "axios"; +const _global = (window /* browser */ || global /* node */) as any; declare class MediaRecorder { // whatever MediaRecorder has @@ -40,21 +39,42 @@ type AudioDocument = makeInterface<[typeof documentSchema, typeof audioSchema]>; const AudioDocument = makeInterface(documentSchema, audioSchema); @observer -export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument>(AudioDocument) { +export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioDocument>(AudioDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(AudioBox, fieldKey); } public static Enabled = false; + static Instance: AudioBox; + static RangeScript: ScriptField; + static LabelScript: ScriptField; + _linkPlayDisposer: IReactionDisposer | undefined; _reactionDisposer: IReactionDisposer | undefined; _scrubbingDisposer: IReactionDisposer | undefined; _ele: HTMLAudioElement | null = null; _recorder: any; _recordStart = 0; + _pauseStart = 0; + _pauseEnd = 0; + _pausedTime = 0; _stream: MediaStream | undefined; - constructor(props: any) { - super(props); - AudioBox.Instance = this; - } + _start: number = 0; + _hold: boolean = false; + _left: boolean = false; + _markers: Array<any> = []; + _first: boolean = false; + _dragging = false; + + _count: Array<any> = []; + _timeline: Opt<HTMLDivElement>; + _duration = 0; + + private _isPointerDown = false; + private _currMarker: any; + + @observable _position: number = 0; + @observable _buckets: Array<number> = new Array<number>(); + @observable private _height: number = NumCast(this.layoutDoc._height); + @observable private _paused: boolean = false; @observable private static _scrubTime = 0; @computed get audioState(): undefined | "recording" | "paused" | "playing" { return this.dataDoc.audioState as (undefined | "recording" | "paused" | "playing"); } set audioState(value) { this.dataDoc.audioState = value; } @@ -62,12 +82,29 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } async slideTemplate() { return (await Cast((await Cast(Doc.UserDoc().slidesBtn, Doc) as Doc).dragFactory, Doc) as Doc); } + constructor(props: Readonly<FieldViewProps>) { + super(props); + + // onClick play script + if (!AudioBox.RangeScript) { + AudioBox.RangeScript = ScriptField.MakeScript(`scriptContext.playFrom((this.audioStart), (this.audioEnd))`, { scriptContext: "any" })!; + } + + if (!AudioBox.LabelScript) { + AudioBox.LabelScript = ScriptField.MakeScript(`scriptContext.playFrom((this.audioStart))`, { scriptContext: "any" })!; + } + } + componentWillUnmount() { this._reactionDisposer?.(); this._linkPlayDisposer?.(); this._scrubbingDisposer?.(); } componentDidMount() { + if (!this.dataDoc.markerAmount) { + this.dataDoc.markerAmount = 0; + } + runInAction(() => this.audioState = this.path ? "paused" : undefined); this._linkPlayDisposer = reaction(() => this.layoutDoc.scrollToLinkID, scrollLinkId => { @@ -79,15 +116,59 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument Doc.SetInPlace(this.layoutDoc, "scrollToLinkID", undefined, false); } }, { fireImmediately: true }); + + // for play when link is selected this._reactionDisposer = reaction(() => SelectionManager.SelectedDocuments(), selected => { const sel = selected.length ? selected[0].props.Document : undefined; - this.layoutDoc.playOnSelect && this.recordingStart && sel && sel.creationDate && !Doc.AreProtosEqual(sel, this.props.Document) && this.playFromTime(DateCast(sel.creationDate).date.getTime()); - this.layoutDoc.playOnSelect && this.recordingStart && !sel && this.pause(); + let link; + if (sel) { + // for determining if the link is created after recording (since it will use linkTime rather than creation date) + DocListCast(this.dataDoc.links).map((l, i) => { + let la1 = l.anchor1 as Doc; + let la2 = l.anchor2 as Doc; + if (la1 === sel || la2 === sel) { // if the selected document is linked to this audio + let linkTime = NumCast(l.anchor2_timecode); + let endTime; + if (Doc.AreProtosEqual(la1, this.dataDoc)) { + la1 = l.anchor2 as Doc; + la2 = l.anchor1 as Doc; + linkTime = NumCast(l.anchor1_timecode); + } + if (la2.audioStart) { + linkTime = NumCast(la2.audioStart); + } + + if (la1.audioStart) { + linkTime = NumCast(la1.audioStart); + } + + if (la1.audioEnd) { + endTime = NumCast(la1.audioEnd); + } + + if (la2.audioEnd) { + endTime = NumCast(la2.audioEnd); + } + + if (linkTime) { + link = true; + this.layoutDoc.playOnSelect && this.recordingStart && sel && !Doc.AreProtosEqual(sel, this.props.Document) && (endTime ? this.playFrom(linkTime, endTime) : this.playFrom(linkTime)); + } + } + }); + } + + // for links created during recording + if (!link) { + this.layoutDoc.playOnSelect && this.recordingStart && sel && sel.creationDate && !Doc.AreProtosEqual(sel, this.props.Document) && this.playFromTime(DateCast(sel.creationDate).date.getTime()); + this.layoutDoc.playOnSelect && this.recordingStart && !sel && this.pause(); + } }); this._scrubbingDisposer = reaction(() => AudioBox._scrubTime, (time) => this.layoutDoc.playOnSelect && this.playFromTime(AudioBox._scrubTime)); } + // for updating the timecode timecodeChanged = () => { const htmlEle = this._ele; if (this.audioState !== "recording" && htmlEle) { @@ -107,15 +188,23 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument } } + // pause play back pause = action(() => { this._ele!.pause(); this.audioState = "paused"; }); + // play audio for documents created during recording playFromTime = (absoluteTime: number) => { this.recordingStart && this.playFrom((absoluteTime - this.recordingStart) / 1000); } - playFrom = (seekTimeInSeconds: number) => { + + // play back the audio from time + @action + playFrom = (seekTimeInSeconds: number, endTime: number = this.dataDoc.duration) => { + let play; + clearTimeout(play); + this._duration = endTime - seekTimeInSeconds; if (this._ele && AudioBox.Enabled) { if (seekTimeInSeconds < 0) { if (seekTimeInSeconds > -1) { @@ -127,20 +216,29 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument this._ele.currentTime = seekTimeInSeconds; this._ele.play(); runInAction(() => this.audioState = "playing"); + if (endTime !== this.dataDoc.duration) { + play = setTimeout(() => this.pause(), (this._duration) * 1000); // use setTimeout to play a specific duration + } } else { this.pause(); } } } - + // update the recording time updateRecordTime = () => { if (this.audioState === "recording") { - setTimeout(this.updateRecordTime, 30); - this.layoutDoc.currentTimecode = (new Date().getTime() - this._recordStart) / 1000; + if (this._paused) { + setTimeout(this.updateRecordTime, 30); + this._pausedTime += (new Date().getTime() - this._recordStart) / 1000; + } else { + setTimeout(this.updateRecordTime, 30); + this.layoutDoc.currentTimecode = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + } } } + // starts recording recordAudioAnnotation = async () => { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this._recorder = new MediaRecorder(this._stream); @@ -156,26 +254,31 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument runInAction(() => this.audioState = "recording"); setTimeout(this.updateRecordTime, 0); this._recorder.start(); - setTimeout(() => this._recorder && this.stopRecording(), 60 * 1000); // stop after an hour + setTimeout(() => this._recorder && this.stopRecording(), 60 * 60 * 1000); // stop after an hour } + // context menu specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ description: (this.layoutDoc.playOnSelect ? "Don't play" : "Play") + " when document selected", event: () => this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect, icon: "expand-arrows-alt" }); - + funcs.push({ description: (this.layoutDoc.playOnSelect ? "Don't play" : "Play") + " when link is selected", event: () => this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.layoutDoc.hideMarkers ? "Don't hide" : "Hide") + " markers", event: () => this.layoutDoc.hideMarkers = !this.layoutDoc.hideMarkers, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.layoutDoc.hideLabels ? "Don't hide" : "Hide") + " labels", event: () => this.layoutDoc.hideLabels = !this.layoutDoc.hideLabels, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.layoutDoc.playOnClick ? "Don't play" : "Play") + " markers onClick", event: () => this.layoutDoc.playOnClick = !this.layoutDoc.playOnClick, icon: "expand-arrows-alt" }); ContextMenu.Instance?.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } + // stops the recording stopRecording = action(() => { this._recorder.stop(); this._recorder = undefined; - this.dataDoc.duration = (new Date().getTime() - this._recordStart) / 1000; + this.dataDoc.duration = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; this.audioState = "paused"; this._stream?.getAudioTracks()[0].stop(); const ind = DocUtils.ActiveRecordings.indexOf(this.props.Document); ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); }); + // button for starting and stopping the recording recordClick = (e: React.MouseEvent) => { if (e.button === 0 && !e.ctrlKey) { this._recorder ? this.stopRecording() : this.recordAudioAnnotation(); @@ -183,14 +286,13 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument } } + // for play button onPlay = (e: any) => { this.playFrom(this._ele!.paused ? this._ele!.currentTime : -1); e.stopPropagation(); } - onStop = (e: any) => { - this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect; - e.stopPropagation(); - } + + // creates a text document for dictation onFile = (e: any) => { const newDoc = Docs.Create.TextDocument("", { title: "", _chromeStatus: "disabled", @@ -204,18 +306,21 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument e.stopPropagation(); } + // ref for updating time setRef = (e: HTMLAudioElement | null) => { e?.addEventListener("timeupdate", this.timecodeChanged); e?.addEventListener("ended", this.pause); this._ele = e; } + // returns the path of the audio file @computed get path() { const field = Cast(this.props.Document[this.props.fieldKey], AudioField); const path = (field instanceof AudioField) ? field.url.href : ""; return path === nullAudio ? "" : path; } + // returns the html audio element @computed get audio() { const interactive = this.active() ? "-interactive" : ""; return <audio ref={this.setRef} className={`audiobox-control${interactive}`}> @@ -224,33 +329,390 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument </audio>; } + // pause the time during recording phase + @action + recordPause = (e: React.MouseEvent) => { + this._pauseStart = new Date().getTime(); + this._paused = true; + this._recorder.pause(); + e.stopPropagation(); + + } + + // continue the recording + @action + recordPlay = (e: React.MouseEvent) => { + this._pauseEnd = new Date().getTime(); + this._paused = false; + this._recorder.resume(); + e.stopPropagation(); + + } + + // return the total time paused to update the correct time + @computed get pauseTime() { + return (this._pauseEnd - this._pauseStart); + } + + // creates a new label + @action + newMarker(marker: Doc) { + marker.data = ""; + if (this.dataDoc[this.annotationKey]) { + this.dataDoc[this.annotationKey].push(marker); + } else { + this.dataDoc[this.annotationKey] = new List<Doc>([marker]); + } + } + + // the starting time of the marker + start(startingPoint: number) { + this._hold = true; + this._start = startingPoint; + } + + // creates a new marker + @action + end(marker: number) { + this._hold = false; + const newMarker = Docs.Create.LabelDocument({ title: ComputedField.MakeFunction(`formatToTime(self.audioStart) + "-" + formatToTime(self.audioEnd)`) as any, isLabel: false, useLinkSmallAnchor: true, hideLinkButton: true, audioStart: this._start, audioEnd: marker, _showSidebar: false, _autoHeight: true, annotationOn: this.props.Document }); + newMarker.data = ""; + if (this.dataDoc[this.annotationKey]) { + this.dataDoc[this.annotationKey].push(newMarker); + } else { + this.dataDoc[this.annotationKey] = new List<Doc>([newMarker]); + } + + this._start = 0; + } + + // starting the drag event for marker resizing + onPointerDown = (e: React.PointerEvent, m: any, left: boolean): void => { + e.stopPropagation(); + e.preventDefault(); + this._isPointerDown = true; + this._currMarker = m; + this._timeline?.setPointerCapture(e.pointerId); + this._left = left; + + document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); + } + + // ending the drag event for marker resizing + @action + onPointerUp = (e: PointerEvent): void => { + e.stopPropagation(); + e.preventDefault(); + this._isPointerDown = false; + this._dragging = false; + + const rect = (e.target as any).getBoundingClientRect(); + this._ele!.currentTime = this.layoutDoc.currentTimecode = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); + + this._timeline?.releasePointerCapture(e.pointerId); + + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + } + + // resizes the marker while dragging + onPointerMove = async (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (!this._isPointerDown) { + return; + } + + const rect = await (e.target as any).getBoundingClientRect(); + + const newTime = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); + + this.changeMarker(this._currMarker, newTime); + } + + // updates the marker with the new time + @action + changeMarker = (m: any, time: any) => { + DocListCast(this.dataDoc[this.annotationKey]).forEach((marker: Doc) => { + if (this.isSame(marker, m)) { + this._left ? marker.audioStart = time : marker.audioEnd = time; + } + }); + } + + // checks if the two markers are the same with start and end time + isSame = (m1: any, m2: any) => { + if (m1.audioStart === m2.audioStart && m1.audioEnd === m2.audioEnd) { + return true; + } + return false; + } + + // instantiates a new array of size 500 for marker layout + markers = () => { + const increment = NumCast(this.layoutDoc.duration) / 500; + this._count = []; + for (let i = 0; i < 500; i++) { + this._count.push([increment * i, 0]); + } + + } + + // makes sure no markers overlaps each other by setting the correct position and width + isOverlap = (m: any) => { + if (this._first) { + this._first = false; + this.markers(); + } + let max = 0; + + for (let i = 0; i < 500; i++) { + if (this._count[i][0] >= m.audioStart && this._count[i][0] <= m.audioEnd) { + this._count[i][1]++; + + if (this._count[i][1] > max) { + max = this._count[i][1]; + } + } + } + + for (let i = 0; i < 500; i++) { + if (this._count[i][0] >= m.audioStart && this._count[i][0] <= m.audioEnd) { + this._count[i][1] = max; + } + + } + + if (this.dataDoc.markerAmount < max) { + this.dataDoc.markerAmount = max; + } + return max - 1; + } + + // returns the audio waveform + @computed get waveform() { + return <Waveform + color={"darkblue"} + height={this._height} + barWidth={0.1} + // pos={this.layoutDoc.currentTimecode} + pos={this.dataDoc.duration} + duration={this.dataDoc.duration} + peaks={this._buckets.length === 100 ? this._buckets : undefined} + progressColor={"blue"} />; + } + + // decodes the audio file into peaks for generating the waveform + @action + buckets = async () => { + const audioCtx = new (window.AudioContext)(); + + axios({ url: this.path, responseType: "arraybuffer" }) + .then(response => { + const audioData = response.data; + + audioCtx.decodeAudioData(audioData, action(buffer => { + const decodedAudioData = buffer.getChannelData(0); + const NUMBER_OF_BUCKETS = 100; + const bucketDataSize = Math.floor(decodedAudioData.length / NUMBER_OF_BUCKETS); + + for (let i = 0; i < NUMBER_OF_BUCKETS; i++) { + const startingPoint = i * bucketDataSize; + const endingPoint = i * bucketDataSize + bucketDataSize; + let max = 0; + for (let j = startingPoint; j < endingPoint; j++) { + if (decodedAudioData[j] > max) { + max = decodedAudioData[j]; + } + } + const size = Math.abs(max); + this._buckets.push(size / 2); + } + + })); + }); + } + + // Returns the peaks of the audio waveform + @computed get peaks() { + return this.buckets(); + } + + // for updating the width and height of the waveform with timeline ref + timelineRef = (timeline: HTMLDivElement) => { + const observer = new _global.ResizeObserver(action((entries: any) => { + for (const entry of entries) { + this.update(entry.contentRect.width, entry.contentRect.height); + this._position = entry.contentRect.width; + } + })); + timeline && observer.observe(timeline); + + this._timeline = timeline; + } + + // update the width and height of the audio waveform + @action + update = (width: number, height: number) => { + if (height) { + this._height = 0.8 * NumCast(this.layoutDoc._height); + const canvas2 = document.getElementsByTagName("canvas")[0]; + if (canvas2) { + const oldWidth = canvas2.width; + const oldHeight = canvas2.height; + canvas2.style.height = `${this._height}`; + canvas2.style.width = `${width}`; + + const ratio1 = oldWidth / window.innerWidth; + const ratio2 = oldHeight / window.innerHeight; + const context = canvas2.getContext('2d'); + if (context) { + context.scale(ratio1, ratio2); + } + } + + const canvas1 = document.getElementsByTagName("canvas")[1]; + if (canvas1) { + const oldWidth = canvas1.width; + const oldHeight = canvas1.height; + canvas1.style.height = `${this._height}`; + canvas1.style.width = `${width}`; + + const ratio1 = oldWidth / window.innerWidth; + const ratio2 = oldHeight / window.innerHeight; + const context = canvas1.getContext('2d'); + if (context) { + context.scale(ratio1, ratio2); + } + + const parent = canvas1.parentElement; + if (parent) { + parent.style.width = `${width}`; + parent.style.height = `${this._height}`; + } + } + } + } + + rangeScript = () => AudioBox.RangeScript; + + labelScript = () => AudioBox.LabelScript; + + // for indicating the first marker that is rendered + reset = () => this._first = true; + render() { const interactive = this.active() ? "-interactive" : ""; + this.reset(); + this.path && this._buckets.length !== 100 ? this.peaks : null; // render waveform if audio is done recording return <div className={`audiobox-container`} onContextMenu={this.specificContextMenu} onClick={!this.path ? this.recordClick : undefined}> {!this.path ? <div className="audiobox-buttons"> <div className="audiobox-dictation" onClick={this.onFile}> <FontAwesomeIcon style={{ width: "30px", background: this.layoutDoc.playOnSelect ? "yellow" : "rgba(0,0,0,0)" }} icon="file-alt" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> </div> - <button className={`audiobox-record${interactive}`} style={{ backgroundColor: this.audioState === "recording" ? "red" : "black" }}> - {this.audioState === "recording" ? "STOP" : "RECORD"} - </button> + {this.audioState === "recording" ? + <div className="recording" onClick={e => e.stopPropagation()}> + <div className="buttons" onClick={this.recordClick}> + <FontAwesomeIcon style={{ width: "100%" }} icon={"stop"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + </div> + <div className="buttons" onClick={this._paused ? this.recordPlay : this.recordPause}> + <FontAwesomeIcon style={{ width: "100%" }} icon={this._paused ? "play" : "pause"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + </div> + <div className="time">{formatTime(Math.round(NumCast(this.layoutDoc.currentTimecode)))}</div> + </div> + : + <button className={`audiobox-record${interactive}`} style={{ backgroundColor: "black" }}> + RECORD + </button>} </div> : - <div className="audiobox-controls"> - <div className="audiobox-player" onClick={this.onPlay}> - <div className="audiobox-playhead"> <FontAwesomeIcon style={{ width: "100%" }} icon={this.audioState === "paused" ? "play" : "pause"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> - <div className="audiobox-playhead" onClick={this.onStop}><FontAwesomeIcon style={{ width: "100%", background: this.layoutDoc.playOnSelect ? "yellow" : "dimGray" }} icon="hand-point-left" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> - <div className="audiobox-timeline" onClick={e => e.stopPropagation()} + <div className="audiobox-controls" > + <div className="audiobox-dictation"></div> + <div className="audiobox-player" > + <div className="audiobox-playhead" title={this.audioState === "paused" ? "play" : "pause"} onClick={this.onPlay}> <FontAwesomeIcon style={{ width: "100%", position: "absolute", left: "0px", top: "5px", borderWidth: "thin", borderColor: "white" }} icon={this.audioState === "paused" ? "play" : "pause"} size={"1x"} /></div> + <div className="audiobox-timeline" ref={this.timelineRef} onClick={e => { e.stopPropagation(); e.preventDefault(); }} onPointerDown={e => { + e.stopPropagation(); + e.preventDefault(); if (e.button === 0 && !e.ctrlKey) { const rect = (e.target as any).getBoundingClientRect(); - const wasPaused = this.audioState === "paused"; + + if (e.target as HTMLElement !== document.getElementById("current")) { + const wasPaused = this.audioState === "paused"; + this._ele!.currentTime = this.layoutDoc.currentTimecode = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); + wasPaused && this.pause(); + } + } + if (e.button === 0 && e.altKey) { + this.newMarker(Docs.Create.LabelDocument({ title: ComputedField.MakeFunction(`formatToTime(self.audioStart)`) as any, useLinkSmallAnchor: true, hideLinkButton: true, isLabel: true, audioStart: this._ele!.currentTime, _showSidebar: false, _autoHeight: true, annotationOn: this.props.Document })); + } + + if (e.button === 0 && e.shiftKey) { + const rect = (e.target as any).getBoundingClientRect(); this._ele!.currentTime = this.layoutDoc.currentTimecode = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); - wasPaused && this.pause(); - e.stopPropagation(); + this._hold ? this.end(this._ele!.currentTime) : this.start(this._ele!.currentTime); } - }} > + }}> + <div className="waveform" id="waveform" style={{ height: `${100}%`, width: "100%", bottom: "0px" }}> + {this.waveform} + </div> + {DocListCast(this.dataDoc[this.annotationKey]).map((m, i) => { + let rect; + (!m.isLabel) ? + (this.layoutDoc.hideMarkers) ? (null) : + rect = + <div key={i} id={"audiobox-marker-container1"} className={this.props.PanelHeight() < 32 ? "audiobox-marker-minicontainer" : "audiobox-marker-container1"} + title={`${formatTime(Math.round(NumCast(m.audioStart)))}` + " - " + `${formatTime(Math.round(NumCast(m.audioEnd)))}`} + style={{ + left: `${NumCast(m.audioStart) / NumCast(this.dataDoc.duration, 1) * 100}%`, + width: `${(NumCast(m.audioEnd) - NumCast(m.audioStart)) / NumCast(this.dataDoc.duration, 1) * 100}%`, height: `${1 / (this.dataDoc.markerAmount + 1) * 100}%`, + top: `${this.isOverlap(m) * 1 / (this.dataDoc.markerAmount + 1) * 100}%` + }} + onClick={e => { this.playFrom(NumCast(m.audioStart), NumCast(m.audioEnd)); e.stopPropagation(); }} > + <div className="left-resizer" onPointerDown={e => this.onPointerDown(e, m, true)}></div> + <DocumentView {...this.props} + Document={m} + pointerEvents={true} + NativeHeight={returnZero} + NativeWidth={returnZero} + rootSelected={returnFalse} + LayoutTemplate={undefined} + ContainingCollectionDoc={this.props.Document} + removeDocument={this.removeDocument} + parentActive={returnTrue} + onClick={this.layoutDoc.playOnClick ? this.rangeScript : undefined} + ignoreAutoHeight={false} + bringToFront={emptyFunction} + scriptContext={this} /> + <div className="resizer" onPointerDown={e => this.onPointerDown(e, m, false)}></div> + </div> + : + (this.layoutDoc.hideLabels) ? (null) : + rect = + <div className={this.props.PanelHeight() < 32 ? "audiobox-marker-minicontainer" : "audiobox-marker-container"} key={i} style={{ left: `${NumCast(m.audioStart) / NumCast(this.dataDoc.duration, 1) * 100}%` }}> + <DocumentView {...this.props} + Document={m} + pointerEvents={true} + NativeHeight={returnZero} + NativeWidth={returnZero} + rootSelected={returnFalse} + LayoutTemplate={undefined} + ContainingCollectionDoc={this.props.Document} + removeDocument={this.removeDocument} + parentActive={returnTrue} + onClick={this.layoutDoc.playOnClick ? this.labelScript : undefined} + ignoreAutoHeight={false} + bringToFront={emptyFunction} + scriptContext={this} /> + </div>; + return rect; + })} {DocListCast(this.dataDoc.links).map((l, i) => { + let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; let linkTime = NumCast(l.anchor2_timecode); @@ -259,32 +721,45 @@ export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument la2 = l.anchor1 as Doc; linkTime = NumCast(l.anchor1_timecode); } + + if (la2.audioStart && !la2.audioEnd) { + linkTime = NumCast(la2.audioStart); + } + return !linkTime ? (null) : - <div className={this.props.PanelHeight() < 32 ? "audiobox-marker-minicontainer" : "audiobox-marker-container"} key={l[Id]} style={{ left: `${linkTime / NumCast(this.dataDoc.duration, 1) * 100}%` }}> - <div className={this.props.PanelHeight() < 32 ? "audioBox-linker-mini" : "audioBox-linker"} key={"linker" + i}> - <DocumentView {...this.props} - Document={l} - NativeHeight={returnZero} - NativeWidth={returnZero} - rootSelected={returnFalse} - LayoutTemplate={undefined} - LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(l, la2)}`)} - ContainingCollectionDoc={this.props.Document} - dontRegisterView={true} - parentActive={returnTrue} - bringToFront={emptyFunction} - backgroundColor={returnTransparent} /> - </div> - <div key={i} className="audiobox-marker" onPointerEnter={() => Doc.linkFollowHighlight(la1)} - onPointerDown={e => { if (e.button === 0 && !e.ctrlKey) { const wasPaused = this.audioState === "paused"; this.playFrom(linkTime); wasPaused && this.pause(); e.stopPropagation(); } }} /> + <div className={this.props.PanelHeight() < 32 ? "audiobox-marker-minicontainer" : "audiobox-marker-container"} key={l[Id]} style={{ left: `${linkTime / NumCast(this.dataDoc.duration, 1) * 100}%` }} onClick={e => e.stopPropagation()}> + <DocumentView {...this.props} + Document={l} + NativeHeight={returnZero} + NativeWidth={returnZero} + rootSelected={returnFalse} + ContainingCollectionDoc={this.props.Document} + parentActive={returnTrue} + bringToFront={emptyFunction} + backgroundColor={returnTransparent} + ContentScaling={returnOne} + forcedBackgroundColor={returnTransparent} + pointerEvents={false} + LayoutTemplate={undefined} + LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(l, la2)}`)} + /> + <div key={i} className={`audiobox-marker`} onPointerEnter={() => Doc.linkFollowHighlight(la1)} + onPointerDown={e => { if (e.button === 0 && !e.ctrlKey) { const wasPaused = this.audioState === "paused"; this.playFrom(linkTime); e.stopPropagation(); e.preventDefault(); } }} /> </div>; })} - <div className="audiobox-current" style={{ left: `${NumCast(this.layoutDoc.currentTimecode) / NumCast(this.dataDoc.duration, 1) * 100}%` }} /> + <div className="audiobox-current" id="current" onClick={e => { e.stopPropagation(); e.preventDefault(); }} style={{ left: `${NumCast(this.layoutDoc.currentTimecode) / NumCast(this.dataDoc.duration, 1) * 100}%`, pointerEvents: "none" }} /> {this.audio} </div> + <div className="current-time"> + {formatTime(Math.round(NumCast(this.layoutDoc.currentTimecode)))} + </div> + <div className="total-time"> + {formatTime(Math.round(NumCast(this.dataDoc.duration)))} + </div> </div> </div> } </div>; } -}
\ No newline at end of file +} +Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); });
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index cb79e1522..c2f27c85a 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -74,7 +74,6 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp } } else if (!this.props.InMenu) { DocumentLinksButton.EditLink = this.props.View; - DocumentLinksButton.EditLinkLoc = [e.clientX + 10, e.clientY]; } })); } @@ -91,7 +90,6 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp //action(() => Doc.BrushDoc(this.props.View.Document)); } else if (!this.props.InMenu) { DocumentLinksButton.EditLink = this.props.View; - DocumentLinksButton.EditLinkLoc = [e.clientX + 10, e.clientY]; } } @@ -147,8 +145,6 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp @observable public static EditLink: DocumentView | undefined; - public static EditLinkLoc: number[] = [0, 0]; - @action clearLinks() { DocumentLinksButton.StartLink = undefined; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index b9e685b44..444583af3 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -179,7 +179,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu RadialMenu.Instance.openMenu(pt.pageX - 15, pt.pageY - 15); // RadialMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "onRight"), icon: "map-pin", selected: -1 }); - RadialMenu.Instance.addItem({ description: "Delete", event: () => { this.props.ContainingCollectionView?.removeDocument(this.props.Document), RadialMenu.Instance.closeMenu(); }, icon: "external-link-square-alt", selected: -1 }); + const effectiveAcl = GetEffectiveAcl(this.props.Document); + (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) && RadialMenu.Instance.addItem({ description: "Delete", event: () => { this.props.ContainingCollectionView?.removeDocument(this.props.Document), RadialMenu.Instance.closeMenu(); }, icon: "external-link-square-alt", selected: -1 }); // RadialMenu.Instance.addItem({ description: "Open in a new tab", event: () => this.props.addDocTab(this.props.Document, "onRight"), icon: "trash", selected: -1 }); RadialMenu.Instance.addItem({ description: "Pin", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin", selected: -1 }); RadialMenu.Instance.addItem({ description: "Open", event: () => MobileInterface.Instance.handleClick(this.props.Document), icon: "trash", selected: -1 }); @@ -570,6 +571,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu alert("Can't delete the active workspace"); } else { SelectionManager.DeselectAll(); + this.props.Document.deleted = true; this.props.removeDocument?.(this.props.Document); } } @@ -762,7 +764,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu moreItems.push({ description: "Copy ID", event: () => Utils.CopyText(Utils.prepend("/doc/" + this.props.Document[Id])), icon: "fingerprint" }); Doc.AreProtosEqual(this.props.Document, Doc.UserDoc()) && moreItems.push({ description: "Toggle Always Show Link End", event: () => Doc.UserDoc()["documentLinksButton-hideEnd"] = !Doc.UserDoc()["documentLinksButton-hideEnd"], icon: "eye" }); } - //GetEffectiveAcl(this.props.Document) === AclEdit && moreItems.push({ description: "Delete", event: this.deleteClicked, icon: "trash" }); const effectiveAcl = GetEffectiveAcl(this.props.Document); (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) && moreItems.push({ description: "Delete", event: this.deleteClicked, icon: "trash" }); @@ -891,20 +892,21 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu this.rootDoc.type === DocumentType.LINK || this.props.dontRegisterView ? (null) : // view that are not registered DocUtils.FilterDocs(this.directLinks, this.props.docFilters(), []).filter(d => !d.hidden && this.isNonTemporalLink).map((d, i) => - <div className="documentView-anchorCont" key={i + 1}> <DocumentView {...this.props} - Document={d} - ContainingCollectionView={this.props.ContainingCollectionView} - ContainingCollectionDoc={this.props.Document} // bcz: hack this.props.Document is not a collection Need a better prop for passing the containing document to the LinkAnchorBox - PanelWidth={this.anchorPanelWidth} - PanelHeight={this.anchorPanelHeight} - ContentScaling={returnOne} - dontRegisterView={false} - forcedBackgroundColor={returnTransparent} - removeDocument={this.hideLinkAnchor} - pointerEvents={false} - LayoutTemplate={undefined} - LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(d, this.props.Document)}`)} - /></div >); + <div className="documentView-anchorCont" key={i + 1}> + <DocumentView {...this.props} + Document={d} + ContainingCollectionView={this.props.ContainingCollectionView} + ContainingCollectionDoc={this.props.Document} // bcz: hack this.props.Document is not a collection Need a better prop for passing the containing document to the LinkAnchorBox + PanelWidth={this.anchorPanelWidth} + PanelHeight={this.anchorPanelHeight} + ContentScaling={returnOne} + dontRegisterView={false} + forcedBackgroundColor={returnTransparent} + removeDocument={this.hideLinkAnchor} + pointerEvents={false} + LayoutTemplate={undefined} + LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(d, this.props.Document)}`)} /> + </div >); } @computed get innards() { TraceMobx(); diff --git a/src/client/views/nodes/LinkAnchorBox.tsx b/src/client/views/nodes/LinkAnchorBox.tsx index be6292bb6..50b2af0d7 100644 --- a/src/client/views/nodes/LinkAnchorBox.tsx +++ b/src/client/views/nodes/LinkAnchorBox.tsx @@ -119,7 +119,7 @@ export class LinkAnchorBox extends ViewBoxBaseComponent<FieldViewProps, LinkAnch const y = NumCast(this.rootDoc[this.fieldKey + "_y"], 100); const c = StrCast(this.layoutDoc._backgroundColor, StrCast(this.layoutDoc.backgroundColor, StrCast(this.dataDoc.backgroundColor, "lightBlue"))); // note this is not where the typical lightBlue default color comes from. See Documents.Create.LinkDocument() const anchor = this.fieldKey === "anchor1" ? "anchor2" : "anchor1"; - const anchorScale = (x === 0 || x === 100 || y === 0 || y === 100) ? 1 : .25; + const anchorScale = !this.dataDoc[this.fieldKey + "-useLinkSmallAnchor"] && (x === 0 || x === 100 || y === 0 || y === 100) ? 1 : .25; const timecode = this.dataDoc[anchor + "_timecode"]; const targetTitle = StrCast((this.dataDoc[anchor] as Doc)?.title) + (timecode !== undefined ? ":" + timecode : ""); diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index f2ef42162..fd4525ced 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -137,7 +137,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> this.gotoDocument(nextSelected, this.itemIndex); const targetNext = Cast(activeNext.presentationTargetDoc, Doc, null); if (activeNext && targetNext.type === DocumentType.AUDIO && activeNext.playAuto) { - } else { this._moveOnFromAudio = false }; + } else this._moveOnFromAudio = false; } } @@ -1440,7 +1440,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> </select> <div className="presBox-presentPanel" style={{ opacity: this.childDocs.length > 0 ? 1 : 0.3 }}> <span className={`presBox-button ${this.layoutDoc.presStatus === "edit" ? "present" : ""}`}> - <div className="presBox-button-left" onClick={() => { if (this.childDocs.length > 0) this.layoutDoc.presStatus = "manual" }}> + <div className="presBox-button-left" onClick={() => (this.childDocs.length > 0) && (this.layoutDoc.presStatus = "manual")}> <FontAwesomeIcon icon={"play-circle"} /> <div style={{ display: this.props.PanelWidth() > 200 ? "inline-flex" : "none" }}> Present</div> </div> diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index cc37cf586..b0bf54be6 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -2,7 +2,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEqual } from "lodash"; -import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx"; +import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap, selectAll } from "prosemirror-commands"; import { history } from "prosemirror-history"; @@ -93,6 +93,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp private _undoTyping?: UndoManager.Batch; private _disposers: { [name: string]: IReactionDisposer } = {}; private _dropDisposer?: DragManager.DragDropDisposer; + private _first: Boolean = true; + private _recordingStart: number = 0; + private _currentTime: number = 0; + private _linkTime: number | null = null; + private _pause: boolean = false; @computed get _recording() { return this.dataDoc.audioState === "recording"; } set _recording(value) { this.dataDoc.audioState = value ? "recording" : undefined; } @@ -140,6 +145,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp super(props); FormattedTextBox.Instance = this; this.updateHighlights(); + this._recordingStart = Date.now(); + this.layoutDoc._timeStampOnEnter = true; } public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -197,9 +204,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } dispatchTransaction = (tx: Transaction) => { + let timeStamp; + clearTimeout(timeStamp); if (this._editorView) { + const metadata = tx.selection.$from.marks().find((m: Mark) => m.type === schema.marks.metadata); if (metadata) { + const range = tx.selection.$from.blockRange(tx.selection.$to); let text = range ? tx.doc.textBetween(range.start, range.end) : ""; let textEndSelection = tx.selection.to; @@ -221,6 +232,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp this.dataDoc[key] = value; } } + const state = this._editorView.state.apply(tx); this._editorView.updateState(state); @@ -233,6 +245,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const json = JSON.stringify(state.toJSON()); let unchanged = true; const effectiveAcl = GetEffectiveAcl(this.dataDoc); + + if (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) { if (!this._applyingChange && json.replace(/"selection":.*/, "") !== curProto?.Data.replace(/"selection":.*/, "")) { this._applyingChange = true; @@ -240,13 +254,24 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp (curText !== Cast(this.dataDoc[this.fieldKey], RichTextField)?.Text) && (this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now()))) && (this.dataDoc[lastmodified] = new DateField(new Date(Date.now()))); if ((!curTemp && !curProto) || curText || curLayout?.Data.includes("dash")) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) if (json.replace(/"selection":.*/, "") !== curLayout?.Data.replace(/"selection":.*/, "")) { + if (!this._pause && !this.layoutDoc._timeStampOnEnter) { + timeStamp = setTimeout(() => this.pause(), 10 * 1000); // 10 seconds delay for time stamp + } + + // if 10 seconds have passed, insert time stamp the next time you type + if (this._pause) { + this._pause = false; + this.insertTime(); + } !curText && tx.storedMarks?.map(m => m.type.name === "pFontSize" && (Doc.UserDoc().fontSize = this.layoutDoc._fontSize = m.attrs.fontSize)); !curText && tx.storedMarks?.map(m => m.type.name === "pFontFamily" && (Doc.UserDoc().fontFamily = this.layoutDoc._fontFamily = m.attrs.fontFamily)); this.dataDoc[this.props.fieldKey] = new RichTextField(json, curText); this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.layoutDoc, self: this.rootDoc, text: curText }); unchanged = false; + } + } else { // if we've deleted all the text in a note driven by a template, then restore the template data this.dataDoc[this.props.fieldKey] = undefined; this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse((curProto || curTemp).Data))); @@ -260,6 +285,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } } } else { + const json = JSON.parse(Cast(this.dataDoc[this.fieldKey], RichTextField)?.Data!); json.selection = state.toJSON().selection; this._editorView.updateState(EditorState.fromJSON(this.config, json)); @@ -267,6 +293,61 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } } + pause = () => this._pause = true; + + formatTime = (time: number) => { + const hours = Math.floor(time / 60 / 60); + const minutes = Math.floor(time / 60) - (hours * 60); + const seconds = time % 60; + + return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); + } + + // for inserting timestamps + insertTime = () => { + if (this._first) { + this._first = false; + DocListCast(this.dataDoc.links).map((l, i) => { + let la1 = l.anchor1 as Doc; + let la2 = l.anchor2 as Doc; + this._linkTime = NumCast(l.anchor2_timecode); + if (Doc.AreProtosEqual(la2, this.dataDoc)) { + la1 = l.anchor2 as Doc; + la2 = l.anchor1 as Doc; + this._linkTime = NumCast(l.anchor1_timecode); + } + + }); + } + this._currentTime = Date.now(); + let time; + this._linkTime ? time = this.formatTime(Math.round(this._linkTime + this._currentTime / 1000 - this._recordingStart / 1000)) : time = null; + + if (this._editorView) { + const state = this._editorView.state; + const now = Date.now(); + let mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(now / 1000) }); + if (!this._break && state.selection.to !== state.selection.from) { + for (let i = state.selection.from; i <= state.selection.to; i++) { + const pos = state.doc.resolve(i); + const um = Array.from(pos.marks()).find(m => m.type === schema.marks.user_mark); + if (um) { + mark = um; + break; + } + } + } + if (time) { + let value = ""; + this._break = false; + value = this.layoutDoc._timeStampOnEnter ? "[" + time + "] " : "\n" + "[" + time + "] "; + const from = state.selection.from; + const inserted = state.tr.insertText(value).addMark(from, from + value.length + 1, mark); + this._editorView.dispatch(this._editorView.state.tr.insertText(value)); + } + } + } + updateTitle = () => { if ((this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.rootDoc.customTitle) { @@ -524,6 +605,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp uicontrols.push({ description: `${this.layoutDoc._showSidebar ? "Hide" : "Show"} Sidebar`, event: () => this.layoutDoc._showSidebar = !this.layoutDoc._showSidebar, icon: "expand-arrows-alt" }); uicontrols.push({ description: `${this.layoutDoc._showAudio ? "Hide" : "Show"} Dictation Icon`, event: () => this.layoutDoc._showAudio = !this.layoutDoc._showAudio, icon: "expand-arrows-alt" }); uicontrols.push({ description: "Show Highlights...", noexpand: true, subitems: highlighting, icon: "hand-point-right" }); + uicontrols.push({ description: `Create TimeStamp When ${this.layoutDoc._timeStampOnEnter ? "Pause" : "Enter"}`, event: () => this.layoutDoc._timeStampOnEnter = !this.layoutDoc._timeStampOnEnter, icon: "expand-arrows-alt" }); !Doc.UserDoc().noviceMode && uicontrols.push({ description: "Broadcast Message", event: () => DocServer.GetRefField("rtfProto").then(proto => proto instanceof Doc && (proto.BROADCAST_MESSAGE = Cast(this.rootDoc[this.fieldKey], RichTextField)?.Text)), icon: "expand-arrows-alt" @@ -1383,6 +1465,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } e.stopPropagation(); if (e.key === "Tab" || e.key === "Enter") { + if (e.key === "Enter" && this.layoutDoc._timeStampOnEnter) { + this.insertTime(); + } e.preventDefault(); } if (e.key === " " || this._lastTimedMark?.attrs.userid !== Doc.CurrentUserEmail) { diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index bcd6f716b..ce784c3d9 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -31,7 +31,7 @@ export const marks: { [index: string]: MarkSpec } = { inclusive: false, parseDOM: [{ tag: "a[href]", getAttrs(dom: any) { - return { allLinks: [{ href: dom.getAttribute("href"), title: dom.getAttribute("title"), linkId: dom.getAttribute("linkids"), targetId: dom.getAttribute("targetids") }], location: dom.getAttribute("location"), }; + return { allLinks: [{ href: dom.getAttribute("href"), title: dom.getAttribute("title"), linkId: dom.getAttribute("linkids"), targetId: dom.dataset.targetids }], location: dom.getAttribute("location"), }; } }], toDOM(node: any) { @@ -40,10 +40,10 @@ export const marks: { [index: string]: MarkSpec } = { return node.attrs.docref && node.attrs.title ? ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, href: node.attrs.allLinks[0].href, class: "prosemirror-attribution" }, node.attrs.title], ["br"]] : node.attrs.allLinks.length === 1 ? - ["a", { ...node.attrs, class: linkids, targetids, style: `text-decoration: ${linkids === " " ? "underline" : undefined}`, title: `${node.attrs.title}`, href: node.attrs.allLinks[0].href }, 0] : + ["a", { ...node.attrs, class: linkids, dataTargetids: targetids, title: `${node.attrs.title}`, href: node.attrs.allLinks[0].href, style: `text-decoration: ${linkids === " " ? "underline" : undefined}` }, 0] : ["div", { class: "prosemirror-anchor" }, ["span", { class: "prosemirror-linkBtn" }, - ["a", { ...node.attrs, class: linkids, targetids, title: `${node.attrs.title}` }, 0], + ["a", { ...node.attrs, class: linkids, dataTargetids: targetids, title: `${node.attrs.title}` }, 0], ["input", { class: "prosemirror-hrefoptions" }], ], ["div", { class: "prosemirror-links" }, ...node.attrs.allLinks.map((item: { href: string, title: string }) => diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 6163fedbb..6bfe91378 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -508,9 +508,9 @@ export namespace Doc { alias.aliasOf = doc; alias.title = ComputedField.MakeFunction(`renameAlias(this, ${Doc.GetProto(doc).aliasNumber = NumCast(Doc.GetProto(doc).aliasNumber) + 1})`); alias.author = Doc.CurrentUserEmail; + alias[AclSym] = doc[AclSym]; - if (!doc.aliases) doc.aliases = new List<Doc>([alias]); - else Doc.AddDocToList(doc, "aliases", alias); + Doc.AddDocToList(doc[DataSym], "aliases", alias); return alias; } diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index 8cf8f47b7..ada13226e 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -19,6 +19,10 @@ export const documentSchema = createSchema({ currentTimecode: "number", // current play back time of a temporal document (video / audio) displayTimecode: "number", // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) inOverlay: "boolean", // whether the document is rendered in an OverlayView which handles selection/dragging differently + isLabel: "boolean", // whether the document is a label or not (video / audio) + audioStart: "number", // the time frame where the audio should begin playing + audioEnd: "number", // the time frame where the audio should stop playing + markers: listSpec(Doc), // list of markers for audio / video x: "number", // x coordinate when in a freeform view y: "number", // y coordinate when in a freeform view z: "number", // z "coordinate" - non-zero specifies the overlay layer of a freeformview diff --git a/src/fields/util.ts b/src/fields/util.ts index 44a3317db..4c71572db 100644 --- a/src/fields/util.ts +++ b/src/fields/util.ts @@ -220,7 +220,7 @@ export function distributeAcls(key: string, acl: SharingPermissions, target: Doc // maps over the aliases of the document if (target.aliases) { DocListCast(target.aliases).map(alias => { - distributeAcls(key, acl, alias); + distributeAcls(key, acl, alias, inheritingFromCollection); }); } diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts index 24b70057a..068ac2159 100644 --- a/src/typings/index.d.ts +++ b/src/typings/index.d.ts @@ -7,6 +7,8 @@ declare module 'cors'; declare module 'webrtc-adapter'; declare module 'bezier-curve'; declare module 'fit-curve'; +declare module 'react-audio-waveform'; + declare module 'reveal'; declare module 'react-reveal'; declare module 'react-reveal/makeCarousel'; |