diff --git a/src-commons-atom/disposable/index.ts b/src-commons-atom/disposable/index.ts new file mode 100644 index 00000000..6401bc94 --- /dev/null +++ b/src-commons-atom/disposable/index.ts @@ -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 } +} diff --git a/src-commons-ui/float-pane/FloatPane.tsx b/src-commons-ui/float-pane/FloatPane.tsx new file mode 100644 index 00000000..59319c3d --- /dev/null +++ b/src-commons-ui/float-pane/FloatPane.tsx @@ -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 +function documentMouseMove$(): Observable { + if (_mouseMove$ == null) { + _mouseMove$ = fromEvent(document, "mousemove") + } + return _mouseMove$ +} + +let _mouseUp$: Observable +function documentMouseUp$(): Observable { + if (_mouseUp$ == null) { + _mouseUp$ = fromEvent(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(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 { + 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( + , + _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() + } +} diff --git a/types-packages/atom.d.ts b/types-packages/atom.d.ts new file mode 100644 index 00000000..afab7b30 --- /dev/null +++ b/types-packages/atom.d.ts @@ -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 + } + + 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 + + // A {Function} that returns an {Array} of {Number}s representing tokens that begin at the current position. + getOpenTags(): Array + } + } + + 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 + } +}