Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src-commons-atom/disposable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Subscription } from "rxjs"
import { DisposableLike } from "atom"

// convert rxjs Subscription to Atom DisposableLike (rename unsubscribe to dispose)
export function disposableFromSubscription(subs: Subscription): DisposableLike {
return { ...subs, dispose: subs.unsubscribe }
}
268 changes: 268 additions & 0 deletions src-commons-ui/float-pane/FloatPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { DisplayMarker, Decoration, TextEditor, Disposable, DisposableLike, CompositeDisposable, TextEditorElement } from "atom"
import "../../types-packages/atom"
import { Observable, fromEvent } from "rxjs"
import type {Subscription} from "rxjs"
import { disposableFromSubscription } from "../../src-commons-atom/disposable"
import { PinnedDatatipPosition, Datatip } from "../../types-packages/main"

import * as React from "react"
import ReactDOM from "react-dom"
import invariant from "assert"
import classnames from "classnames"

import { ViewContainer, DATATIP_ACTIONS } from "./ViewContainer"
import isScrollable from "./isScrollable"

const LINE_END_MARGIN = 20

let _mouseMove$: Observable<MouseEvent>
function documentMouseMove$(): Observable<MouseEvent> {
if (_mouseMove$ == null) {
_mouseMove$ = fromEvent<MouseEvent>(document, "mousemove")
}
return _mouseMove$
}

let _mouseUp$: Observable<MouseEvent>
function documentMouseUp$(): Observable<MouseEvent> {
if (_mouseUp$ == null) {
_mouseUp$ = fromEvent<MouseEvent>(document, "mouseup")
}
return _mouseUp$
}

interface Position {
x: number,
y: number,
}
export interface PinnedDatatipParams {
onDispose: (pinnedDatatip: PinnedDatatip) => void,
hideDataTips: () => void,
// Defaults to 'end-of-line'.
position?: PinnedDatatipPosition,
// Defaults to true.
showRangeHighlight?: boolean,
}

