Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"license": "AGPL-3.0",
"scripts": {
"clean": "rm -fr dist",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"prestart": "yarn clean",
"start": "tsc -p tsconfig.json --watch",
"prebuild": "yarn clean",
Expand Down
2 changes: 1 addition & 1 deletion packages/snjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"start": "webpack -w --config webpack.dev.js",
"start:test-server": "yarn node e2e-server.js",
"clean": "rm -fr dist",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"prebuild": "yarn clean",
"build": "yarn tsc && webpack --config webpack.prod.js",
"watch": "webpack --config webpack.prod.js --watch",
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
],
"scripts": {
"build": "yarn clean && yarn copy:components && webpack --config web.webpack.prod.js && yarn tsc",
"clean": "rm -fr dist",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"format": "prettier --write src/javascripts",
"lint": "eslint src/javascripts && yarn tsc",
"lint:fix": "eslint src/javascripts --fix",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import NoteView from '../NoteView/NoteView'
import { NoteViewController } from '../NoteView/Controller/NoteViewController'
import { FileViewController } from '../NoteView/Controller/FileViewController'
import { WebApplication } from '@/Application/WebApplication'
import { TabBar } from './TabBar'

type State = {
showMultipleSelectedNotes: boolean
showMultipleSelectedFiles: boolean
controllers: (NoteViewController | FileViewController)[]
activeControllerIndex: number
splitControllerIndex: number | undefined
focusedPane: 'primary' | 'secondary'
selectedFile: FileItem | undefined
selectedPane?: AppPaneId
isInMobileView?: boolean
Expand All @@ -28,10 +32,14 @@ class NoteGroupView extends AbstractComponent<Props, State> {

constructor(props: Props) {
super(props, props.application)
const controllerGroup = props.application.itemControllerGroup
this.state = {
showMultipleSelectedNotes: false,
showMultipleSelectedFiles: false,
controllers: [],
activeControllerIndex: controllerGroup.activeControllerIndex || 0,
splitControllerIndex: controllerGroup.splitControllerIndex,
focusedPane: controllerGroup.focusedPane || 'primary',
selectedFile: undefined,
}
}
Expand All @@ -44,6 +52,9 @@ class NoteGroupView extends AbstractComponent<Props, State> {
const controllers = controllerGroup.itemControllers
this.setState({
controllers: controllers,
activeControllerIndex: controllerGroup.activeControllerIndex,
splitControllerIndex: controllerGroup.splitControllerIndex,
focusedPane: controllerGroup.focusedPane,
})
})

Expand Down Expand Up @@ -91,23 +102,75 @@ class NoteGroupView extends AbstractComponent<Props, State> {
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles

const hasControllers = this.state.controllers.length > 0
const itemControllerGroup = this.application.itemControllerGroup

const primaryController = this.state.controllers[this.state.activeControllerIndex]
const secondaryController = this.state.splitControllerIndex !== undefined
? this.state.controllers[this.state.splitControllerIndex]
: undefined

const renderController = (controller: NoteViewController | FileViewController) => {
return controller instanceof NoteViewController ? (
<NoteView key={controller.runtimeId} application={this.application} controller={controller} />
) : (
<FileView key={controller.runtimeId} application={this.application} file={controller.item} />
)
}

return (
<>
{shouldNotShowMultipleSelectedItems && hasControllers && (
<TabBar
application={this.application}
activeControllerIndex={this.state.activeControllerIndex}
splitControllerIndex={this.state.splitControllerIndex}
focusedPane={this.state.focusedPane}
controllers={this.state.controllers}
/>
)}

{this.state.showMultipleSelectedNotes && <MultipleSelectedNotes application={this.application} />}
{this.state.showMultipleSelectedFiles && (
<MultipleSelectedFiles itemListController={this.application.itemListController} />
)}

{shouldNotShowMultipleSelectedItems && hasControllers && (
<>
{this.state.controllers.map((controller) => {
return controller instanceof NoteViewController ? (
<NoteView key={controller.runtimeId} application={this.application} controller={controller} />
) : (
<FileView key={controller.runtimeId} application={this.application} file={controller.item} />
)
})}
</>
<div className="flex-grow flex flex-col h-full overflow-hidden relative">
{secondaryController !== undefined ? (
<div className="editors-split-container flex-grow h-full">
<div
className={`split-pane-wrapper h-full flex flex-col relative ${
this.state.focusedPane === 'primary' ? 'split-pane-focused' : ''
}`}
onClick={() => {
if (itemControllerGroup.focusedPane !== 'primary') {
itemControllerGroup.focusedPane = 'primary'
itemControllerGroup.notifyObservers()
}
}}
>
<div className="split-pane-overlay" />
{primaryController && renderController(primaryController)}
</div>
<div
className={`split-pane-wrapper h-full flex flex-col relative ${
this.state.focusedPane === 'secondary' ? 'split-pane-focused' : ''
}`}
onClick={() => {
if (itemControllerGroup.focusedPane !== 'secondary') {
itemControllerGroup.focusedPane = 'secondary'
itemControllerGroup.notifyObservers()
}
}}
>
<div className="split-pane-overlay" />
{renderController(secondaryController)}
</div>
</div>
) : (
primaryController && renderController(primaryController)
)}
</div>
)}
</>
)
Expand Down
198 changes: 198 additions & 0 deletions packages/web/src/javascripts/Components/NoteGroupView/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React, { useState, useEffect, useRef } from 'react'
import { WebApplication } from '@/Application/WebApplication'
import { NoteViewController } from '../NoteView/Controller/NoteViewController'
import { FileViewController } from '../NoteView/Controller/FileViewController'

type TabBarProps = {
application: WebApplication
activeControllerIndex: number
splitControllerIndex: number | undefined
focusedPane: 'primary' | 'secondary'
controllers: (NoteViewController | FileViewController)[]
}

export const TabBar: React.FC<TabBarProps> = ({
application,
activeControllerIndex,
splitControllerIndex,
focusedPane,
controllers,
}) => {
const itemControllerGroup = application.itemControllerGroup
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
index: number
} | null>(null)

const menuRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setContextMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])

if (controllers.length === 0) {
return null
}

const handleTabClick = (index: number) => {
// If split screen is active, determine which pane we are clicking in
// For simplicity, clicking a tab will focus that index as the primary tab
if (splitControllerIndex !== undefined && index === splitControllerIndex) {
itemControllerGroup.focusedPane = 'secondary'
} else {
itemControllerGroup.activeControllerIndex = index
itemControllerGroup.focusedPane = 'primary'
}
itemControllerGroup.notifyObservers()
}

const handleCloseClick = (e: React.MouseEvent, index: number) => {
e.stopPropagation()
itemControllerGroup.closeTab(index)
}

const handleContextMenu = (e: React.MouseEvent, index: number) => {
e.preventDefault()
setContextMenu({
x: e.clientX,
y: e.clientY,
index,
})
}

const handleCloseTab = () => {
if (contextMenu !== null) {
itemControllerGroup.closeTab(contextMenu.index)
setContextMenu(null)
}
}

const handleCloseOthers = () => {
if (contextMenu !== null) {
const targetController = controllers[contextMenu.index]
for (const controller of [...controllers]) {
if (controller !== targetController) {
itemControllerGroup.closeItemController(controller, { notify: false })
}
}
itemControllerGroup.activeControllerIndex = 0
itemControllerGroup.splitControllerIndex = undefined
itemControllerGroup.focusedPane = 'primary'
itemControllerGroup.notifyObservers()
setContextMenu(null)
}
}

const handleSplitTab = () => {
if (contextMenu !== null) {
itemControllerGroup.splitTab(contextMenu.index)
setContextMenu(null)
}
}

const getTabTitle = (controller: NoteViewController | FileViewController) => {
if (controller instanceof NoteViewController) {
return controller.item?.title || 'Untitled note'
} else {
return (controller.item as any)?.title || (controller.item as any)?.name || 'Untitled file'
}
}

return (
<div className="tab-bar-container">
<div className="tab-bar-tabs">
{controllers.map((controller, index) => {
const isPrimaryActive = activeControllerIndex === index
const isSecondaryActive = splitControllerIndex === index
const isFocused = (focusedPane === 'primary' && isPrimaryActive) ||
(focusedPane === 'secondary' && isSecondaryActive)
const isOpened = isPrimaryActive || isSecondaryActive

let tabClassName = 'tab-item'
if (isOpened) {
tabClassName += ' tab-item-active'
}
if (isFocused) {
tabClassName += ' tab-item-focused'
}

return (
<div
key={controller.runtimeId}
className={tabClassName}
onClick={() => handleTabClick(index)}
onContextMenu={(e) => handleContextMenu(e, index)}
>
<span className="tab-title-text">{getTabTitle(controller)}</span>
{controllers.length > 1 && (
<button
className="tab-close-btn"
onClick={(e) => handleCloseClick(e, index)}
title="Close tab"
>
</button>
)}
</div>
)
})}

<button
className="tab-add-btn"
onClick={() => itemControllerGroup.openNewTemplateTab()}
title="New tab"
>
</button>
</div>

<div className="tab-bar-actions">
<button
className={`tab-action-btn ${splitControllerIndex !== undefined ? 'tab-action-btn-active' : ''}`}
onClick={() => itemControllerGroup.toggleSplitScreen()}
title={splitControllerIndex !== undefined ? "Merge Editor Panes" : "Split Editor Panes"}
>
{splitControllerIndex !== undefined ? (
// Merge/Close Split icon
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="12" y1="3" x2="12" y2="21" />
<path d="M17 12h-3M7 12h3" />
</svg>
) : (
// Split icon
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
)}
</button>
</div>

{contextMenu && (
<div
ref={menuRef}
className="tab-context-menu"
style={{ top: contextMenu.y, left: contextMenu.x }}
>
<div className="context-menu-item" onClick={handleCloseTab}>
Close Tab
</div>
<div className="context-menu-item" onClick={handleCloseOthers}>
Close Other Tabs
</div>
<div className="context-menu-item" onClick={handleSplitTab}>
Split Screen
</div>
</div>
)}
</div>
)
}
Loading