Merge pull request #3007 from overleaf/pr-file-outline-extras

File outline extras

GitOrigin-RevId: c35c4f35dce280c9f44c02b567df9734943d0cb0
This commit is contained in:
Paulo Jorge Reis 2020-07-16 11:09:01 +01:00 committed by Copybot
parent 79b6f6e473
commit 0e9771ac09
16 changed files with 341 additions and 146 deletions

View file

@ -1,102 +1,113 @@
aside.file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view != 'history' || !history.isV2").full-size
.toolbar.toolbar-filetree(ng-if="permissions.write")
a(
href,
ng-click="openNewDocModal()",
tooltip-html="'"+translate('new_file').replace(' ', '<br>')+"'",
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-file
a(
href,
ng-click="openNewFolderModal()",
tooltip-html="'"+translate('new_folder').replace(' ', '<br>')+"'",
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-folder
a(
href,
ng-click="openUploadFileModal()",
tooltip=translate('upload'),
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-upload
.toolbar-right
a(
href,
ng-click="startRenamingSelected()",
tooltip=translate('rename'),
tooltip-placement="bottom",
ng-show="multiSelectedCount == 0"
)
i.fa.fa-fw.fa-pencil
a(
href,
ng-click="openDeleteModalForSelected()",
tooltip=translate('delete'),
tooltip-placement="bottom",
tooltip-append-to-body="true"
)
i.fa.fa-fw.fa-trash-o
.file-tree-inner(
ng-if="rootFolder",
ng-controller="FileTreeRootFolderController",
ng-class="{ 'no-toolbar': !permissions.write }"
aside.editor-sidebar.full-size(
ng-controller="FileTreeController"
ng-class="{ 'multi-selected': multiSelectedCount > 0 }"
ng-show="ui.view != 'history' || !history.isV2"
vertical-resizable-panes=user.alphaProgram && "outline-resizer"
vertical-resizable-panes-toggled-externally-on=user.alphaProgram && "outline-toggled"
)
.file-tree(
vertical-resizable-top=user.alphaProgram
)
ul.list-unstyled.file-tree-list(
droppable="permissions.write"
accept=".entity-name"
on-drop-callback="onDrop"
.toolbar.toolbar-filetree(ng-if="permissions.write")
a(
href,
ng-click="openNewDocModal()",
tooltip-html="'"+translate('new_file').replace(' ', '<br>')+"'",
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-file
a(
href,
ng-click="openNewFolderModal()",
tooltip-html="'"+translate('new_folder').replace(' ', '<br>')+"'",
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-folder
a(
href,
ng-click="openUploadFileModal()",
tooltip=translate('upload'),
tooltip-placement="bottom"
)
i.fa.fa-fw.fa-upload
.toolbar-right
a(
href,
ng-click="startRenamingSelected()",
tooltip=translate('rename'),
tooltip-placement="bottom",
ng-show="multiSelectedCount == 0"
)
i.fa.fa-fw.fa-pencil
a(
href,
ng-click="openDeleteModalForSelected()",
tooltip=translate('delete'),
tooltip-placement="bottom",
tooltip-append-to-body="true"
)
i.fa.fa-fw.fa-trash-o
.file-tree-inner(
ng-if="rootFolder",
ng-controller="FileTreeRootFolderController",
ng-class="{ 'no-toolbar': !permissions.write }"
)
li(
ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')"
ng-class="{ 'selected': ui.view == 'pdf' }"
ng-controller="PdfViewToggleController"
ul.list-unstyled.file-tree-list(
droppable="permissions.write"
accept=".entity-name"
on-drop-callback="onDrop"
)
.entity
.entity-name(
ng-click="togglePdfView()"
)
i.fa.fa-fw.toggle
i.fa.fa-fw.fa-file-pdf-o
| PDF
li(
ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')"
ng-class="{ 'selected': ui.view == 'pdf' }"
ng-controller="PdfViewToggleController"
)
.entity
.entity-name(
ng-click="togglePdfView()"
)
i.fa.fa-fw.toggle
i.fa.fa-fw.fa-file-pdf-o
| PDF
file-entity(
entity="entity",
permissions="permissions",
ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
)
file-entity(
entity="entity",
permissions="permissions",
ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
)
li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
h3 #{translate("deleted_files")}
li(
ng-class="{ 'selected': entity.selected }",
ng-repeat="entity in deletedDocs | orderBy:'name'",
ng-controller="FileTreeEntityController",
ng-show="ui.view == 'history'"
)
.entity
.entity-name(
ng-click="select($event)"
)
//- Just a spacer to align with folders
i.fa.fa-fw.toggle
i.fa.fa-fw.fa-file
span {{ entity.name }}
li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
h3 #{translate("deleted_files")}
li(
ng-class="{ 'selected': entity.selected }",
ng-repeat="entity in deletedDocs | orderBy:'name'",
ng-controller="FileTreeEntityController",
ng-show="ui.view == 'history'"
)
.entity
.entity-name(
ng-click="select($event)"
)
//- Just a spacer to align with folders
i.fa.fa-fw.toggle
i.fa.fa-fw.fa-file
span {{ entity.name }}
if user.alphaProgram
.outline-container(
vertical-resizable-bottom
ng-controller="OutlineController"
)
outline-pane(
is-tex-file="isTexFile"
outline="outline"
project-id="project_id"
jump-to-line="jumpToLine"
on-toggle="onToggle"
)

View file

@ -1,4 +1,4 @@
aside.file-tree.full-size(
aside.editor-sidebar.full-size(
ng-controller="HistoryV2FileTreeController"
ng-if="ui.view == 'history' && history.isV2"
)

View file

@ -40,6 +40,7 @@ import './ide/hotkeys/index'
import './ide/wordcount/index'
import './ide/directives/layout'
import './ide/directives/validFile'
import './ide/directives/verticalResizablePanes'
import './ide/services/ide'
import './directives/focus'
import './directives/fineUpload'

View file

@ -13,6 +13,7 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import App from '../../base'
import _ from 'lodash'
import 'libs/jquery-layout'
import 'libs/jquery.ui.touch-punch'
@ -233,9 +234,16 @@ ng-click=\"handleClick()\">\
}
// Save state when exiting
$(window).unload(() =>
ide.localStorage(`layout.${name}`, element.layout().readState())
)
$(window).unload(() => {
// Save only the state properties for the current layout, ignoring sublayouts inside it.
// If we save sublayouts state (`children`), the layout library will use it when
// initializing. This raises errors when the sublayout elements aren't available (due to
// being loaded at init or just not existing for the current project/user).
const stateToSave = _.mapValues(element.layout().readState(), pane =>
_.omit(pane, 'children')
)
ide.localStorage(`layout.${name}`, stateToSave)
})
if (attrs.openEast != null) {
scope.$watch(attrs.openEast, function(value, oldValue) {

View file

@ -0,0 +1,93 @@
import App from '../../base'
const layoutOptions = {
center: {
paneSelector: '[vertical-resizable-top]',
paneClass: 'vertical-resizable-top',
size: 'auto'
},
south: {
paneSelector: '[vertical-resizable-bottom]',
paneClass: 'vertical-resizable-bottom',
resizerClass: 'vertical-resizable-resizer',
resizerCursor: 'row-resize',
size: 'auto',
resizable: true,
closable: false,
slidable: false,
spacing_open: 6,
spacing_closed: 6,
maxSize: '75%'
}
}
export default App.directive('verticalResizablePanes', localStorage => ({
restrict: 'A',
link(scope, element, attrs) {
let name = attrs.verticalResizablePanes
let storedSize = null
let manualResizeIncoming = false
if (name) {
const storageKey = `vertical-resizable:${name}:south-size`
storedSize = localStorage(storageKey)
$(window).unload(() => {
if (storedSize) {
localStorage(storageKey, storedSize)
}
})
}
const toggledExternally = attrs.verticalResizablePanesToggledExternallyOn
const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled`
function enableResizer() {
if (layoutHandle.resizers && layoutHandle.resizers.south) {
layoutHandle.resizers.south.removeClass(resizerDisabledClass)
}
}
function disableResizer() {
if (layoutHandle.resizers && layoutHandle.resizers.south) {
layoutHandle.resizers.south.addClass(resizerDisabledClass)
}
}
function handleDragEnd() {
manualResizeIncoming = true
}
function handleResize(paneName, paneEl, paneState) {
if (manualResizeIncoming) {
storedSize = paneState.size
}
manualResizeIncoming = false
}
if (toggledExternally) {
scope.$on(toggledExternally, (e, open) => {
let newSize = 'auto'
if (open) {
if (storedSize) {
newSize = storedSize
}
enableResizer()
} else {
disableResizer()
}
layoutHandle.sizePane('south', newSize)
})
}
// The `drag` event fires only when the user manually resizes the panes; the `resize` event fires even when
// the layout library internally resizes itself. In order to get explicit user-initiated resizes, we need to
// listen to `drag` events. However, when the `drag` event fires, the panes aren't yet finished sizing so we
// get the pane size *before* the resize happens. We do get the correct size in the next `resize` event.
// The solution to work around this is to set up a flag in `drag` events which tells the next `resize` event
// that it was user-initiated (therefore, storing the value).
layoutOptions.south.ondrag_end = handleDragEnd
layoutOptions.south.onresize = handleResize
const layoutHandle = element.layout(layoutOptions)
}
}))

View file

@ -132,6 +132,7 @@ export default (EditorManager = (function() {
this.$scope.ui.view = 'editor'
const done = () => {
this.$scope.$broadcast('doc:after-opened')
if (options.gotoLine != null) {
// allow Ace to display document before moving, delay until next tick
// added delay to make this happen later that gotoStoredPosition in

View file

@ -14,8 +14,8 @@ class OutlineManager {
this.isTexFile = false
this.outline = []
scope.$watch('editor.sharejs_doc', shareJsDoc => {
this.shareJsDoc = shareJsDoc
scope.$on('doc:after-opened', () => {
this.shareJsDoc = scope.editor.shareJsDoc
this.isTexFile = isValidTeXFile(scope.editor.open_doc_name)
this.updateOutline()
this.broadcastChangeEvent()

View file

@ -1,15 +1,28 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import classNames from 'classnames'
import OutlineRoot from './OutlineRoot'
import localStorage from '../../../modules/localStorage'
function OutlinePane({ isTexFile, outline, jumpToLine }) {
const [expanded, setExpanded] = useState(true)
function OutlinePane({ isTexFile, outline, projectId, jumpToLine, onToggle }) {
const storageKey = `file_outline.expanded.${projectId}`
const [expanded, setExpanded] = useState(() => {
const storedExpandedState = localStorage(storageKey) !== false
return storedExpandedState
})
const isOpen = isTexFile && expanded
useEffect(
() => {
onToggle(isOpen)
},
[isOpen]
)
const expandCollapseIconClasses = classNames('fa', 'outline-caret-icon', {
'fa-angle-down': expanded,
'fa-angle-right': !expanded
'fa-angle-down': isOpen,
'fa-angle-right': !isOpen
})
const headerClasses = classNames('outline-pane', {
@ -18,6 +31,7 @@ function OutlinePane({ isTexFile, outline, jumpToLine }) {
function handleExpandCollapseClick() {
if (isTexFile) {
localStorage(storageKey, !expanded)
setExpanded(!expanded)
}
}
@ -32,20 +46,20 @@ function OutlinePane({ isTexFile, outline, jumpToLine }) {
>
<i className={expandCollapseIconClasses} />
<h4 className="outline-header-name">File outline</h4>
<OverlayTrigger placement="top" overlay={tooltip} delayHide={100}>
<a
href="/beta/participate"
target="_blank"
rel="noopener noreferrer"
className="outline-header-beta-badge"
>
<span className="sr-only">
The File outline is a beta feature. Click here to manage your
beta program membership.
</span>
</a>
</OverlayTrigger>
</button>
<OverlayTrigger placement="top" overlay={tooltip} delayHide={100}>
<a
href="/beta/participate"
target="_blank"
rel="noopener noreferrer"
className="outline-header-beta-badge"
>
<span className="sr-only">
The File outline is a beta feature. Click here to manage your beta
program membership.
</span>
</a>
</OverlayTrigger>
</header>
{expanded && isTexFile ? (
<div className="outline-body">
@ -65,7 +79,9 @@ const tooltip = (
OutlinePane.propTypes = {
isTexFile: PropTypes.bool.isRequired,
outline: PropTypes.array.isRequired,
jumpToLine: PropTypes.func.isRequired
projectId: PropTypes.string.isRequired,
jumpToLine: PropTypes.func.isRequired,
onToggle: PropTypes.func
}
export default OutlinePane

View file

@ -18,10 +18,22 @@ App.controller('OutlineController', function($scope, ide) {
$scope.jumpToLine = lineNo => {
ide.outlineManager.jumpToLine(lineNo)
}
$scope.onToggle = isOpen => {
$scope.$applyAsync(() => {
$scope.$emit('outline-toggled', isOpen)
})
}
})
// Wrap React component as Angular component. Only needed for "top-level" component
App.component(
'outlinePane',
react2angular(OutlinePane, ['outline', 'jumpToLine', 'isTexFile'])
react2angular(OutlinePane, [
'outline',
'jumpToLine',
'projectId',
'isTexFile',
'onToggle'
])
)

View file

@ -1,13 +1,17 @@
angular.module('localStorage', []).value('localStorage', function(...args) {
/*
localStorage can throw browser exceptions, for example if it is full
We don't use localStorage for anything critical, on in that case just
fail gracefully.
*/
angular.module('localStorage', []).value('localStorage', localStorage)
/*
localStorage can throw browser exceptions, for example if it is full
We don't use localStorage for anything critical, on in that case just
fail gracefully.
*/
function localStorage(...args) {
try {
return $.localStorage(...args)
} catch (e) {
console.error('localStorage exception', e)
return null
}
})
}
export default localStorage

View file

@ -61,6 +61,7 @@
@import 'components/popovers.less';
@import 'components/carousel.less';
@import 'components/daterange-picker';
@import 'components/vertical-resizable-panes.less';
// ngTagsInput
@import 'components/tags-input.less';

View file

@ -8,9 +8,16 @@
}
}
.file-tree {
.editor-sidebar {
display: flex;
flex-direction: column;
}
.file-tree {
display: flex !important; // To work around jQuery layout's inline styles
flex-direction: column;
flex-grow: 1;
max-height: 100%;
.toolbar.toolbar-filetree {
.toolbar-small-mixin;

View file

@ -1,10 +1,5 @@
@outline-v-rhythm: 24px;
@outline-h-rhythm: 24px;
@outline-item-h-padding: @outline-h-rhythm * 0.25;
.outline-container {
width: 100%;
max-height: 50%; // While we don't support resizing.
background-color: @file-tree-bg;
}
@ -13,7 +8,7 @@
flex-flow: column;
height: 100%;
font-size: @font-size-small;
color: #fff;
color: @file-tree-item-color;
}
.outline-pane-disabled {
@ -24,29 +19,33 @@
.toolbar-small-mixin;
.toolbar-alt-mixin;
display: flex;
align-items: center;
padding-right: @outline-h-rhythm * 0.25;
flex-shrink: 0;
border-bottom: 1px solid @toolbar-border-color;
border-top: 1px solid @toolbar-border-color;
}
.outline-header-expand-collapse-btn {
display: inline-block;
display: flex;
align-items: center;
background-color: transparent;
border: 0;
padding: 0;
padding: 0 (@outline-h-rhythm * 0.25) 0 0;
font-size: inherit;
vertical-align: inherit;
color: #fff;
flex-grow: 1;
color: @file-tree-item-color;
flex: 1 0 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
border-radius: @border-radius-base;
text-overflow: ellipsis;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
&:hover,
&:focus {
outline: 0;
background-color: @ol-blue-gray-5;
}
&:hover {
background-color: @outline-header-hover-bg;
}
&:hover[disabled] {
background-color: transparent;
}
}
@ -54,8 +53,13 @@
display: inline-block;
font-family: @font-family-sans-serif;
font-size: @font-size-small;
color: #fff;
color: @file-tree-item-color;
font-weight: 700;
margin: 0;
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.outline-header-beta-badge {
@ -87,7 +91,7 @@
}
.outline-body-no-elements {
color: @ol-blue-gray-2;
color: @outline-no-items-color;
text-align: center;
padding: @outline-v-rhythm @outline-h-rhythm (@outline-v-rhythm * 2);
margin-right: -(@outline-h-rhythm * 0.25);
@ -95,11 +99,11 @@
.outline-body-link {
display: block;
color: #fff;
color: @file-tree-item-color;
text-decoration: underline;
&:hover,
&:focus {
color: #fff;
color: @file-tree-item-color;
text-decoration: underline;
}
}
@ -111,7 +115,7 @@
&::before {
content: '';
background-color: @ol-blue-gray-3;
background-color: @outline-line-guide-color;
top: @outline-h-rhythm / 4;
bottom: @outline-h-rhythm / 4;
width: 1px;
@ -127,9 +131,6 @@
}
}
.outline-item {
}
.outline-item-no-children {
padding-left: @outline-h-rhythm - @outline-item-h-padding;
}
@ -149,19 +150,19 @@
position: relative;
z-index: 1;
background-color: @file-tree-bg;
color: @ol-blue-gray-2;
color: @outline-expand-collapse-color;
margin-right: -(@outline-item-h-padding);
border-radius: @border-radius-base;
&:hover,
&:focus {
outline: 0;
background-color: @ol-blue-gray-5;
background-color: @file-tree-item-hover-bg;
}
}
.outline-item-link {
display: inline;
color: #fff;
color: @file-tree-item-color;
background-color: transparent;
border: 0;
position: relative;
@ -174,7 +175,7 @@
&:hover,
&:focus {
outline: 0;
background-color: @ol-blue-gray-5;
background-color: @file-tree-item-hover-bg;
}
}

View file

@ -0,0 +1,21 @@
.vertical-resizable-resizer {
background-color: @vertical-resizable-resizer-bg;
&:hover {
background-color: @vertical-resizable-resizer-hover-bg;
}
&::after {
content: '····';
display: block;
color: @ol-blue-gray-2;
text-align: center;
font-size: 20px;
line-height: 3px;
pointer-events: none;
}
}
.vertical-resizable-resizer-disabled {
pointer-events: none;
}

View file

@ -57,6 +57,13 @@
@content-alt-bg-color : @ol-blue-gray-0;
// File outline
@outline-line-guide-color: @ol-blue-gray-1;
@outline-header-hover-bg: @file-tree-item-hover-bg;
@vertical-resizable-resizer-bg: @ol-blue-gray-1;
@vertical-resizable-resizer-hover-bg: @file-tree-item-hover-bg;
// Editor resizers
@editor-resizer-bg-color : @ol-blue-gray-1;
@editor-resizer-bg-color-dragging: @ol-blue-gray-1;

View file

@ -1012,6 +1012,18 @@
@file-tree-multiselect-hover-bg: @ol-dark-blue;
@file-tree-droppable-bg-color: @ol-blue-gray-2;
// File outline
@outline-v-rhythm: 24px;
@outline-h-rhythm: 24px;
@outline-item-h-padding: @outline-h-rhythm * 0.25;
@outline-line-guide-color: @ol-blue-gray-3;
@outline-expand-collapse-color: @ol-blue-gray-2;
@outline-no-items-color: @ol-blue-gray-2;
@outline-header-hover-bg: @ol-blue-gray-6;
@vertical-resizable-resizer-bg: @ol-blue-gray-5;
@vertical-resizable-resizer-hover-bg: @ol-blue-gray-6;
// Editor resizers
@editor-resizer-bg-color: @ol-blue-gray-5;
@editor-resizer-bg-color-dragging: @ol-blue-gray-5;