export class PinnedDatatip {
_boundDispose: Function
_boundHandleMouseDown: Function
_boundHandleCapturedClick: Function
_mouseUpTimeout: NodeJS.Timeout | null = null
_hostElement: HTMLElement = document.createElement("div")
_marker?: DisplayMarker
_rangeDecoration?: Decoration
_mouseSubscription: Subscription | null = null
_subscriptions: CompositeDisposable = new CompositeDisposable()
_datatip: Datatip
_editor: TextEditor
_dragOrigin: Position | null = null
_isDragging: boolean = false
_offset: Position = { x: 0, y: 0 }
_isHovering: boolean = false
_checkedScrollable: boolean = false
_isScrollable: boolean = false
_hideDataTips: () => void
_position: PinnedDatatipPosition
_showRangeHighlight: boolean

constructor(datatip: Datatip, editor: TextEditor, params: PinnedDatatipParams) {
this._subscriptions.add(new Disposable(() => params.onDispose(this)))
this._datatip = datatip
this._editor = editor
this._hostElement.className = "datatip-element"
this._boundDispose = this.dispose.bind(this)
this._boundHandleMouseDown = this.handleMouseDown.bind(this)
this._boundHandleCapturedClick = this.handleCapturedClick.bind(this)

const _wheelSubscription = fromEvent<WheelEvent>(this._hostElement, "wheel").subscribe((e) => {
if (!this._checkedScrollable) {
this._isScrollable = isScrollable(this._hostElement, e)
this._checkedScrollable = true
}
if (this._isScrollable) {
e.stopPropagation()
}
})

this._subscriptions.add(
disposableFromSubscription(_wheelSubscription)
)
this._hostElement.addEventListener("mouseenter", (e) => this.handleMouseEnter(e))
this._hostElement.addEventListener("mouseleave", (e) => this.handleMouseLeave(e))
this._subscriptions.add(
new Disposable(() => {
this._hostElement.removeEventListener("mouseenter", (e) => this.handleMouseEnter(e))
this._hostElement.removeEventListener("mouseleave", (e) => this.handleMouseLeave(e))
})
)
this._hideDataTips = params.hideDataTips
this._position = params.position == null ? "end-of-line" : params.position
this._showRangeHighlight = params.showRangeHighlight == null ? true : params.showRangeHighlight
this.render()
}

// Mouse event hanlders:

handleMouseEnter(event: MouseEvent): void {
this._isHovering = true
this._hideDataTips()
}

handleMouseLeave(event: MouseEvent): void {
this._isHovering = false
}

isHovering(): boolean {
return this._isHovering
}

handleGlobalMouseMove(evt: MouseEvent): void {
const { _dragOrigin } = this
invariant(_dragOrigin)
this._isDragging = true
this._offset = {
x: evt.clientX - _dragOrigin.x,
y: evt.clientY - _dragOrigin.y,
}
this.render()
}

handleGlobalMouseUp(): void {
// If the datatip was moved, push the effects of mouseUp to the next tick,
// in order to allow cancellation of captured events (e.g. clicks on child components).
this._mouseUpTimeout = setTimeout(() => {
this._isDragging = false
this._dragOrigin = null
this._mouseUpTimeout = null
this._ensureMouseSubscriptionDisposed()
this.render()
}, 0)
}

_ensureMouseSubscriptionDisposed(): void {
if (this._mouseSubscription != null) {
this._mouseSubscription.unsubscribe()
this._mouseSubscription = null
}
}

handleMouseDown(evt: MouseEvent): void {
this._dragOrigin = {
x: evt.clientX - this._offset.x,
y: evt.clientY - this._offset.y,
}
this._ensureMouseSubscriptionDisposed()
this._mouseSubscription = documentMouseMove$()
.takeUntil(documentMouseUp$())
.subscribe(
(e: MouseEvent) => {
this.handleGlobalMouseMove(e)
},
(error: any) => {},
() => {
this.handleGlobalMouseUp()
}
)
}

handleCapturedClick(event: SyntheticEvent<>): void {
if (this._isDragging) {
event.stopPropagation()
} else {
// Have to re-check scrolling because the datatip size may have changed.
this._checkedScrollable = false
}
}

// Update the position of the pinned datatip.
_updateHostElementPosition(): void {
const { _editor, _datatip, _hostElement, _offset, _position } = this
const { range } = _datatip
_hostElement.style.display = "block"
switch (_position) {
case "end-of-line":
const charWidth = _editor.getDefaultCharWidth()
const lineLength = _editor.getBuffer().getLines()[range.start.row].length
_hostElement.style.top = -_editor.getLineHeightInPixels() + _offset.y + "px"
_hostElement.style.left = (lineLength - range.end.column) * charWidth + LINE_END_MARGIN + _offset.x + "px"
break
case "above-range":
_hostElement.style.bottom = _editor.getLineHeightInPixels() + _hostElement.clientHeight - _offset.y + "px"
_hostElement.style.left = _offset.x + "px"
break
default:
// ;(_position: empty)
throw new Error(`Unexpected PinnedDatatip position: ${this._position}`)
}
}

async render(): Promise<void> {
const { _editor, _datatip, _hostElement, _isDragging, _isHovering } = this

let rangeClassname = "datatip-highlight-region"
if (_isHovering) {
rangeClassname += " datatip-highlight-region-active"
}

if (this._marker == null) {
const marker: DisplayMarker = _editor.markBufferRange(_datatip.range, {
invalidate: "never",
})
this._marker = marker
_editor.decorateMarker(marker, {
type: "overlay",
position: "head",
class: "datatip-pinned-overlay",
item: this._hostElement,
// above-range datatips currently assume that the overlay is below.
avoidOverflow: this._position !== "above-range",
})
if (this._showRangeHighlight) {
this._rangeDecoration = _editor.decorateMarker(marker, {
type: "highlight",
class: rangeClassname,
})
}
await _editor.getElement().getNextUpdatePromise()
// Guard against disposals during the await.
if (marker.isDestroyed() || _editor.isDestroyed()) {
return
}
} else if (this._rangeDecoration != null) {
this._rangeDecoration.setProperties({
type: "highlight",
class: rangeClassname,
})
}

ReactDOM.render(
<ViewContainer
action={DATATIP_ACTIONS.CLOSE}
actionTitle="Close this datatip"
className={classnames(_isDragging ? "datatip-dragging" : "", "datatip-pinned")}
{..._datatip}
onActionClick={this._boundDispose}
onMouseDown={this._boundHandleMouseDown}
onClickCapture={this._boundHandleCapturedClick}
/>,
_hostElement
)
this._updateHostElementPosition()
}

dispose(): void {
if (this._mouseUpTimeout != null) {
clearTimeout(this._mouseUpTimeout)
}
if (this._marker != null) {
this._marker.destroy()
}
if (this._mouseSubscription != null) {
this._mouseSubscription.unsubscribe()
}
ReactDOM.unmountComponentAtNode(this._hostElement)
this._hostElement.remove()
this._subscriptions.dispose()
}
}
115 changes: 115 additions & 0 deletions types-packages/atom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// TODO add to @types/Atom

export {}

// An {Object} with the following fields:
interface BufferChangeEvent {
// The deleted text
oldText: string

// The {Range} of the deleted text before the change took place.
oldRange: Range

// The inserted text
newText: string

// The {Range} of the inserted text after the change took place.
newRange: Range
}

type HighlightingChangeEvent = (range: Range) => void

declare module "atom" {
interface TextEditor {
// Get the Element for the editor.
getElement(): TextEditorElement

// Controls visibility based on the given {Boolean}.
setVisible(visible: boolean): void

// Experimental: Get a notification when async tokenization is completed.
onDidTokenize(callback: () => any): Disposable

component: {
getNextUpdatePromise(): Promise<unknown>
}

isDestroyed(): boolean

getDefaultCharWidth(): number
}

interface LanguageMode {
// A {Function} that returns a {String} identifying the language.
getLanguageId(): string

// A {Function} that is called whenever the buffer changes.
bufferDidChange(change: BufferChangeEvent): void

// A {Function} that takes a callback {Function} and calls it with a {Range} argument whenever the syntax of a given part of the buffer is updated.
onDidChangeHighlighting(callback: HighlightingChangeEvent): void

// A function that returns an iterator object with the following methods:
buildHighlightIterator(): {
// A {Function} that takes a {Point} and resets the iterator to that position.
seek(point: Point): any

// A {Function} that advances the iterator to the next token
moveToSuccessor(): void

// A {Function} that returns a {Point} representing the iterator's current position in the buffer.
getPosition(): Point

// A {Function} that returns an {Array} of {Number}s representing tokens that end at the current position.
getCloseTags(): Array<number>

// A {Function} that returns an {Array} of {Number}s representing tokens that begin at the current position.
getOpenTags(): Array<number>
}
}

interface TextMateLanguageMode {
fullyTokenized: boolean

// Get the suggested indentation level for an existing line in the buffer.
//
// * bufferRow - A {Number} indicating the buffer row
//
// Returns a {Number}.
suggestedIndentForBufferRow(bufferRow: number, tabLength: number, options: object): number

// Get the suggested indentation level for a given line of text, if it were inserted at the given
// row in the buffer.
//
// * bufferRow - A {Number} indicating the buffer row
//
// Returns a {Number}.
suggestedIndentForLineAtBufferRow(bufferRow: number, line: number, tabLength: number): number

// Get the suggested indentation level for a line in the buffer on which the user is currently
// typing. This may return a different result from {::suggestedIndentForBufferRow} in order
// to avoid unexpected changes in indentation. It may also return undefined if no change should
// be made.
//
// * bufferRow - The row {Number}
//
// Returns a {Number}.
suggestedIndentForEditedBufferRow(bufferRow: number, tabLength: number): number
}

interface TextBuffer {
// Experimental: Get the language mode associated with this buffer.
//
// Returns a language mode {Object} (See {TextBuffer::setLanguageMode} for its interface).
getLanguageMode(): LanguageMode | TextMateLanguageMode

// Experimental: Set the LanguageMode for this buffer.
//
// * `languageMode` - an {Object} with the following methods:
setLanguageMode(languageMode: LanguageMode | TextMateLanguageMode): void
}

interface TextEditorElement {
setUpdatedSynchronously(val: boolean): void
}
}